# Auth-Capture
Source: https://waffo.com/docs/en/essentials/auth-capture
Split payments into two steps — authorize funds upfront and capture them only when the transaction is confirmed, reducing fraud risk and refund costs.
Standard payments are typically "Immediate Sale," meaning funds are debited simultaneously as the user pays. Auth & Capture splits the payment process into two distinct steps:
## Core value
## Use cases
**AI & usage-based services**
Ideal for scenarios with high costs and variable fees, such as AI image generation, LLM inference, and cloud compute rental.
| Step | Timing | What happens |
| - | - | - |
| **Auth** | Before task starts | User submits a complex generation task. The system estimates a maximum fee of \$10 and freezes that amount, ensuring the user's card has sufficient funds to cover the compute cost. |
| **Capture** | After task ends | The task actually consumed 5,000 tokens valued at $4.50. The system initiates a capture for $4.50 and automatically releases the remaining \$5.50 credit limit. |
> [!TIP]
>
> **Advantage:** Avoids the poor experience of "Task interrupted due to insufficient balance" while preventing merchants from giving away expensive GPU resources for free.
**E-commerce: shipment model**
The mainstream model for US/EU e-commerce (for example, Amazon).
| Step | Timing | What happens |
| - | - | - |
| **Auth** | At order placement | User places an order for $100; system freezes $100 credit limit. |
| **Capture** | At shipment | Merchant confirms stock and ships the package; system initiates a \$100 capture, and funds are formally debited. |
> [!TIP]
>
> **Advantage:** Avoids financial reconciliation issues and fee losses caused by "User applies for refund immediately after payment" or refunds due to overselling.
**Travel & rentals: deposit model**
Applicable to hotels, car rentals, power bank sharing, and similar services.
| Step | Timing | What happens |
| - | - | - |
| **Auth** | At check-in/rental | Freeze an estimated fee (for example, \$200 deposit). |
| **Capture** | At check-out/return | Initiate a capture for the actual amount based on actual consumption (for example, room rate plus minibar). |
> [!TIP]
>
> **Advantage:** Ensures the user's card has sufficient funds to pay potential fees, preventing bad debt.
## Key capabilities & operations
### Partial capture
Supports capturing an amount less than the original frozen amount for a pre-authorized order.
> [!NOTE]
>
> **Scenario:** AI task estimated at $10, actual consumption $4.50.
>
> **Operation:** Initiate a Capture request for $4.50. The system debits $4.50 and automatically releases the remaining \$5.50 limit.
### Void authorization
You can cancel the transaction at any time before initiating capture.
> [!NOTE]
>
> **Scenario:** AI task failed due to system overload; no actual cost incurred.
>
> **Operation:** Call the Void API.
>
> **Feature:** Zero cost (no processing fees), instant release (user limit restored within minutes) — far superior to refunds, which typically take 3–7 days.
## Integration
### API integration
Set the parameter `capture_method: manual` when creating a payment order to enable pre-authorization mode. The default is `automatic` for immediate sale.
### Webhook notifications
The system sends notifications at the following nodes to help you sync business status:
| Event | Description |
| - | - |
| `payment_intent.authorized` | Credit limit frozen successfully. You can start the AI task or ship goods. |
| `payment_intent.captured` | Funds debited successfully. Funds are ready for settlement. |
| `payment_intent.canceled` | Pre-authorization voided or expired. |
[View API documentation](/docs/api-reference/introduction) for parameter examples and integration guides.
## Frequently asked questions
> **How long is the pre-authorization valid?**
>
> Typically, Visa/Mastercard pre-authorizations are valid for **7 days**. This is usually sufficient for AI services or e-commerce shipping. If you are in a special industry (e.g., long-term rentals), we can assist in applying to extend the pre-authorization window to 30 days.
> **Why don't I see the pre-authorized money in my account?**
>
> Pre-authorization only "freezes" the user's credit limit; the funds are still in the user's bank account and have not been transferred to the merchant. Funds will only begin settlement and enter your account balance after you successfully call the Capture interface.
> **Do I need to refund if the AI task fails?**
>
> No "Refund" is needed because the money hasn't actually been taken. You simply need to call the Void interface or do nothing (wait for auto-expiration), and the user's limit will be restored. This is simpler than the refund process and avoids user confusion over "Charged then Refunded."
> **Can I perform multiple captures on a single authorization?**
>
> Currently, we primarily support Single Capture. Once you initiate a Capture (even for a partial amount), the remaining pre-authorized limit is automatically released. If you run a continuous AI service (e.g., chat stream billed by the minute), we recommend using the Tokenization feature to bind the card and initiate charges periodically (e.g., every hour or upon reaching a \$10 threshold).
***
Need help? [Contact support](mailto:support@waffo.com)
# Checkout
Source: https://waffo.com/docs/en/essentials/checkout
Waffo's standardized checkout product for managing global payment interaction flows across one-time and subscription payment modes.
Waffo Checkout is Waffo's standardized checkout product, designed to unify the management of global payment interaction flows. It enables merchants to provide a secure, smooth, and consistent payment experience for users across various modes, including **One-time Payments** and **Subscription Payments**.
[Start Integration →](/docs/en/developer-docs/getting-started/overview)
[Contact us](mailto:support@waffo.com)
## Core capabilities overview
## Integration modes
**Hosted Checkout**
Waffo hosts and renders the entire checkout page. Your backend creates a payment session and redirects the user to the Waffo-hosted URL.

**Embedded Checkout**
Embed the Waffo Checkout directly into your page using the client SDK or pre-built components, giving you full control over layout and user experience.

## Payment methods showcase
Waffo Checkout dynamically displays available payment methods based on payment mode, region, currency, or business rules. Whether it is a one-time payment or a subscription, the Checkout seamlessly synergizes with the corresponding payment capabilities.
Supported
**430+**
mainstream methods

[View Supported Payment Methods →](/docs/en/essentials/payment-methods)
## Payment flow experience
From order creation to payment completion, Waffo provides a clear, smooth, and standardized interaction flow.
**1. Confirm Order**
The user browses goods or services on the merchant's site and clicks the "Checkout" button. The merchant backend calls the Waffo API to create a Payment Session or Subscription.
* Generates a unique Order ID
* Presets amount and currency

**2. Invoke Checkout**
The merchant frontend invokes the Waffo Checkout via redirect (Hosted) or SDK initialization (Embedded).

**3. Select Payment Method**
The user selects their preferred payment method from the dynamically displayed options. Available methods are filtered by region, currency, and merchant configuration.

**4. Authorize and complete payment**
The user completes authentication and authorization through the selected payment method's native flow (card input, wallet redirect, biometric, etc.).

**5. Result & Notification**
Waffo returns the payment result to the user's browser and sends a webhook notification to the merchant's backend. The merchant updates order status accordingly.

## UI customization options
To ensure the checkout blends seamlessly with your brand, Waffo offers granular customization options. You can control the look and feel via three methods:
* **Merchant Portal:** Visual Configuration, No Code Required. Upload logo, configure brand colors and interface styling, customize payment method order—all with real-time preview in the merchant dashboard.
* **API Parameters:** Server-side dynamic control. Pass configuration parameters when creating a Session to display different interfaces for different user groups (e.g., VIPs).
* **Client SDK:** Frontend fine-grained control. (Embedded only). Define specific CSS properties like fonts, spacing, and border radius via JavaScript objects.

## Multi-language support
Waffo Checkout aims to provide a payment experience that feels like a native local application for global users. To deliver the most precise localization, Waffo Checkout intelligently presets and presents the most suitable interface language based on the region of the selected payment method.
> [!NOTE]
>
> **Example:** When a user selects **Alipay**, the system automatically adapts to a **Simplified Chinese** interface; when selecting **TrueMoney**, it adapts to **Thai**, ensuring users complete payments in their most familiar language environment.
Example languages supported (non-exhaustive): English, Thai, Japanese, Simplified Chinese, Traditional Chinese, Spanish, Bahasa Indonesia, and more.

## Integration
Waffo Checkout can be combined with different payment capabilities to form a complete solution:
* **Using Hosted Checkout:** Support one-time or subscription payments with minimal code.
* **Using Embedded Checkout:** Embed the checkout via SDK or components for higher freedom of experience and customization.
* **With Server API:** Used to create payments or subscriptions, query status, receive notifications, and dynamically control checkout behavior.
[View Development Documentation](/docs/api-reference/introduction) to learn how to integrate Checkout with different payment modes.
## Frequently asked questions
> **Which payment methods does the Checkout support?**
>
> Currently, the Checkout supports 430+ global payment methods. The specific methods available may vary based on region and merchant configuration.
>
> See the full list on the [Payment Methods](/docs/en/essentials/payment-methods) page.
> **Does the Checkout meet payment security and compliance requirements?**
>
> Yes, the Checkout complies with relevant payment security and compliance standards (such as PCI DSS) and processes sensitive payment information via secure channels.
> **How does the Checkout handle payment failures?**
>
> If a payment fails, the Checkout page displays the reason for the failure. Users can retry the payment or switch to a different credit card. Common failure reasons include insufficient funds, incorrect card information, or transaction decline by the issuing bank.
***
Need help? [Contact support](mailto:support@waffo.com)
# Data Intelligence
Source: https://waffo.com/docs/en/essentials/data-intelligence
End-to-end data intelligence that translates obscure bank codes into actionable growth strategies.
## Overview
In cross-border payments, data is not just about reporting—it's hidden profit. The global payment landscape is complex and diverse: user habits vary by country, payment methods span cards, local wallets, and BNPL; and bank rules, risk strategies, and network stability differ vastly. Unlike the highly unified domestic payment environment with high success rates, cross-border payments require deep analysis of "Why it failed," not just "Whether it succeeded."

The **Waffo Data Intelligence Center** breaks down data silos between "One-time Payments" and "Subscription Payments," as well as "Bank Cards" and "Local Wallets." We provide merchants with multi-layered, panoramic diagnostic services, translating obscure bank codes into actionable growth strategies. We help merchants answer three core questions: Is my payment health robust? Why are transactions failing? How can I recover lost customers?
***
## Monitoring & Omnichannel Funnel
Waffo monitors not just payment outcomes, but the entire user payment journey, especially for complex hybrid payment scenarios.
### Real-Time Health Dashboard
The Real-Time Health Dashboard provides immediate visibility into your payment performance:
Second-Level Response
Whether it's the Black Friday rush or daily operations, track minute fluctuations in Auth Rate, TPV (Total Payment Volume), and Conversion Rate in real-time.


### Omnichannel Conversion Funnel
Addressing the distinct payment logic between Bank Cards (Direct) and E-Wallets/BNPL (Redirect), we provide a unified funnel view:
* **End-to-End Monitoring:** Tracks the complete journey from "Merchant Invokes Checkout", each step has clear metrics, helping you reconstruct the real user path from entry and selection to operation and completion.

* **Drop-off Diagnosis:** Data clearly tells you whether user churn is due to failure to enter the checkout (technical issue) or abandonment during method selection/info entry (experience or intent issue), guiding targeted optimization of redirect links, payment lists, or interaction flows.

***
## Drill-down & Smart Attribution
When success rates fall below expectations, Waffo provides powerful analysis tools to locate the "root cause."
### Multi-Dimensional Drill-Down
* **Geo & Issuer:** Precise analysis down to specific Issuing Banks (BIN).
> [!NOTE]
>
> **Scenario:** Overall success rate is normal, but a specific "Brazilian Bank" has a 40% failure rate? This prompts you to optimize routing or installment strategies for that bank.
* **Method Comparison:** Horizontal comparison of Credit Card vs. Local Wallet performance. Data might show that pinning "Apple Pay" to the top in specific countries drives higher GMV.
### Smart Code Mapping
Bank returns like **"Do Not Honor"** or **"Error 05"** are no longer black boxes. Waffo intelligently maps thousands of global raw decline codes into binary attribution categories, allowing your team to quickly decide on next steps, reducing ineffective troubleshooting and improving resolution efficiency.
***
## Subscription & Retention Health
For SaaS, Membership, and Recurring Billing merchants, we focus on reducing Churn and increasing LTV (Lifetime Value).
### Churn Attribution Analysis
Waffo clearly distinguishes between two distinct types of churn:
> [!NOTE]
>
> **Voluntary Churn:** User manually cancels the subscription. (Requires product experience optimization)
> [!WARNING]
>
> **Involuntary Churn:** User did not cancel, but payment failed due to expired cards or insufficient funds. (Requires payment strategy optimization) This is the key pain point Waffo helps merchants solve.
### Recovery Performance Quantification
**Recovered Revenue:** Intuitively displays how much revenue—originally destined to fail—was automatically saved this month through Waffo's Smart Retry and Account Updater mechanisms.

**Best Retry Strategy:** Analysis might show that retrying on "Day 3 after failure" or "End of Month" yields the highest success rate, guiding the system to automatically adjust Dunning logic.

### Cohort Analysis
Evaluate user lifecycle and channel quality from multiple dimensions:
* **First vs. Renewal Conversion:** Distinguish between new user first-order conversion and existing user renewal conversion to locate bottlenecks at different stages.
* **Retention Heatmap:** Track the 3rd-month and 6th-month retention performance of users acquired in different months via different channels, helping marketing teams evaluate acquisition quality.
***
## Frequently asked questions
> **What is "Auth Rate" and how does Waffo calculate it?**
>
> The formula is: Successful Transactions / Total Attempted Transactions. Waffo's advanced analytics supports excluding non-systemic factors like "User Input Errors" or "Voluntary Cancellations" to present a more referenceable Technical Success Rate.
> **How can data help me with "Do Not Honor" declines?**
>
> This is the most common generic decline. Waffo data often reveals that enabling 3DS Authentication in specific high-risk scenarios can significantly reduce this error rate. Our system will suggest dynamic adjustments to risk strategies based on this, converting declines into successes.
> **Why is "Involuntary Churn" analysis important?**
>
> Industry data shows that 20%-40% of subscription churn stems from payment failures rather than user intent. Solving this is the fastest, lowest-cost way to increase LTV.
> **Is your data real-time?**
>
> The Real-Time Health Dashboard provides second-level updates, ideal for monitoring during big sales events. Deep Attribution and Cohort Analysis are typically updated on a T+1 basis to ensure data cleaning and reconciliation accuracy.
***
Need help? [Contact support](mailto:support@waffo.com)
# Dispute and Chargeback
Source: https://waffo.com/docs/en/essentials/dispute-chargeback
A systematic, tiered closed-loop defense system covering three core stages: Chargeback Alert, Customer Dispute, and Chargeback.
## Overview
In cross-border and local payment operations, post-transaction disputes and fund recovery risks are critical operational challenges for merchants. The Waffo Dispute & Chargeback Management Platform has built a systematic, tiered closed-loop defense system to address this. This system covers **three core stages**, responding to different phases of risk evolution:
**Chargeback Alert**
Concept
An early risk signal notification provided by card schemes or third-party networks (e.g., Visa RDR, Mastercard Ethoca) after a cardholder complains to the issuing bank but before the bank formally initiates the chargeback process.
**Role & Importance**
This is the most proactive and cost-effective defense stage. Receiving and acting on alerts means resolving disputes before the bank opens a case, preventing the transaction from escalating into a formal chargeback record. This effectively avoids bank fines and protects the merchant's chargeback ratio and account health.
**Customer Dispute**
Concept
A formal information inquiry process initiated by the issuing bank or payment channel. The bank requests the merchant to provide transaction evidence to verify authenticity. At this stage, funds have typically not yet been debited.
**Role & Importance**
This is the negotiation and evidence clarification stage. Timely and accurate response to dispute requests by providing sufficient proof of transaction (e.g., proof of delivery, user authorization logs) is key to successfully blocking case escalation and avoiding financial loss. Effective dispute handling significantly reduces the probability of conversion to formal chargebacks and prevents escalation due to non-response.
**Chargeback**
Concept
The formal process where the issuing bank forcibly debits the disputed amount from the merchant's account after the cardholder or issuer formally contests the transaction. This process follows strict rules and timelines and incurs handling fees.
**Role & Importance**
This is the final decision and fund execution stage of risk management. Here, merchants must submit compelling evidence to defend the chargeback (Representment) and recover funds. Systematic chargeback management directly impacts the merchant's fund recovery rate, operational costs (fines and fees; the total cost of a chargeback is often 2–3× the order value), and reconciliation accuracy.
The Waffo Platform integrates these three stages into a unified "Risk Sensing - Negotiation Response - Formal Defense" standardized closed loop. Through this system, we help merchants proactively intervene throughout the risk evolution chain, ultimately achieving the core goals of reducing financial loss, minimizing operational leakage, and improving processing efficiency and compliance.
***
## Chargeback alert
Providing a golden window for resolution to achieve optimal cost-efficiency before risks solidify into formal cases.
### Scenario description
When a cardholder questions a credit card transaction and contacts the issuing bank to complain, but the bank has not yet formally initiated the chargeback process, card schemes or third-party networks (e.g., Visa RDR, Mastercard Ethoca) generate alert signals. The transaction is in a non-mandatory negotiation window. Merchants have a golden processing time of **24-72 hours** to intervene proactively and prevent the case from being formally established.
### Core capabilities
### Merchant value
***
## Customer dispute
Effectively clarifying facts through standardized, professional evidence submission during the bank inquiry stage to stop cases from entering fund debit.
[Request the dispute handling operations manual →](mailto:support@waffo.com)
### Scenario description
The issuing bank or payment channel has formally initiated an information inquiry process, requiring the merchant to provide transaction vouchers to verify authenticity. This stage typically manifests as a "Retrieval Request" or "Request for Information (RFI)." Funds have not yet been debited, but the merchant must provide sufficient evidence within the specified timeframe, otherwise, the case may escalate to a formal chargeback.
### Core capabilities
### Merchant value
***
## Chargeback
Providing professional, efficient, full-process defense support to fully recover debited funds when cases enter the final adjudication stage.
[Request the chargeback handling operations manual →](mailto:support@waffo.com)
[Waffo Chargeback Management API: View Documentation →](/docs/api-reference/introduction)
### Scenario description
The issuing bank or payment channel has formally initiated the chargeback process. Funds have been debited from the merchant account. The merchant must submit a formal defense (Representment) within the specified timeframe, providing complete evidence to request the card scheme to adjudicate and reverse the debit.
### Core capabilities
### Merchant value
***
## Frequently asked questions
> **What is the fundamental difference between Alerts, Disputes, and Chargebacks?**
>
> * **Alert:** A "Pre-emptive Opportunity." The bank has not opened a formal case. The goal is "Case Elimination."
> * **Dispute:** A "Mid-stream Negotiation." The bank has opened an inquiry. The goal is "Provide Evidence, Persuade Closure."
> * **Chargeback:** A "Post-event Adjudication." The bank has debited funds. The goal is "Submit Defense, Recover Funds."
> **Why did I receive a Chargeback directly without going through the Dispute stage?**
>
> This is mainly due to two reasons:
>
> * Not all issuing banks support the Alert or Retrieval Request workflow; some banks prefer to initiate a Chargeback directly.
> * Card schemes' Early Fraud Warning (EFW) systems and Dispute processing systems operate independently. Information may not sync, causing an Alert to arrive later than the Chargeback.
> **How can I improve the success rate of defense during the Chargeback stage?**
>
> Success depends on the quality and compliance of the evidence. The Waffo platform provides structured checklist guidance based on Reason Codes and advises merchants to ensure evidence is clear, complete, and targeted (e.g., providing proof of delivery with signature, clear proof of transaction authorization) to meet the issuing bank's review standards. Systematic processes also prevent technical failures due to timeouts or formatting errors.
> **Can Chargeback-related fees be avoided?**
>
> Once a formal Chargeback process begins, handling fees charged by the issuing bank or card scheme are typically non-refundable. Therefore, the most effective cost control strategy lies in proactive defense—fully utilizing the Alert and Dispute stages to resolve issues and preventing Chargebacks at the source.
> **Are Chargeback handling fees refundable? What if I win the Arbitration?**
>
> Generally, no. Once a formal Chargeback process is entered, the handling fee charged by the bank covers administrative costs and is not refunded regardless of the defense outcome. However, if a case proceeds to the Arbitration stage and the merchant wins, the high arbitration fees may potentially be refunded or borne by the losing party.
***
Need help? [Contact support](mailto:support@waffo.com)
# Merchant Portal
Source: https://waffo.com/docs/en/essentials/merchant-portal
A one-stop payment management platform for cross-border enterprises. Manage transactions, settlements, analytics, and team access in one place.
The Waffo Merchant Portal is a one-stop, secure, and intelligent payment management platform designed for cross-border enterprises. It serves as your control center, empowering global expansion, refined operations, and fund visibility. No matter where you are, you can manage the entire process from transaction acceptance to account configuration in real-time.
## Key solutions
## Home & Operations
This is your "all-in-one center" for daily work, driving business decisions through data.
### Home: the "real-time monitor" of business pulse
* **Dynamic Dashboard:** Displays trends for Payments and Refunds over the last 15 days by default.
* **Multi-dimensional View:** Supports aggregated views of transaction counts and amounts across different currencies.
* **Value:** Eliminates information lag for operators, allowing immediate grasp of performance fluctuations and rapid strategy adjustment through refund trend monitoring.

### Transaction management: traceability & analytics
* **Deep Retrieval & Export:** Filter documents by status, time, and payment method across multiple dimensions, with one-click CSV export for offline analysis.
* **Payment Analytics:** Visualize success rates, checkout conversion rates, and payment conversion rates with trend charts and failure analysis.
* **Disputes:** Centralized display of credit card chargeback cases. The system provides "to-do" reminders and online evidence submission to help merchants recover funds efficiently.

## Subscription: the "automatic deposit" for recurring business
Manage the full lifecycle of recurring billing — from plan creation to renewal tracking.
### Subscription management
* **Subscription Tracking:** View active, paused, and cancelled subscriptions with filtering by plan, status, and billing cycle.
* **Plan Configuration:** Define subscription intervals (daily, weekly, monthly, annual) and pricing tiers for different customer segments.
* **Note:** Subscription billing is automatically retried on failure. Configure retry logic and dunning settings in the portal.
## Settlement & Payout
Creating a "Settlement Map" for financial reconciliation and a "Control Tower" for fund flow.
### Settlement management: removing the "black box"
* **Settlement Calendar:** Visually maps "which days' revenue will arrive on which day" and automatically marks the impact of public holidays.
* **Settlement Estimation:** Scientifically predicts the specific arrival amount of future funds based on current unsettled orders to assist in cash flow planning.
* **Batch Reconciliation:** Provides daily aggregated entry summaries linked to underlying transaction details, achieving precise matching with bank statements and significantly shortening monthly reconciliation time.

### Accounts & payouts: flexible fund flow
* **Self-service Withdrawal:** Initiate payouts to registered bank accounts on demand, with configurable minimum thresholds and approval workflows.
* **Merchant Balance:** View real-time balance across currencies and track historical withdrawal transactions.
* **Batch Payouts:** Upload batch payout files to pay multiple recipients simultaneously. Download templates for IDN and PHL markets directly from the portal.

## Settings & Security
A "Toolbox" for technical management to ensure enterprise-level security and compliance.
### Integration & environments
* **Dual-Environment Isolation:** Provides **Live (Production)** and **Sandbox** environments. Test data and operational data are strictly isolated to protect business security.
* **Developer Resources:** One-stop access to API Keys (public/private keys) and Webhook configurations to shorten system integration cycles.
### Team permissions (RBAC)
* **Member Management (Operators):** Facilitates the invitation of team members and the assignment of predefined roles.
Roles available in the portal:
| Role | Permissions |
| - | - |
| **Admin** | Full management permissions. |
| **General** | Daily payment operations, financial reconciliation, and withdrawal operations. |
| **Dispute Manager** | Manage and respond to chargeback cases. |
| **Dev** | Specifically designed for technical teams to focus on system integration and debugging. |
> [!NOTE]
>
> **Value:** Ensures that customer service can only view data and cannot move funds, achieving enterprise-level internal control.
## Frequently asked questions
> **How do I start trying the Waffo Portal?**
>
> You can contact a sales consultant for Demo Data to experience a full-function simulation, or [register on Waffo.com](https://dashboard-sandbox.waffo.com/auth/sign-up) to immediately open a test account (Sandbox) for quick API integration.
> **Is "Settlement Estimation" accurate?**
>
> Yes, it is highly reliable. The system calculates expected arrival dates for unsettled funds in real-time based on your contracted cycle (e.g., T+2, T+7) and global bank holiday data.
> **What should I do if I encounter a Chargeback?**
>
> Immediately go to the Transaction > Disputes module. The system will show pending cases and deadlines. Simply upload logistics proof or communication records as prompted, and Waffo will automatically submit the appeal to the card organization.
> **Why can't I see the "Withdraw" or "API Key" buttons?**
>
> This is due to permission controls. If your role is Read-only or Dispute Manager, sensitive fund operations and technical configurations are hidden. Please contact your Admin to adjust your permissions.
***
Need help? [Contact support](mailto:support@waffo.com)
# Merchant Settlement
Source: https://waffo.com/docs/en/essentials/merchant-settlement
Intelligent and transparent global fund settlement service that automatically converts multi-currency transactions and optimizes merchants' cash flow via flexible settlement strategies.
## Overview
Provide cross-border merchants with intelligent and transparent global fund settlement solutions. Leveraging flexible and efficient settlement mechanisms, we consolidate merchants' multi-currency transactions into designated unified currencies for settlement. We standardize settlement procedures and billing statements, simplify complicated overseas settlement rules, greatly reduce difficulties in financial management and account reconciliation, and help merchants efficiently manage global capital and streamline cross-border business income and expenditure.
## Core pain points solved
## Use cases
Cross-border E-commerceSelling in multiple global markets and needing to aggregate revenue from various regions into a headquarters account.


SaaS & Digital ServicesProviding software subscriptions to global users requiring stable cash flow to support business expansion.

Content PlatformsManaging ad revenue and paid memberships involving multiple currencies, requiring transparent exchange rate conversion.
***
## Smart settlement strategy
### Flexible settlement cycles
We provide differentiated settlement rhythms based on the specific funding needs of different merchants:
T+1 Express Settlement
Ideal for merchants with high capital turnover requirements
Funds arrive 1 business day after the transaction.
T+10 Standard Settlement
Suitable for most cross-border merchants
Balancing capital efficiency with risk control requirements.
Weekly Settlement
Designed for large enterprises
Unifying settlement on a weekly basis to facilitate financial reconciliation.
> [!NOTE]
>
> *Settlement cycles can be personalized based on merchant credit ratings, transaction history, and other factors.*
### Multi-currency unified management (aggregation)
Whether your customers pay in USD, IDR, JPY, or other currencies, we aggregate and convert them into your designated settlement currency.
* **Smart Currency Matching:** Based on merchants' preset pricing currency and customers' actual transaction regions, the system automatically matches local currencies for collection conversion to accommodate payment habits across different regions.
* **Unified Settlement Statement:** The settlement statement displays amounts in both original transaction currency and final settlement currency, clearly presenting dual-currency data to fully meet merchants’ daily financial bookkeeping and business analysis needs.

***
## FX services & transparent pricing
### Real-time transparent FX
* **FX Benchmarking:** We use market rates from multiple mainstream banks and professional FX providers as our pricing benchmark to ensure fairness and timeliness.
* **Rate Transparency:** Merchants can query the actual exchange rate used for every transaction in the **Settlement Bills** section of the Dashboard, ensuring complete transparency.
### Smart FX execution
* **Automated Conversion:** The system automatically performs currency conversion at the prevailing rate during settlement, eliminating the need for manual merchant intervention.
* **Batch Optimization:** For multiple transactions in the same currency within a single settlement cycle, the system consolidates them for conversion to reduce spread costs.
***
## Fund security & smart monitoring
### Smart reserve management
To balance risk control with merchant cash flow needs, we employ a dynamic reserve strategy:
* **New Merchant Probation Period:** A rolling security deposit is automatically reserved during the initial settlement phase to maintain effective risk control.
* **Credit-based Adjustment:** The security deposit ratio will be gradually reduced as merchants build up their credit history.
* **Smart Release:** Once the reserve reaches the approved limit or the holding period expires, subsequent funds are settled without further holding, maximizing available capital.
### Automated exception handling
* **Refund Netting:** When a refund occurs, the system automatically deducts the corresponding amount from the pending settlement balance, avoiding cash flow confusion.
* **Chargeback Withholding:** In the event of a credit card chargeback, the system temporarily withholds settlement of the disputed funds until the dispute is resolved.
* **Risk Alerts:** Real-time monitoring of abnormal transaction patterns to provide early warnings of potential funding risks.
***
## Access & API integration
### Visual dashboard
The Merchant Portal provides an intuitive settlement management interface:

### API integration

[View API Documentation](/docs/api-reference/introduction) for parameter examples and integration guides.
***
## Frequently asked questions
> **Can multi-currency transactions be supported under single-currency pricing?**
>
> **Yes.** Based on the preset pricing currency set by merchants and the actual transaction regions of end users, the system automatically matches local currencies to complete collection conversion, adapting to payment habits in various regions.
> **Are exchange rates real-time? Can I lock in rates?**
>
> Our rates are based on real-time market rates from major financial institutions. For large-value transactions or FX-sensitive merchants, we offer an **"FX Locking"** service, allowing you to enjoy a fixed exchange rate within a specific time window.
> **When will the Rolling Reserve be released?**
>
> If you apply to terminate cooperation, the reserve will be returned merged with the final settlement payment, provided the following conditions are met:
>
> 1. All transactions have been settled
> 2. There are no pending disputes or chargebacks
> 3. All fees have been cleared
>
> The entire return process is typically completed within **30 business days**.
***
Need help? [Contact support](mailto:support@waffo.com)
# One-time Payment
Source: https://waffo.com/docs/en/essentials/one-time-payment
One integration, global payment methods covered — every transaction clear and in control.
## Overview
Providing clear and stable payment capabilities for individual transactions. Through a unified API interface, we support a wide range of global payment methods, helping merchants complete every transaction efficiently.
[Start Integration →](/docs/en/developer-docs/getting-started/overview)
[Contact Sales](mailto:support@waffo.com)
***
## What is One-time Payment?
One-time Payment refers to a payment model where a fee is charged for a single, standalone transaction. In this model, every payment is independent, involving no recurring charges or automatic renewals. Due to its clear structure and direct process, it is the most fundamental and common form of payment, applicable to a wide range of business scenarios.
## Core capabilities
***
## Supported payment methods
With Waffo, integrate once to support multiple mainstream payment methods—no need to develop separately for each one.
[View full list of supported payment methods →](/docs/en/essentials/payment-methods)
***
## Integration mode
Waffo offers three flexible integration modes to meet diverse business scenarios and technical requirements. The differences between these modes are as follows:
***
## Security & compliance
Waffo follows the strictest global financial data security standards to ensure the safety of every transaction.
> [!NOTE]
>
> Waffo protects payment data with tokenization, encrypted transmission, and strict access controls designed for PCI DSS-aligned payment flows.
[Contact support for security and compliance details →](mailto:support@waffo.com)
***
## Frequently asked questions
> **Does One-time Payment support refunds?**
>
> Yes (subject to the specific payment method's capabilities). Most mainstream payment methods support refunds. However, whether full or partial refunds are supported strictly depends on the rules and capabilities of the underlying payment method.
> **How do I query transaction records?**
>
> All transaction data is synced in real-time to the Waffo Dashboard. It supports filtering by time, amount, and status, as well as exporting reports in CSV format.
> **How should payment failures be handled?**
>
> Waffo returns specific failure reason codes (e.g., "Insufficient Funds," "Declined by Issuer"). We recommend that merchants prompt users on the front end to switch payment methods and retry.
***
Need help? [Contact support](mailto:support@waffo.com)
# Payment Methods
Source: https://waffo.com/docs/en/essentials/payment-methods
Connect your business to mainstream global and distinctive local payment methods through a single, unified API interface.
Waffo is dedicated to connecting your business with mainstream global and distinctive local payment methods through a single, unified API interface. This ensures you can provide the payment experience your customers expect in any market.
## Core advantages
## Specific payment methods
## Frequently asked questions
> **What payment methods does Waffo support?**
>
> Waffo is committed to building an omni-channel payment matrix for global merchants, supporting the following core payment products:
>
> * **International Credit & Debit Cards:** Deep integration with global card networks including Visa, Mastercard, American Express, JCB, Discover, Diners Club, UnionPay International, and more.
> * **Digital Wallets:** Fast access to Apple Pay, Google Pay, PayPal, as well as leading Asian wallets like WeChat Pay and Alipay.
> * **Buy Now, Pay Later (BNPL):** Supports installment tools like Paidy, Tendopay, Billease, significantly improving conversion rates for high-value orders.
> * **Local Bank Transfers:** Covers iDEAL, Sofort, Bancontact, Giropay in Europe, and real-time bank transfer services in Southeast Asia and Latin America.
> * **Real-time Payments:** Such as Pix, FPS, etc.
> * **Over-the-Counter (OTC):** Deep reach into emerging markets like Brazil (Boleto/PIX), Mexico (OXXO), and Southeast Asia (various local wallets & 7-Eleven counter payments), catering to consumers accustomed to paying via cash or local transfers, greatly boosting overseas order conversion.
> * **Prepaid Cards & Vouchers:** Supports various mainstream prepaid cards and voucher channels.
> **How secure is Waffo's payment system?**
>
> Security is Waffo's lifeline. We have achieved PCI DSS (Payment Card Industry Data Security Standard) Level 1 Compliance. Through industry-leading encryption technology and anti-fraud systems, Waffo effectively safeguards data security for every transaction, building a trustworthy payment environment for merchants and consumers.
> **Does Waffo support Subscription Payment / Recurring Billing?**
>
> Yes, Waffo provides comprehensive subscription management and recurring billing services. Merchants can flexibly customize based on business needs:
>
> * **Flexible Billing Cycles:** Customize trial periods and formal subscription cycles (Daily/Weekly/Monthly/Yearly).
> * **Automated Billing:** The system automatically handles subsequent deductions, reducing manual intervention costs.
> * **Churn Recovery:** Provides smart failure retry mechanisms to improve renewal success rates, helping businesses achieve sustainable revenue.
> **What are the fees for online payments?**
>
> Waffo adopts a customized pricing strategy based on business scale and risk level. To match your business with the optimal rate plan, [contact the Sales Team](mailto:support@waffo.com) and we will provide you with a detailed quotation.
***
Need help? [Contact support](mailto:support@waffo.com)
# Point Topup
Source: https://waffo.com/docs/en/essentials/point-topup
Waffo Point Topup is a point-card top-up API and white-label point-card top-up solution. Suppliers connect their point-code inventory or top-up capability through an API or a single URL redirect, with no storefront or payment work of their own.
**Waffo Point Topup** is Waffo's point-card top-up API and white-label point-card top-up solution. Suppliers — game publishers and point-code issuers — connect their point-code inventory or top-up capability through an API integration or a simple URL redirect, with no storefront or payment work of their own.
Waffo Point Topup then runs the entire end-user-facing side on the supplier's behalf: a multi-lingual storefront on a dedicated subdomain, global payment methods, merchant-of-record for the sale, and regular settlement back to the supplier.
> [!NOTE]
>
> This page covers the product. For the technical integration specification, see the [Point Topup integration overview](/docs/en/developer-docs/point-topup/overview).
## Core value
The Waffo Point Topup API and solution complements Waffo's payment solution. It reduces the complexity of payment integration for suppliers and acts as a tool for monetization and revenue maximization.
## Three integration modes
Waffo Point Topup supports three integration modes, covering suppliers with different technical setups.
| Mode | Name | API required | Best for |
| - | - | - | - |
| **A** | Offline point code import | No API | Quick launch without API integration |
| **B** | Point code retrieval API | Supplier provides API | Point code security and expiration control by the supplier |
| **C** | Direct fulfillment API | Supplier provides API | User experience and conversion rate, with direct fulfillment |
#### Online demo site
To see how the three modes work in practice, walk through the end-to-end flows on the demo site.

## End-user experience
**1. Storefront page**
Mode A and Mode B land on the storefront page after the URL redirect.
* Users browse and select an item, then enter an email address
* Users select a payment method; 20+ countries and regions are supported, each with its own site code
* Custom face value point code purchase is supported, for example any amount between 1000 and 50000 points
* An FAQ section explains how to use the point code and its expiration, where applicable
* A customer support link and email address cover the case where a user cannot receive the point code, for example after entering a wrong email address
* **Logo and page style can be customized to the supplier's requirements**

**2. Order confirmation page**
All modes pass through the order confirmation page before checkout. The responsive design behaves consistently on desktop and mobile browsers. Clicking "Pay Now" continues to checkout.

**3. Waffo checkout**
The user completes payment on the Waffo checkout page.

**4. Payment success page and return**
Users can download the purchase invoice or receipt, which helps if a claim or dispute comes up later. Clicking "Return" sends the user back to the supplier's page, which can be a point code redeem page or an account balance page.

## Settlement and reporting
Waffo Point Topup provides regular settlement reports containing:
* Total points sold and total amount
* Revenue breakdown by SKU
* Remaining inventory status
* Settlement amount
## Next steps
# Waffo Product Overview
Source: https://waffo.com/docs/en/essentials/product-overview
Building global payment infrastructure. A full-stack solution from acquiring to growth.
## Acquiring & Payments
Connect to global payment networks with flexible collection methods tailored to diverse business models.
### One-time Payment
Standard immediate transaction model (Sale), supporting major global credit cards and local payment methods.
[Explore One-time Payment Solutions →](/docs/en/essentials/one-time-payment)
***
### Subscription & Recurring Payment
A recurring billing engine built for SaaS and membership businesses.
> [!TIP]
>
> **Use Cases:** Software subscriptions, membership renewals, recurring orders.
[Explore Subscription Solutions →](/docs/en/essentials/subscription-recurring)
***
### Hosted Checkout
Out-of-the-box hosted payment pages requiring no frontend coding to launch.
[View Checkout →](/docs/en/essentials/checkout)
***
### Payment Methods
One-stop access to global mainstream payment channels.
[Explore Payment Methods →](/docs/en/essentials/payment-methods)
***
## Settlement & Fund Management
A transparent, compliant global fund and tax management system, eliminating cross-border operational worries.
### Merchant Settlement
A flexible global fund distribution network.
[Explore Merchant Settlement →](/docs/en/essentials/merchant-settlement)
***
### Global Tax (MoR)
Automated tax handling service based on the Merchant of Record (MoR) model.
> [!TIP]
>
> **Value:** Operate compliantly without registering local entities overseas, completely avoiding tax risks.
[Explore Global Tax Compliance →](/docs/en/essentials/tax)
***
## Value-Added Services
Beyond payments—empowering business growth through data insights and risk management.
### Data Analysis
A panoramic payment health monitoring and diagnostic center.
[Explore Data Intelligence →](/docs/en/essentials/data-intelligence)
***
### Merchant Portal & Dispute Management
An integrated visual dashboard that serves as both an operation center and a risk defense stronghold.
[Explore Dispute Defense →](/docs/en/essentials/dispute-chargeback)
***
## Advanced Features
Deep customization capabilities for complex business scenarios, optimizing experience and risk control through technology.
### Tokenization
Financial-grade data security hosting and lifecycle management technology.
[Explore Tokenization Technology →](/docs/en/essentials/tokenization)
***
### Auth & Capture
A flexible transaction model separating "Freeze" and "Charge" steps.
> [!TIP]
>
> **Use Cases:** AI Usage-Based Billing, E-commerce Shipment Model, Hotel/Rental Deposits.
[Explore Auth & Capture Mechanism →](/docs/en/essentials/auth-capture)
***
Need help? [Contact support](mailto:support@waffo.com)
# Subscription & Recurring
Source: https://waffo.com/docs/en/essentials/subscription-recurring
Providing automated, high-success-rate billing solutions for recurring payment scenarios. Through a mechanism of one-time authorization and multiple automated deductions, we support flexible billing cycle configurations and smart retries.
## Overview
Providing automated, high-success-rate billing solutions for recurring payment scenarios. Through a mechanism of "one-time authorization, multiple automated deductions," we support flexible billing cycle configurations and smart retries. This helps merchants achieve sustained growth in subscription revenue while optimizing the user experience.
[Start Integration →](/docs/en/developer-docs/getting-started/overview)
[Contact Sales](mailto:support@waffo.com)
***
## What is Subscription Payment?
**Subscription Payment**, also known as automatic billing or recurring billing, is a payment model where users sign an agreement/authorization during the initial payment, and the system automatically initiates deductions based on an agreed cycle (e.g., weekly/monthly/yearly).
By automating the "payment action," it significantly reduces the friction of renewal operations, serving as the core infrastructure for SaaS services, streaming memberships, and content monetization businesses.
## Core capabilities
***
## Use cases
***
## Supported payment methods
Through Waffo, you can access mainstream payment methods that support "Credentials on File" and "Merchant Initiated Transactions (MIT)."
**Bank Cards (Credit/Debit Cards)**
Key security features for bank cards:
* **Tokenization Security** — A token is created upon the first payment. Subsequent deductions use MIT (Merchant Initiated Transaction) mode, requiring no CVV re-entry.
* **3DS Smart Verification** — Mandatory 3DS verification for the initial signing ensures legal authorization, while subsequent auto-debits are exempted to balance security and experience.
* **Auto-Update** — Supports VAU (Visa Account Updater) and ABU (Automatic Billing Updater).
**Digital Wallets**
* **Native Experience** — Utilize device biometrics for the initial authorization, providing a frictionless user experience.
* **Token Status Awareness** — Receive real-time notifications on token status changes (e.g., updates, suspensions, restorations) to maintain subscription continuity.
**Local Payment Methods**
Supports subscription binding and auto-debit capabilities, aligning with local user habits.
[View full list of supported payment methods →](/docs/en/essentials/payment-methods)
***
## Subscription models & experience
Waffo Subscription Payment offers flexible business model configurations to adapt to different commercial strategies.
### Core business models
You can select the subscription model via the Dashboard or API:
***
## Trial & promotion configuration
Free TrialsSupports 0-amount pre-authorization (immediately reversed after verifying card validity) to lower the barrier to entry.
Discounted TrialsSupports setting special prices for the first N periods, automatically reverting to the original price thereafter.
***
## Deduction & retry logic
The system automatically handles the billing flow to maximize success and minimize manual intervention.
**Billing Flow**
**1. Expiry Detection**
The system detects when a billing cycle is due and prepares the deduction task.
**2. Generate MIT Order**
A Merchant Initiated Transaction order is created using the stored payment credentials.
**3. Initiate Deduction**
The deduction is submitted to the payment network for processing.
**4. Success notification / Failure retry**
On success, a confirmation notification is sent. On failure, the retry strategy is triggered.
> [!CAUTION]
>
> **Failure Handling**
> Default strategy retries 24 hours after failure, and again 48 hours later (parameters configurable). Supports triggering automatic payment failure notifications via email to guide users to update their payment method.
***
## Integration
We provide multiple integration methods to help you seamlessly embed subscription capabilities into your business system.
### Hosted checkout
Scenario:
Teams with limited development resources who need to launch subscription features quickly.



### API integration
The following core APIs are available for subscription management:
| Endpoint | Description |
| - | - |
| `POST /api/v1/subscription/create` | Create subscription order, initiate signing. |
| `POST /api/v1/subscription/cancel` | Cancel subscription. |
| `POST /api/v1/subscription/change` | Support plan upgrades/downgrades. |
When you call `POST /api/v1/subscription/create`, include `subscriptionManagementUrl` for every subscription. This URL should point to your authenticated subscription management page.
Complete control over the frontend interaction flow; use Webhooks to handle subsequent logic.
[View Subscription Payment API documentation →](/docs/api-reference/introduction)
***
## Security & compliance
Waffo follows global financial data security standards to ensure the safety of recurring billing.
***
## Frequently asked questions
> **What is 0-amount pre-authorization?**
>
> For "Free Trial" scenarios, the system needs to verify the validity of the payment method. We initiate a 0-amount pre-authorization (Verify). If certain banks do not support this, the system automatically degrades to a low-amount (e.g., 1 unit) authorization and immediately voids/reverses it to ensure the user's card is real and valid.
> **How does the payment failure retry mechanism work?**
>
> Under the default strategy, the system automatically retries 24 hours after the 1st failure, and again 48 hours after the 2nd failure. After the 3rd failure, the subscription is frozen, and the service is suspended. You can customize the maximum retry count and time intervals in the Dashboard.
> **How are card changes (e.g., expiration, replacement) handled?**
>
> The system integrates Visa Account Updater and Mastercard Automatic Billing Updater. When a user's card expires or is replaced/lost, the card network automatically syncs the new card information to us. This ensures your subscription billing is not interrupted by card expiration, requiring no manual intervention from the user.
***
Need help? [Contact support](mailto:support@waffo.com)
# Tax
Source: https://waffo.com/docs/en/essentials/tax
Waffo Global Tax is an intelligent tax solution designed specifically for cross-border digital businesses. Whether your customers are in the US, the EU/UK, or the Asia-Pacific region, we automatically identify and handle local tax compliance.
## Overview
Waffo Global Tax is an intelligent tax solution designed specifically for cross-border digital businesses. Whether your customers are in the US (Sales Tax), the EU/UK (VAT), or the Asia-Pacific region (GST), we automatically identify and handle local tax compliance.
### Core value
**MoR Model** — Operating under the **Merchant of Record (MoR)** model, Waffo acts as the nominal seller in your global transactions.
Traditional Model
You are required to register for **tax IDs**, **hire local accountants**, and **file** monthly/quarterly returns in every country/state where you do business.
Waffo MoR Model
We hold tax registrations in major global markets. You do not need to handle any tax registration or filing work; we assume the **full legal responsibility** for collecting, reporting, and remitting taxes to governments worldwide.
***
### Supported global tax types
The system covers indirect tax regimes in **100+ countries and regions**:
| Region | Tax Types |
| - | - |
| North America | US Sales Tax (precise to state/county/city level), Canada GST/HST/PST. |
| Europe (EMEA) | EU-27 VAT, UK VAT, Middle East VAT. |
| Asia-Pacific (APAC) | Australia/New Zealand GST, Japan JCT, Singapore GST, etc. |
***
## Core capabilities
Addressing the complexity of global tax systems, we provide the following intelligent processing capabilities:
### Smart cross-border B2B vs. B2C determination
In Europe and parts of APAC, tax rules for business-to-business (B2B) transactions differ significantly from consumer (B2C) transactions.
### Dynamic pricing display (inclusive vs. exclusive)
Consumers in different regions have different price display habits. The system supports automatic switching based on IP:
| Type | Region | Example Behavior |
| - | - | - |
| Tax Exclusive | US | Display price \$100; tax is added at checkout. |
| Tax Inclusive | Europe/Japan/Korea | Display price €100 (tax included); the system automatically calculates the tax portion from the total to improve conversion rates. |
### Product tax code mapping
Tax rates vary across countries for different product types (e.g., eBooks, SaaS software, online courses, physical goods). We provide standardized Tax Codes; you only need to map your products once to automatically adapt to the global tax rate library.
***
## Service workflow
### Collection (transaction phase)
**1. Location Locking**
Utilizes multiple dimensions including consumer IP, credit card issuing country, and billing address to precisely determine the tax jurisdiction.
**2. Real-Time Calculation**
Returns accurate tax amounts in milliseconds at checkout, supporting multi-currency calculations.
**3. Compliant Invoicing**
Automatically generates compliant electronic invoices (e.g., EU VAT Invoices) upon successful transaction and sends them to the consumer.
### Filing (compliance phase)
**1. Unified Reconciliation**
Standardizes tax data from different global currencies and tax rates.
**2. Automated Filing**
Waffo's global tax team submits returns according to each country's filing cycle (monthly/quarterly/yearly).
**3. Tax Remittance**
We pay taxes directly to tax authorities; you do not need to perform any cross-border remittance operations.
***
## Fund & security assurance
### Independent tax escrow
To ensure fund security, all collected taxes are automatically diverted to a Tax Escrow Account at settlement. These funds are physically segregated from your revenue and are dedicated solely for remittance to tax authorities.
### Risk reserve & FX buffer
Due to multi-currency settlements (e.g., collecting VAT in Euros but potentially converting for filing) and potential refund adjustments, the system holds a small percentage (e.g., 5%) as a risk reserve.
* Used to cover discrepancies caused by exchange rate fluctuations.
* Used to handle cross-period refund adjustments.
> [!NOTE]
>
> **Note:** The reserve is released back to the merchant account after the filing cycle concludes without issues.
***
## Integration & configuration
### Minimalist integration
### Dashboard configuration
* **Whitelist Mechanism:** Supports configuring tax switches for specific countries/regions (e.g., you can choose to enable only European VAT while handling US tax yourself).
* **Product Category Mapping:** Associate your SKUs with system-preset tax categories (e.g., "Digital Services") in the dashboard.
[View API Documentation →](/docs/api-reference/introduction) for parameter examples and integration guides.
***
## Frequently asked questions
> **I only sell to European customers. Do I need to register for the EU OSS (One Stop Shop)?**
>
> No. Under the Waffo MoR Model, we have already completed OSS registration. The transaction is treated as you selling to Waffo, and then Waffo selling to the European customer. Therefore, the compliance responsibility lies with us.
> **Why is the same product sold to US customers as "$100 + Tax" but to German customers as "$100 (Tax Included)"?**
>
> This is to align with local consumer habits. The US is accustomed to "Tax Exclusive" pricing, where the displayed price does not include tax; Germany is accustomed to "Tax Inclusive" pricing. The system automatically adjusts the display logic based on "Pricing Strategy" configurations to maximize conversion rates.
> **How do business customers (B2B) apply for tax exemption?**
>
> On the checkout page, we provide an entry for "Add VAT ID." After the customer enters their tax ID, the system calls databases like EU VIES in real-time to verify it. Upon successful validation, the order amount will automatically update to 0 tax (Reverse Charge).
> **Will tax fees be refunded if a refund occurs?**
>
> Yes. In the case of a full refund, we refund the full tax amount to the consumer and deduct this tax credit from the next filing with the tax authority. You do not bear this loss.
***
Need help? [Contact support](mailto:support@waffo.com)
# Tokenization
Source: https://waffo.com/docs/en/essentials/tokenization
Securely store payment credentials using Waffo Tokens to enable one-click checkout, recurring billing, and delayed charges without handling raw card numbers.
Tokenization technology enables merchants to store user payment information securely without handling raw card numbers. It replaces sensitive bank card data with a non-sensitive random string — the **Waffo Token**.
Waffo provides a standardized Tokenization solution. Merchants integrate with a unified Token interface to achieve **"One-Click Payment"** and **"Recurring Billing"** without managing complex card scheme rules or security encryption details.
## Core value
## Why choose Waffo Tokenization
Our Token is not just a simple storage credential. It integrates Waffo's Smart Payment Engine to continuously optimize your transaction performance.
### Smart optimization
Waffo employs multiple industry-leading optimization technologies at the infrastructure level. When you initiate a charge using a Waffo Token, the intelligent engine automatically selects the optimal ISO message format and routing strategy based on issuing bank preferences, transaction types, and risk characteristics.
### Account updater
Waffo is deeply integrated with major card scheme networks to support automated card lifecycle management.
## Use cases
## Lifecycle management flow
**1. Create Token**
* **User side:** The user checks "Save this card for future use" at checkout.
* **System side:** Waffo validates card data, performs multi-layer encryption, and returns a Token ID to the merchant.
**2. Verify & charge**
* **Verify:** Perform a Zero-Auth (0 amount) or \$1 pre-authorization at the time of binding to confirm the card is active and chargeable.
* **Charge:** When initiating a payment, your backend sends the Token ID directly — no card number required.
**3. Manage & display**
* Retrieve a list of all bound Tokens for a specific user via API.
* The API returns masked information (Card Brand, Last 4 Digits, Expiry Date, Card Art Background Color) for frontend display and user selection.
## Integration & migration
### Integration methods
### Data migration
Waffo supports flexible data migration schemes to eliminate vendor lock-in concerns.
[View API Documentation](/docs/api-reference/introduction)
## Frequently asked questions
> **Is saving credit cards secure?**
>
> Extremely secure. Waffo holds the highest level of security certification, PCI-DSS Level 1 (Financial Grade Security Standard). Actual card number data is encrypted and stored in physically isolated vaults. The merchant's system and database store only the meaningless Token string. Even if the merchant's database is compromised, hackers cannot reconstruct the real bank card information.
> **Does the Token expire?**
>
> The Waffo Token itself is permanently valid unless you proactively call the API to delete it. Even if the underlying physical card expires, thanks to our Account Updater feature, the system automatically maintains the mapping relationship in most cases without manual intervention from you or the user.
> **How do I verify that the bound card is valid?**
>
> We recommend performing a Zero Amount Verify at the same time the Token is created. This ensures that the card corresponding to the Token is real, valid, not reported lost, and capable of being charged at the moment of binding.
> **Can I share the Token with my sub-merchants?**
>
> This depends on your account architecture. If you are a Platform merchant, you can authorize payment credentials to sub-merchants via "Token Cloning" to achieve a unified wallet experience.
***
Need help? [Contact support](mailto:support@waffo.com)
# API Credential Scope & Auth Error Triage
Source: https://waffo.com/docs/en/support/api-credential-scope-and-triage-faq
Which API key is allowed to call which endpoint, why Withdraw must be a separate credential, the exact order in which Waffo checks your API key, scope, signature and IP, how to read A0001 / A0002 / A0004 / A0046, and how long a credential change takes to apply.
Three pages already cover the mechanics of authentication:
| Question | Page |
| - | - |
| How do I sign a request, verify a response and a webhook, and generate a key pair? | [Authentication & Signature FAQ](/docs/en/support/auth-signature-faq) |
| How do I submit or rotate a production public key, and what is an API operation type? | [Production Public Keys & API Operation Types](/docs/en/support/production-api-key-and-public-key-faq) |
| How do I set notification URLs, turn on the IP allowlist, and use two-step verification? | [Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq) |
**This page is the scope matrix and the triage tree** — which credential may call
which endpoint, and how to read the error when one may not.
## How many keys are involved, and who holds each one?
Four things, and only two of them are yours to keep secret:
| Item | Who generates it | Who holds it | Purpose |
| - | - | - | - |
| API key | Waffo, alongside each accepted public key | You send it on every request | Identifies which credential — and therefore which merchant and which scope — a request is using |
| Your private key | **You** | You, server-side only | Signs your requests and your webhook responses |
| Your public key | You (submitted to Waffo) | Waffo | Verifies your signatures |
| Waffo public key | Waffo | You copy it from the Portal | Verifies Waffo's responses and webhook signatures |
The relationship that matters operationally: **one API key maps to exactly one
public key.** The `X-API-KEY` you send decides which public key your signature is
checked against — so an API key and a private key are a matched pair, and
swapping one without the other fails.
## Why does my Integration page show several API keys and public keys? Which one do I use?
Because a credential is scoped to a class of operation, and you hold one per
class you use. The row you want is the one whose **API Operation Type** matches
the call you are making.
Which class covers which endpoint, exactly:
| Operation type | Endpoints it may call |
| - | - |
| **Payin** | `/api/v1/order/create`, `/api/v1/order/inquiry`, `/api/v1/order/cancel`, `/api/v1/order/refund`, `/api/v1/order/capture`, `/api/v1/refund/inquiry`, `/api/v1/subscription/create`, `/api/v1/subscription/inquiry`, `/api/v1/subscription/cancel`, `/api/v1/subscription/manage`, `/api/v1/subscription/change`, `/api/v1/subscription/change/inquiry`, `/api/v1/subscription/update`, `/api/v1/tokenization/generate`, `/api/v1/tokenization/inquiry`, `/api/v1/tokenization/remove`, `/api/v1/merchantconfig/inquiry`, `/api/v1/paymethodconfig/inquiry`, `/api/v1/saved-payment-methods/config`, `/api/v1/userid/inquiry`, `/api/v1/userinfo/inquiry`, `/api/v1/wallet/inquiry`, `/api/v1/chargeback/inquiry`, `/api/v1/chargeback/list`, `/api/v1/chargeback/update`, `/api/v1/chargeback/accept`, `/api/v1/fraud-report/list`, `/api/v1/risk/transactionlimit/create`, `/api/v1/risk/transactionlimit/update`, `/api/v1/risk/transactionlimit/inquiry` |
| **Payout** | `/api/v1/payout/create`, `/api/v1/payout/inquiry`, `/api/v1/account/balance/inquiry` |
| **Payin and payout** | Everything in both rows above |
| **Withdraw** | `/api/v1/withdraw/consult`, `/api/v1/withdraw/create`, `/api/v1/withdraw/inquiry`, `/api/v1/account/balance/inquiry`, `/api/v1/account/history/inquiry` |
| **Card collection** | `/cardcenter/web/v1/tokenization/submit` — the browser-side card submission endpoint |
Two details this table settles:
* **Balance inquiry is in both the Payout and the Withdraw scope.** It is the one
endpoint you can reach with either credential, which is why a balance check
never forces you to add a scope.
* **Account history is Withdraw-only.** A payout credential cannot read it.
## I used my payin key on a payout endpoint and got a permission error. Why?
Because scope is enforced per request path, not per merchant. The credential was
valid — it simply is not allowed to call that endpoint, and the request is
rejected with **`A0004` Permission denied** (HTTP 401).
The distinction to keep in mind while debugging: `A0004` means "this key, wrong
endpoint". It does not mean your key is wrong, your signature is wrong, or your
account lacks the product. Look up the endpoint in the table above, then use the
credential row whose operation type contains it.
## Why can't Withdraw be combined with Payin or Payout? I need both.
Because withdrawal moves **your own settled funds out**, where payin and payout
move a customer's money in or pay a third party. Keeping withdrawal on its own
credential means a key held by your checkout service or your payout service can
never be used to move your balance out, and that compromising one does not imply
the other.
The Portal enforces this rather than warning about it: selecting Withdraw clears
Payin and Payout, and selecting either of those clears Withdraw.
So if you use withdrawals through the API, plan on **at least two credentials** —
one for your payments traffic and a separate one for withdrawals — and store them
in different places in your secret manager, with different access lists. That is
the point of the separation; putting both in the same environment file gives it
away. Submitting the second credential is the same self-serve flow as the first;
see
[Production Public Keys & API Operation Types](/docs/en/support/production-api-key-and-public-key-faq).
## In what order do you check my API key, scope, signature and IP?
This order, and knowing it tells you which error you will see first when more
than one thing is wrong:
1. **Is `X-API-KEY` present?** If not → `A0001`.
2. **Is it a known, `Active` credential?** If not → `A0001`.
3. **Does that credential's operation type allow this endpoint?** If not →
`A0004`.
4. **Does `X-SIGNATURE` verify against that credential's public key?** If not →
`A0002`.
5. **Is the source IP on your allowlist, if you have enabled one?** If not →
`A0046`.
The practical consequence: **a request that is both signed wrongly and coming
from a non-allowlisted IP reports `A0002`, not `A0046`.** So do not conclude your
allowlist is fine because you have never seen `A0046` — you may simply never have
got past the signature check. Fix errors in the order above.
## What is the difference between "invalid API key" and "invalid signature"?
They point at different halves of the same pair, and each has its own checklist:
| Code | Message | HTTP | What it means | Check |
| - | - | - | - | - |
| `A0001` | Invalid Api secret key | 401 | The `X-API-KEY` is missing, unknown, or not currently `Active` | Header present? Copied from the Integration page of **this** environment? The key's version is `Active`, not `Pending` or `Rejected`? |
| `A0002` | Invalid signature | 401 | The signature did not verify against the public key bound to that API key | Signed the exact bytes you send, with the private key paired to **that** API key's public key, using SHA256withRSA, Base64-encoded? |
| `A0004` | Permission denied | 401 | Valid credential, wrong endpoint for its operation type | The scope table above |
| `A0046` | Access denied: the request IP is not in the whitelist | 401 | Signature was fine; source IP is not allowlisted | Your current egress IP, and the allowlist entries |
After a key rotation, `A0002` almost always means the `X-API-KEY` and the signing
private key no longer belong to each other — switch both together, as one change.
## Is "merchant status abnormal" or "contract does not allow this operation" an authentication problem?
No. Those are account and agreement states, and they arrive **after** your
credential has already been accepted:
| Code | Meaning | Where to look |
| - | - | - |
| `A0006` | Merchant status abnormal | Your account state — contact integration support |
| `A0010` | Operation not allowed in merchant contract | Your agreement does not include this operation or product |
| `A0018` | The payment method in the order does not match your contract | Your contracted payment methods for that country and currency |
The triage shortcut: any `A0001` / `A0002` / `A0004` / `A0046` is a *credential*
problem you can fix yourself. `A0006`, `A0010` and `A0018` are *entitlement*
problems that need either a configuration change on Waffo's side or a different
request. Full list in
[Error codes](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes).
## Do I have to send the API version header?
Yes, on raw HTTP calls: send `X-API-VERSION: 1.0.0`. SDKs manage the header
themselves — do not set or override it there.
One trap worth naming, because it is easy to get wrong from a changelog: the
version numbers you see in the API version history identify **documentation and
contract revisions** and must not be sent in this header. The header value is
`1.0.0`. See [API overview](/docs/en/developer-docs/integration/api/overview).
## How long after I activate or rotate a key can I use it?
Give it **a short while** — it is not instant. Only `Active` credentials verify
signatures, and the lookup used on the request path is refreshed on an interval —
so a key that has just become `Active` may still be rejected for a short window,
and a key that has just stopped being active may still work for the same window.
What follows for a rotation: after the new version becomes `Active`, wait out
that window before cutting traffic over, and keep the old credential in place
until the new one has verified real traffic. Because more than one version can be
`Active` at once, you never need a gap where neither works.
The IP allowlist behaves differently and faster — see below.
## Can I use one API key across several merchant accounts?
No. Each API key belongs to a single merchant account, and a request
authenticated with account A's credential can only act on account A's data.
Querying an order that belongs to account B fails even though the key itself is
perfectly valid.
If you operate several merchant IDs, keep one credential set per account and
always call with the credential of the account that created the object. Building
that routing in from the start is much cheaper than retrofitting it — see
[Multi-brand and sub-merchant setups](/docs/en/support/multi-brand-and-sub-merchant-faq).
## What does the IP allowlist accept, and how quickly does an edit apply?
| Item | Rule |
| - | - |
| Entry formats | **IPv4 addresses and IPv4 CIDR ranges** — for example `203.0.113.7` or `198.51.100.0/24` |
| IPv6 | Not supported |
| Hostnames | Not supported — addresses and ranges only |
| Entries per merchant | Up to **20**; duplicates rejected |
| Per-entry controls | A label, and an individual on/off toggle |
| Master switch | Cannot be turned on with no active entries; deleting or disabling the last active entry turns it off |
| Time to take effect | **Almost immediately** — saving also invalidates the cached lookup |
If 20 entries is not enough, consolidate into CIDR ranges rather than listing
individual hosts — one `/24` replaces up to 254 entries, and a stable range is
also less maintenance than a list that changes every time you scale out.
Enabling it, and the exact Portal fields, are in
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
## My servers are behind proxies, or have dynamic IPs. What actually gets matched?
The address matched is the **first entry in the forwarded-for chain** of the
request as it reaches Waffo — that is, the originating client address, not the
last hop. If that value cannot be parsed as an IPv4 address, the check counts as
"could not decide" rather than as a mismatch.
That distinction drives the outcome, and there are three enforcement policies:
| Policy | Source IP not on the list | Source IP undeterminable |
| - | - | - |
| Observe only | Allowed, and recorded | Allowed, and recorded |
| Enforce (default) | **Blocked** with `A0046` | Allowed, and recorded |
| Strict | **Blocked** with `A0046` | **Blocked** with `A0046` |
The Portal exposes only the master on/off switch; the policy itself is set by
Waffo. So if your egress is dynamic, multi-region, or containerised behind
several layers, ask integration support to put you in **observe only** first,
watch for recorded mismatches, and switch to enforcement once the list is
complete. Turning enforcement on blind is how a correctly-configured integration
takes an outage.
If your egress genuinely cannot be pinned to a set of ranges, the allowlist is
the wrong control for you — it is optional and off by default, and signature
verification is already mandatory on every request.
## How do I find out what my own egress IP is?
Not from Waffo — the address Waffo sees is the address your infrastructure
presents, and the reliable source for it is your own platform: the NAT gateway or
load balancer configuration of your cloud environment, which is also the only
place it is guaranteed to stay stable.
Two practical routes when you cannot get it from configuration:
* **Ask your platform team for the egress ranges** of the environment your
payment service runs in, and allowlist the ranges rather than the addresses
you observe. Observed addresses go stale the next time you scale.
* **Run in observe-only first** as described above, so the mismatches themselves
tell you which addresses are in play before anything is blocked.
## Is my Portal password encrypted in transit? Can Waffo staff see it?
Your password is **encrypted in the browser before it is submitted**, using a
Waffo public key, on top of TLS — so the form never puts a plaintext password on
the wire, not even inside the encrypted channel. The same applies when you set or
change a password.
On the storage side it is kept only as a **one-way hash**, so there is no
recoverable copy to read: nobody at Waffo can retrieve your password, and a
password reset issues you a link rather than telling you the old value.
Password rules, expiry, lockout and sign-in history are on
[Portal Sign-in, Links & Account Status](/docs/en/support/portal-account-access-faq)
and [Merchant Portal Operations](/docs/en/support/portal-operations-faq).
## I think my private key may have leaked. What do I do, in order?
Rotate first, investigate second — and remember that a leaked private key lets
someone sign requests as you, which is exactly as serious as it sounds.
1. **Generate a new key pair** and submit the new public key from the Portal. Your
existing credential keeps working while it is reviewed, so this step costs you
no downtime.
2. **When the new version is `Active`, read its API key** from the Integration
page and switch the `X-API-KEY` and the signing private key together.
3. **Stop using the old credential**, and confirm from your own logs that no
traffic still carries the old API key.
4. **Turn on the IP allowlist** if you have not already. It is the one control
that makes a stolen credential unusable from outside your infrastructure, and
it is the reason to have it configured before you need it.
5. **Review your own handling**: the private key must not be in source control,
in an image, in a log, or in a browser. The
[go-live checklist](/docs/en/developer-docs/getting-started/go-live) requires it to
live in environment configuration or a key management service.
6. **Tell integration support** what happened and when, so activity on the old
credential can be reviewed from Waffo's side.
Note step 2's failure mode, since it is the most common: reusing the old API key
with the new key pair leaves you verifying against the old public key, so your
new signatures fail while the Portal looks correctly configured.
## Can I capture request and response payloads to debug a signature problem?
Yes, and you should — but capture the **raw bytes**, not a parsed and
re-serialised copy, because a round trip through your JSON library is itself one
of the top causes of a signature that will not verify.
What to log, and what not to:
| Log | Do not log |
| - | - |
| The exact request body bytes you signed | The private key, or any part of it |
| The `X-SIGNATURE` you produced | Full card numbers or other sensitive payment data |
| The `X-API-KEY` in masked form, enough to tell which credential was used | The complete API key |
| The response code and the error code returned | |
The go-live checklist makes the second column a requirement rather than advice:
logs must not record full keys or sensitive payment information. A short-lived,
access-controlled debug capture is the right shape for this — not a permanent
log line.
***
# API Reference Tables
Source: https://waffo.com/docs/en/support/api-reference-tables
The four lookup tables the other Waffo API pages point at: payout payee field requirements by country and payout method, conditionally mandatory fields on order create, the full orderAction.actionData field list for merchants building their own cashier, and the error code table for each API family — payout, acquiring, subscription and withdrawal.
Several Waffo pages say "see the field requirements table" or "see the error code list".
Those tables are collected here. This page is the **full lookup**; two neighbouring pages
cover the same ground from a different angle and are usually the faster read:
| Page | Use it for |
| - | - |
| [Error code quick reference](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes) | Looking up one code you just received, across all APIs |
| [Errors & troubleshooting](/docs/en/support/errors-troubleshooting-guide) | The handful of codes that cause most integration failures, with fixes |
| **This page** | The complete table for **one** API family, and the fields those tables reference |
The one thing you cannot get from the quick reference is which API family a code belongs
to — and several codes mean different things on different families. That is the first
question below.
## Does the same error code mean the same thing on every Waffo API?
**No.** The numeric part of a code is scoped to the API family, not global. Three codes in
particular are traps:
| Code | On acquiring (`/api/v1/order/*`) | On payout and withdrawal |
| - | - | - |
| `A0013` | Order cannot be cancelled — payment already succeeded | Too many requests — another request with the same `payoutRequestId` is still being processed |
| `A0014` | Order cannot be refunded due to refund rules | Does not exist — a duplicate create with identical parameters returns the ordinary success code with the original order |
| `A0008` | Transaction limit exceeded; payment amount must be within range | Payout / withdrawal limit exceeded; amount must be within range |
So an `A0013` on `/api/v1/payout/create` means your earlier request with that
`payoutRequestId` is still in flight — wait, then inquire on it before submitting again; an
`A0013` on `/api/v1/order/cancel` is terminal and must not be retried.
Always resolve a code against the family of the endpoint you called.
The prefix meaning is stable everywhere: `A` merchant-related, `B` user-related, `C`
system-related, `D` risk rejection, `E` unknown (keep inquiring until a final status).
## Which payee fields must I send on a payout, and which go inside `payoutMethodProperty`?
Requirements are a matrix of **receiving country × payout method**, not a property of the
API. `payeeFirstName`, `payeeMiddleName`, `payeeLastName` and `payeeIDType` are declared
`conditional` in the specification for exactly this reason.
| Country | `payoutMethodType` | `payoutMethodName` | First / Middle / Last name | Nationality | Birthday | Phone | Email | `bankCode` | `addressInfo` |
| - | - | - | - | - | - | - | - | - | - |
| Indonesia (`IDN`) | `BANK_ACCOUNT` | `IDN_BANK` | Not required | Not required | Not required | Not required | Not required | **Required** | Not required |
| Indonesia (`IDN`) | `EWALLET` | `DANA` | Not required | Not required | Not required | Not required | Not required | Not required | Not required |
Two identity rules are stated outside the matrix, in the field definitions themselves:
| Country | Rule |
| - | - |
| Mainland China (`CHN`) | `payeeIDType` is **mandatory** — send `ID` or `PASSPORT` with the matching `payeeIDNumber`. Bank payouts carry additional local rules. |
| Brazil (`BRA`) | `payeeIDType` is **mandatory** and is a CPF / CNPJ. |
For every other corridor the payee fields are validated downstream rather than by a
country rule, so send everything you hold. Rows are added to this matrix as corridors are
onboarded — treat it as a floor, not a ceiling, and let a rejection tell you what is
actually missing.
### Which fields belong in `payoutMethodProperty` rather than at the top level?
`payoutMethodProperty` is a **JSON string** (max 512 characters), not an object, and it is
the only place bank routing detail is accepted:
| Key | When | Notes |
| - | - | - |
| `bankCode` | Required for `payoutMethodType = BANK_ACCOUNT` | Not accepted at the top level of the request |
| `bankBranch` | Optional | Defaults to empty when omitted |
| `bankCity` | Optional | Returned on inquiry where it was supplied |
The payee's account identifier itself is **not** in `payoutMethodProperty` — it is
`payoutInfo.payoutMethodUserAccountNo`, and its format depends on
`payoutMethodType`: a bank account number for `BANK_ACCOUNT`, and for `EWALLET` the
wallet's own account number or public user identifier. `payoutMethodName` is the wallet
name for `EWALLET` and is **empty** for `BANK_ACCOUNT`.
See [Payout FAQ](/docs/en/support/payout-faq) for the create / inquire / notify loop itself.
## What values can `purpose` take on a payout?
`purpose` on `/api/v1/payout/create` is **required for payouts to mainland China** and must
then be one of the values below; for other corridors it is optional and passed through to the
receiving side as supplied. The accepted values:
| Value | Meaning |
| - | - |
| `SALARY_PAYMENT` | Salary |
| `SERVICE_CHARGES` | Service fees |
| `ADVISORY_FEES` | Advisory / consulting fees |
| `ADVERTISING_EXPENSES` | Advertising and public relations |
| `CONSTRUCTION_EXPENSES` | Construction costs |
| `PROPERTY_RENTAL` | Property rent |
| `TAX_PAYMENT` | Tax payment |
| `LIBERALIZED_REMITTANCE` | Liberalized remittance |
Pick the one that genuinely describes the payment. Some corridors — mainland China in
particular — are validated against it.
Two adjacent free-text fields are often confused with it. `description` (max 128
characters) is printed on the payee's bank or wallet statement, so keep it short: banks
and wallets truncate it, and roughly 16 English words or 10 Chinese characters is the
practical ceiling. `extendInfo` (max 128 characters, JSON) is echoed back to you
untouched and never reaches the payee.
## Which "Optional" fields on `/api/v1/order/create` become mandatory for certain payment methods?
The acquiring specification marks `goodsInfo`, `addressInfo` and most of `userInfo`
optional, because whether they are required depends on the payment method actually used.
Aggregated by `payMethodType`:
| `payMethodType` | `userFirstName` | `userLastName` | `userPhone` | `goodsInfo` | `addressInfo` |
| - | - | - | - | - | - |
| `BNPL` | Required | Required | Required | Required | Required |
| `ONLINE_BANKING` | Required | Required | Varies by market | Varies by market | Not required |
| `EWALLET` | Not required | Not required | Varies by market | Not required | Not required |
| `VA` | Required | Required | Not required | Not required | Not required |
| `OTC` | Required | Required | Not required | Not required | Not required |
| `CREDITCARD`, `DEBITCARD` | Not required | Not required | Not required | Not required | Not required |
Read this as sizing information, not as a contract: the same `payMethodType` can differ
between markets, which is why several cells say *varies by market*. `BNPL` is the only
type that requires the full itemised basket everywhere it is offered.
**If you use Waffo's hosted cashier you can usually ignore this table.** When the chosen
method needs user information you did not supply, the buyer is redirected to the cashier
and the cashier collects it. The table matters where there is no cashier in the flow.
For the authoritative pattern — submit, read the returned missing-field list, resend — and
for the full set of field paths that can be requested, see
[Conditionally required fields by payment method](/docs/en/support/conditional-required-fields-faq).
## What does `orderAction.actionData` contain?
`orderAction` is returned by `/api/v1/order/create` and `/api/v1/order/inquiry` when
`orderStatus = AUTHORIZATION_REQUIRED`, i.e. the buyer still has something to do.
`actionType` is `WEB` (redirect the buyer to `webUrl`) or `DEEPLINK` (open `deeplinkUrl`).
`actionData` is the alternative to redirecting: it carries the raw payment instrument so
you can render your own cashier page. **Only the subset relevant to the chosen payment
method appears** in any given response, so treat every field as optional and render what
is present.
### Payment voucher fields
| Field | Type | When it appears | What to do with it |
| - | - | - | - |
| `paymentExpiryTime` | String | Most voucher-style methods | ISO 8601 (`2023-04-01T03:00:00.000Z`) after which the buyer can no longer pay. Show a countdown or an expiry notice. |
| `paymentQr` | String | QR-based methods | The **raw QR string**, not an image. Render it into a scannable QR code client-side. |
| `paymentCode` | String | Code-entry and over-the-counter methods | A numeric or alphanumeric code the buyer types into their banking / wallet app, or reads out at a counter. |
| `paymentBarCode` | String | Over-the-counter methods | The **raw barcode string**. Render it as a 1D barcode (for example Code 128) for the buyer to present at a physical scanner. |
### Amount and FX fields
| Field | Type | When it appears | What to do with it |
| - | - | - | - |
| `paymentCurrency` | String | When the method collects in a currency of its own | ISO 4217. May differ from `orderCurrency` when the method only supports a specific local currency. |
| `paymentAmount` | String | With `paymentCurrency` | What the buyer will actually pay, in `paymentCurrency`. Where it differs from the order amount, make **this** the headline figure on your page. |
| `userFeeAmount` | String | When a buyer-side surcharge applies | A fee borne by the buyer, not by you. Itemise it separately — do not fold it into the total silently. |
| `userFeeCurrency` | String | With `userFeeAmount` | ISO 4217. Usually equals `paymentCurrency`, but can differ in cross-border flows. |
| `exchangeRate` | String | Only when `paymentCurrency` ≠ `orderCurrency` | Defined as `paymentAmount / orderAmount`. Disclose it before the buyer confirms. |
### Bank transfer fields
| Field | Type | When it appears | What to do with it |
| - | - | - | - |
| `bankAccount` | String | Bank transfer and virtual account methods | The account to transfer to. Depending on the market this is an IBAN, a routing + account number, or a virtual account generated for this one order. |
| `bankName` | String | With `bankAccount` | The receiving bank or clearing participant the buyer must select in their banking app. If a numeric code is returned, map it to a readable name yourself. |
| `bankBeneficiary` | String | With `bankAccount` | The account holder name the buyer will see. |
| `referenceNumber` | String | Bank transfer and over-the-counter methods | A reconciliation reference. **Prompt the buyer strongly** to copy it verbatim into the *Memo / Remark / Reference No.* field of their transfer — reconciliation depends on it. |
A virtual account (`bankAccount` generated per order) reconciles on the account number
alone; a shared collection account reconciles on `referenceNumber`. If both are returned,
show both and treat `referenceNumber` as mandatory buyer input.
## Full error code table — payout
`/api/v1/payout/create`, `/api/v1/payout/inquiry` and the payout notification.
| Code | Description | HTTP |
| - | - | - |
| `0` | Success | 200 |
| `A0001` | Invalid API secret key | 401 |
| `A0002` | Invalid signature | 401 |
| `A0003` | Parameter validation failed | 400 |
| `A0004` | Permission denied | 401 |
| `A0005` | Merchant limit exceeded | 400 |
| `A0006` | Merchant status abnormal | 400 |
| `A0007` | Payout currency is not supported | 400 |
| `A0008` | Payout limit exceeded — amount should be within the range | 400 |
| `A0009` | Order does not exist | 400 |
| `A0010` | Operation not allowed in merchant contract | 400 |
| `A0011` | Idempotent param mismatch | 400 |
| `A0012` | Merchant account insufficient balance | 400 |
| `A0013` | Too many requests, please try again later | 400 |
| `B0001` | User status abnormal | 400 |
| `B0002` | User limit exceeded | 400 |
| `B0003` | User does not exist | 400 |
| `B0004` | User info does not match | 400 |
| `C0001` | System error | 500 |
| `C0002` | Invalid merchant contract | 500 |
| `C0003` | Order info mismatch | 500 |
| `C0004` | Payout channel rejection | 503 |
| `C0005` | Payout channel error | 503 |
| `D0001` | Risk rejection | 406 |
| `E0001` | Unknown status | 500 |
Note what payout does **not** have: no `A0015`–`A0047`. If you see one of those on a
payout endpoint, you are looking at the wrong table.
## Full error code table — acquiring
`/api/v1/order/create`, `/inquiry`, `/cancel`, `/refund`, `/capture`,
`/api/v1/refund/inquiry` and the payment and refund notifications.
| Code | Description | HTTP |
| - | - | - |
| `0` | Success | 200 |
| `A0001` | Invalid API secret key | 401 |
| `A0002` | Invalid signature | 401 |
| `A0003` | Parameter validation failed | 400 |
| `A0004` | Permission denied | 401 |
| `A0005` | Merchant limit exceeded | 400 |
| `A0006` | Merchant status abnormal | 400 |
| `A0007` | Transaction currency is not supported | 400 |
| `A0008` | Transaction limit exceeded — payment amount should be within the range | 400 |
| `A0009` | Order does not exist | 400 |
| `A0010` | Operation not allowed in merchant contract | 400 |
| `A0011` | Idempotent param mismatch | 400 |
| `A0012` | Merchant account insufficient balance | 400 |
| `A0013` | Order cannot be cancelled — payment already succeeded | 400 |
| `A0014` | Order cannot be refunded due to refund rules | 400 |
| `A0015` | Order cannot be cancelled — payment method does not support cancel | 400 |
| `A0016` | Order cannot be cancelled — rejected downstream | 400 |
| `A0017` | Order cannot be refunded — payment method does not support refund | 400 |
| `A0018` | Payment method in the order does not match the merchant contract | 400 |
| `A0019` | Order cannot be refunded due to a chargeback dispute | 400 |
| `A0020` | Payment amount exceeds the transaction limit | 400 |
| `A0021` | Accumulative payment amount exceeds the daily transaction limit | 400 |
| `A0022` | Specify the product name — multiple products found | 400 |
| `A0023` | Order cannot be created — expired token | 400 |
| `A0024` | Order cannot be processed — the FX rate has expired | 400 |
| `A0026` | The cashier language specified is not supported | 400 |
| `A0027` | The refund has exceeded the maximum number of times | 400 |
| `A0029` | Card data provided is invalid | 400 |
| `A0030` | Card BIN provided cannot be found | 400 |
| `A0031` | Card scheme or type provided is not supported | 400 |
| `A0032` | Payment token data provided is invalid | 400 |
| `A0033` | Specify the pay method country — multiple pay methods found | 400 |
| `A0034` | The order expiry time provided has already passed | 400 |
| `A0035` | This order does not support the capture operation | 400 |
| `A0036` | The current order status does not allow capture | 400 |
| `A0037` | The user payment token is invalid or has expired | 400 |
| `A0039` | Order has already been refunded by the chargeback alert service | 400 |
| `A0040` | Order cannot be created concurrently | 400 |
| `A0045` | MIT cannot be processed — the token status is Unverified | 400 |
| `A0046` | Access denied — the request IP is not in the whitelist | 400 |
| `A0047` | Invalid cashier language format — must follow IETF BCP 47 (two-letter ISO 639-1 language, optional four-letter ISO 15924 script, optional two-letter ISO 3166-1 alpha-2 region) | 400 |
| `B0001` | User status abnormal | 400 |
| `B0002` | User limit exceeded | 400 |
| `B0003` | User insufficient balance | 400 |
| `B0004` | User did not pay within the timeout period | 400 |
| `B0005` | User authorization failure | 400 |
| `B0006` | Invalid phone number | 400 |
| `B0007` | Invalid email format | 400 |
| `C0001` | System error | 500 |
| `C0002` | Invalid merchant contract | 500 |
| `C0003` | Order status is invalid to proceed | 500 |
| `C0004` | Order info mismatch | 500 |
| `C0005` | Payment rejected downstream | 503 |
| `C0006` | Payment processing error | 503 |
| `C0007` | The payment method is under maintenance | 503 |
| `D0001` | Risk rejection | 406 |
| `E0001` | Unknown status | 500 |
The acquiring family is the widest, and the
[quick reference](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes)
carries additional acquiring codes beyond this specification revision. Where the two
disagree, the quick reference is the newer list.
## Full error code table — subscription
`/api/v1/subscription/create`, `/inquiry`, `/cancel`, `/manage`, `/change`,
`/change/inquiry`, `/update` and the subscription notifications.
| Code | Description | HTTP |
| - | - | - |
| `0` | Success | 200 |
| `A0001` | Invalid API secret key | 401 |
| `A0002` | Invalid signature | 401 |
| `A0003` | Parameter validation failed | 400 |
| `A0004` | Permission denied | 401 |
| `A0005` | Merchant limit exceeded | 400 |
| `A0006` | Merchant status abnormal | 400 |
| `A0007` | Transaction currency is not supported | 400 |
| `A0009` | Order does not exist | 400 |
| `A0010` | Operation not allowed in merchant contract | 400 |
| `A0011` | Idempotent param mismatch | 400 |
| `A0014` | Order cannot be refunded due to refund rules | 400 |
| `A0015` | Order cannot be cancelled — payment method does not support cancel | 400 |
| `A0016` | Order cannot be cancelled — rejected downstream | 400 |
| `A0018` | No available payment method for this user to continue the payment | 400 |
| `A0022` | Specify the product name — multiple products found | 400 |
| `A0025` | Period number must be more than one | 400 |
| `A0028` | Subscription is still in progress or has failed, and the management URL is not available | 400 |
| `B0001` | User status abnormal | 400 |
| `B0002` | User limit exceeded | 400 |
| `B0003` | User insufficient balance | 400 |
| `B0004` | User did not pay within the timeout period | 400 |
| `B0005` | User authorization failure | 400 |
| `B0006` | Invalid phone number | 400 |
| `B0007` | Invalid email format | 400 |
| `C0001` | System error | 500 |
| `C0002` | Invalid merchant contract | 500 |
| `C0003` | Order status is invalid to proceed | 500 |
| `C0004` | Order info mismatch | 500 |
| `C0005` | Payment rejected downstream | 503 |
| `C0006` | Payment processing error | 503 |
| `C0007` | The payment method is under maintenance | 503 |
| `D0001` | Risk rejection | 406 |
| `E0001` | Unknown status | 500 |
Two codes are unique to this family and worth handling explicitly: `A0025` (a
subscription must run for more than one period) and `A0028` (asking for the management
URL before the first payment has settled).
## Full error code table — withdrawal, balance and transaction limit rules
`/api/v1/account/balance/inquiry`, `/api/v1/account/history/inquiry`,
`/api/v1/withdraw/consult`, `/create`, `/inquiry`, the withdrawal notification, and
`/api/v1/risk/transactionlimit/create`, `/update`, `/inquiry`.
| Code | Description | HTTP |
| - | - | - |
| `0` | Success | 200 |
| `A0001` | Invalid API secret key | 401 |
| `A0002` | Invalid signature | 401 |
| `A0003` | Parameter validation failed | 400 |
| `A0004` | Permission denied | 401 |
| `A0005` | Merchant limit exceeded | 400 |
| `A0006` | Merchant status abnormal | 400 |
| `A0007` | The withdraw currency is not supported | 400 |
| `A0008` | Withdraw limit exceeded — amount should be within the range | 400 |
| `A0009` | Order does not exist | 400 |
| `A0010` | Operation not allowed in merchant contract | 400 |
| `A0011` | Idempotent param mismatch | 400 |
| `A0012` | Merchant account insufficient balance | 400 |
| `A0013` | Too many requests, please try again later | 400 |
| `A0100` | The same `goodsId` is already configured by another rule ID | 400 |
| `A0101` | The same `goodsCategory` is already configured by another rule ID | 400 |
| `A0102` | Rule does not exist | 400 |
| `C0001` | System error | 500 |
| `C0002` | Invalid merchant contract | 500 |
| `C0003` | Order info mismatch | 500 |
| `C0004` | Withdrawal rejected downstream | 503 |
| `C0005` | Withdrawal processing error | 503 |
| `D0001` | Risk rejection | 406 |
| `E0001` | Unknown status | 500 |
`A0100`–`A0102` exist **only** here and appear only on the transaction limit rule
endpoints. They are not in the general quick reference, so a `A0100` is not a
malformed `A0010`.
## I received a code that is not in any of these tables. What is it?
Every code Waffo returns on a signed public API matches one of these shapes: `0`, or a
single letter `A`–`E` followed by four digits. A value in any other shape — a longer
string, a different prefix, a mixed alphanumeric identifier — is **not part of the public
error contract** and should not be branched on in your integration. Log it with the
`paymentRequestId` / `payoutRequestId` and
[contact support](mailto:support@waffo.com); treat the transaction the way you would treat
`E0001` until you have a definitive status from an inquiry call.
The same applies to a public-shaped code that is simply not in the table for the family
you called: inquire the order for its real status rather than inferring an outcome from
the code.
# Authentication & Signature FAQ
Source: https://waffo.com/docs/en/support/auth-signature-faq
Answers to the most common questions about Waffo API authentication: where to get your API key, how request and response signing works, webhook verification, A0001/A0002 troubleshooting, and RSA key generation.
The authentication and signing questions merchants ask most often. Full contracts:
[Acquiring API Specification](/docs/api-reference/introduction).
## Where do I get my API key?
Your API key is provided by Waffo during merchant onboarding. You can view it
any time on **Merchant Portal → Integration** (**Dev** or **Admin** role
required). Send it on every API request in the `X-API-KEY` header. Sandbox and
production use different API keys — switch both the API domain and the key
when you go live (see the [go-live checklist](/docs/en/developer-docs/getting-started/go-live)).
## What keys are exchanged between my system and Waffo?
At onboarding, you and Waffo exchange **RSA public keys** — each party keeps
its own private key:
| Key | Who holds it | Used for |
| - | - | - |
| Merchant private key | You (server-side only) | Signing your requests and your webhook responses |
| Merchant public key | Configured by you on **Merchant Portal → Integration** | Waffo verifies your signatures |
| Waffo private key | Waffo | Signing Waffo's responses and webhook notifications |
| Waffo public key | Copied by you from **Merchant Portal → Integration** | You verify Waffo's signatures |
Never send your private key to Waffo or commit it to a repository.
## How do I sign an API request?
The signature algorithm is **SHA256WithRSA**. Use your merchant RSA private
key to sign the **whole body message** of the request, and send the result in
the `X-SIGNATURE` header alongside `X-API-KEY`. Waffo verifies it with the
merchant public key you configured in the Portal and rejects the request with
an invalid-signature error if verification fails. All requests are HTTP POST
with `Content-Type: application/json` (UTF-8) over TLS 1.2 or above.
## How do I verify Waffo's API responses?
Waffo signs every response body with its own private key and returns the
signature in the response's `X-SIGNATURE`. Verify it with the **Waffo public
key** (from Merchant Portal → Integration) using SHA256WithRSA before
processing the response. If verification fails: investigate and contact
Waffo, and **stop sending new transactions until the issue is resolved** —
Waffo may already have processed your request even though you could not
accept the response.
## How do I verify webhook notifications?
Same mechanism as responses: each webhook request carries `X-SIGNATURE`,
signed with the Waffo private key. Verify with the Waffo public key —
SHA256WithRSA over the **raw request body** (do not JSON-parse and
re-stringify), signature decoded from Base64 — and always verify before
processing the event. The SDK's `handleWebhook()` does this for you; full
steps and Node.js/Go examples are in
[Webhook signature verification](/docs/en/developer-docs/webhook/signature-verification).
## Do I sign my webhook response back to Waffo?
Yes. Your response to a Waffo callback also carries an `X-SIGNATURE` header,
signed over the whole body message with your merchant private key. The body
is `{"message": "success / failed / unknown"}` per your processing result.
## What is the difference between A0001 and A0002?
| Code | Meaning | HTTP status | Points to |
| - | - | - | - |
| `A0001` | Invalid Api secret key | 401 | The `X-API-KEY` you sent is not valid for this environment |
| `A0002` | Invalid signature | 401 | The `X-SIGNATURE` failed verification against your configured public key |
## I get A0001. What should I check?
1. The `X-API-KEY` header is present and matches the key shown on **Merchant Portal → Integration**.
2. You are not sending a sandbox key to the production domain (or vice versa) — each environment has its own key.
## I get A0002. What should I check?
1. You signed the **whole body message** — exactly the bytes you send, not a re-serialized copy.
2. You signed with your merchant **private** key using **SHA256WithRSA**.
3. The merchant **public** key configured on **Merchant Portal → Integration** matches that private key.
4. After going live, you are using your production key pair — do not reuse the sandbox pre-generated keys.
## How do I generate an RSA key pair?
Use openssl ([https://www.openssl.org/source](https://www.openssl.org/source)), as given in the specification appendix:
```bash theme={null}
# generate private key
openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -out merchant_private_key.pem
# generate public key
openssl rsa -in merchant_private_key.pem -pubout > merchant_public_key.pem
```
The [go-live checklist](/docs/en/developer-docs/getting-started/go-live) also shows how
to strip the PEM wrappers into the one-line Base64 values the Portal public-key field expects.
## Who on my team can access the Integration page?
Viewing the API key and Waffo public key, and configuring your merchant
public key, all require the **Dev** or **Admin** role in the Merchant Portal.
## I have several merchant accounts. Can I use one API key across all of them?
No. Each API key is bound to a single merchant account (MID). A request
authenticated with account A's key can only operate on account A's data —
using it to query an order that belongs to account B fails, even though the
key itself is valid. When you operate multiple MIDs, keep one key pair per
account and always call with the key of the MID that created the order.
A related lookup pitfall: **subscription orders are not returned by the
one-time payment Order Inquiry**. Query subscriptions and their payment
orders through the subscription-specific inquiry APIs instead.
## Why does the SDK's pay-method query return an empty `payMethods` array?
When you query your pay-method configuration through the SDK, the
`payMethods` field in the result is always an empty array — this is expected.
The real data is in **`payMethodDetails`**: one entry per contracted pay
method, carrying `productName`, `payMethodName`, `country`, and
`currentStatus` (whether the method is currently available). Iterate
`payMethodDetails` to build the list of pay methods actually enabled under
your contract for each country.
# Can Waffo withdraw my balance automatically, and how do I integrate withdrawals?
Source: https://waffo.com/docs/en/support/auto-withdrawal-and-withdrawal-integration-faq
The automatic-withdrawal contract end to end — how it is triggered, what the six statuses mean, what happens when your balance is under the threshold, and how to pause or terminate it — plus the withdrawal notification event, how to tell an API withdrawal from a Portal one, exporting the withdrawal list, and what openingRegion and fundsFrom mean on a withdrawal.
[Withdrawals: exceptions & statuses](/docs/en/support/withdrawal-exceptions-and-status-faq) covers a
withdrawal that went wrong. [Withdrawal & Balance API](/docs/en/support/withdrawal-and-balance-api-faq) is
the endpoint contract. **This page covers the two things neither of those answers**: automating
withdrawals so you stop raising them by hand, and the integration details around a withdrawal —
the notification, the identifiers, the export.
## Can Waffo withdraw my settlement balance automatically?
Yes, through an **automatic withdrawal contract**. One contract is attached to one withdrawal
agreement and one recipient bank account, and it raises withdrawals for you on a rule you agree with
Waffo.
The contract is set up by Waffo and goes through an internal approval before it becomes active. Once
it exists, you see it and control it yourself in the Portal at **Account → Withdraw → Auto
Withdrawal**.
To request one, [contact support](mailto:support@waffo.com) and say which currency, which recipient
account, and which rule you want.
## How is an automatic withdrawal triggered?
Two trigger types exist:
| `triggerType` | Rule |
| - | - |
| `AMOUNT_ONLY` | Checked every day. When your withdrawable balance reaches the threshold, a withdrawal is raised |
| `AMOUNT_WITH_PERIOD` | Checked only on the days the period says. On those days, the threshold still has to be met |
For `AMOUNT_WITH_PERIOD` the period is one of:
| `periodType` | Period configuration | Fires on |
| - | - | - |
| `DAILY` | — | Every day |
| `WEEKLY` | Days of the week, e.g. `1,3` | Monday and Wednesday |
| `MONTHLY` | Days of the month, e.g. `1,15`, or `last`, or `15,last` | The 1st and 15th; the last day of the month; both |
Every contract also carries a **trigger time**, expressed in `UTC+8`. The automation re-checks
through the day and acts on the first check at or after that time; a day-level guard means **at most
one automatic withdrawal per contract per day**, however many times the check runs.
The Portal shows the assembled rule as a readable sentence on the contract row, so you do not have
to reconstruct it from the fields.
## How much does an automatic withdrawal take out?
**Your full withdrawable balance**, not the threshold.
The threshold is the condition, not the amount. If your threshold is met, the contract withdraws
everything that is withdrawable at that moment — the settlement account's available balance less any
amount your reserve arrangement is holding back. It is the same figure the Portal shows you as your
withdrawable amount and the same figure a manual full withdrawal would take.
Why your withdrawable amount can be lower than your account balance is covered in
[Balance & Account History](/docs/en/support/balance-and-account-history-faq).
## What happens on a day when my balance is below the threshold?
**Nothing, and nothing fails.** No withdrawal order is created, no error is raised against your
account, and there is no failed row for you to clean up. The next scheduled check applies the same
rule again.
Two thresholds are applied, and being under either one is enough to skip the day:
| Check | Source |
| - | - |
| The contract's balance threshold | Your automatic withdrawal contract |
| The withdrawal agreement's minimum withdrawal amount | Your withdrawal agreement |
So a contract with a threshold below your agreement's minimum will simply never fire below that
minimum — the agreement floor wins.
## My balance was well above the threshold and no withdrawal happened. Why?
In order of likelihood:
| Cause | What you see | What to do |
| - | - | - |
| A previous automatic withdrawal on the same agreement has not reached a final status | No new order | Wait for the open one to finish; the next check picks up |
| One was already raised today | One order, not two | Expected — one per contract per day |
| The trigger time has not passed yet in `UTC+8` | No order yet | Wait for the trigger time |
| The contract is `SUSPENDED`, or outside its effective window | Status on the contract row | Resume it, or ask support about the window |
| The recipient bank account is no longer valid | No order | Contact support — this needs a Waffo-side fix |
| It is not a trigger day for an `AMOUNT_WITH_PERIOD` contract | No order | Check the trigger rule on the row |
The in-flight rule is per **(merchant, withdrawal agreement)** pair: a contract on one currency is
not blocked by an open withdrawal on another.
## What do the automatic withdrawal statuses mean?
| `status` | Meaning | Can it fire? | Can it be edited? |
| - | - | - | - |
| `INIT` | Drafted, not submitted for approval | No | Yes |
| `AUDITING` | Submitted, waiting for approval | No | No |
| `ACTIVE` | Approved and in effect | Yes | Yes |
| `REJECTED` | Approval was refused. Final | No | No |
| `SUSPENDED` | Paused. Can be resumed | No | Yes |
| `TERMINATED` | Ended. Final and irreversible | No | No |
`REJECTED` and `TERMINATED` are terminal: no further action of any kind is possible on that contract,
and its fields are kept only as a record.
## My contract was rejected. Can I fix it and resubmit?
No — a `REJECTED` contract cannot be edited or resubmitted. A rejected rule has to be replaced by a
new contract, which goes through approval again. [Contact support](mailto:support@waffo.com) with
what you want changed rather than trying to reuse the rejected one.
## What is the difference between pausing and terminating?
| | Pause | Terminate |
| - | - | - |
| Status it sets | `SUSPENDED` | `TERMINATED` |
| Reversible | Yes — Resume returns it to `ACTIVE` | **No** |
| Approval needed to undo | None | A new contract, with a new approval |
| Use it when | You want to hold withdrawals for a while — a bank change, a quiet period, an investigation | The arrangement is over |
Both are yours to do, from the contract row in the Portal. Pause is the safe default: if you are not
certain the arrangement is finished, pause rather than terminate.
## Can I have different automatic withdrawal settings per currency?
Yes. A contract is bound to one withdrawal agreement, and a withdrawal agreement is bound to one
withdrawal currency pair, so per-currency rules are the normal shape: one contract per currency, each
with its own threshold, period, trigger time and recipient account.
Contracts are independent of each other, including the in-flight rule described above.
## Is there a notification when a withdrawal finishes?
Yes. Withdrawals send a notification of type **`WITHDRAW_NOTIFICATION`** to the `notifyUrl` you
supply, when the withdrawal succeeds or fails.
| Point | Detail |
| - | - |
| Event name | `WITHDRAW_NOTIFICATION` |
| Where the URL comes from | `notifyUrl` on the withdrawal create request — it is **required** |
| Envelope and signature | The same envelope and `X-SIGNATURE` verification as every other Waffo notification, see [Webhook overview](/docs/en/developer-docs/webhook/overview) |
| Body | The same structure as the withdrawal inquiry response |
Because `notifyUrl` is per request, there is no account-level withdrawal notification setting to
configure: each withdrawal you create carries its own destination. Withdrawals raised in the Portal
and by an automatic contract are not API calls of yours, so treat the notification as a
confirmation channel for withdrawals you created through `/api/v1/withdraw/create`, and the Portal
list as the record for the rest.
Verify the signature and then re-read state from `/api/v1/withdraw/inquiry` before acting on it —
the same rule that applies to payment notifications.
## What is in the withdrawal notification and the withdrawal inquiry response?
One structure serves both:
| Field | Meaning |
| - | - |
| `withdrawRequestId` | Your own identifier for the request |
| `withdrawOrderId` | Waffo's identifier for the withdrawal |
| `status` | Where the withdrawal has reached |
| `currency` | The currency withdrawn from your settlement account |
| `beforeAvailableBalance`, `afterAvailableBalance` | Your available balance either side of the withdrawal |
| `receiveCurrency` | The currency credited to your bank account |
| `expectedReceiveAmount` | What the withdrawal was expected to deliver |
| `actualReceiveAmount` | What it actually delivered |
| `fxRate` | The rate applied, on a cross-currency withdrawal |
| `feeCurrency`, `feeAmount` | The withdrawal fee and its currency |
| `bankRefId` | The bank's own reference — **this is the field to reconcile against your bank statement** |
| `withdrawRequestedAt`, `withdrawCompleteTime` | When you asked, and when it completed |
| `failureCode`, `failureReason` | Populated when the withdrawal failed |
| `merchantBankAccount` | The recipient account used |
| `description` | The description you sent |
`expectedReceiveAmount` and `actualReceiveAmount` differing on a cross-currency withdrawal is normal
and is explained in
[Withdrawals: exceptions & statuses](/docs/en/support/withdrawal-exceptions-and-status-faq).
## Can I tell an API-initiated withdrawal from one raised in the Portal?
Not from a column — the Portal withdrawal list does not show how a withdrawal was started. What
distinguishes them is the request identifier:
| Raised by | Identified by |
| - | - |
| Your API call | The `withdrawRequestId` you sent, returned on `/api/v1/withdraw/inquiry` |
| You in the Portal | A Waffo-generated request identifier |
| An automatic contract | A generated request identifier that encodes the contract and the date |
In practice, if you need the origin as data, keep your own `withdrawRequestId` values and treat
anything you do not recognise as raised outside your integration.
## Can I export my withdrawal list?
Yes — Portal → Account → Withdraw has an Export action. The **export date range cannot exceed 31
days**, so a longer period has to be pulled in several exports.
Like the other Portal exports it is a background task: the file appears in the export history rather
than downloading straight away. See [Export Center](/docs/en/support/export-center-faq).
## Is there a minimum and a maximum withdrawal amount?
Yes — both live on your **withdrawal agreement**, per withdrawal currency pair, and both are enforced
when the withdrawal is created, whether you raise it in the Portal, through the API, or through an
automatic contract. An amount outside the range is rejected at creation rather than failing later.
The values that apply to you are the ones on your agreement; ask support if you do not have them to
hand. The same minimum is the floor the automatic contract respects.
## Which bank account does a withdrawal go to, and how do I add or change one?
Each withdrawal agreement has one or more recipient bank accounts bound to it. When you withdraw:
* the Portal shows you the bound accounts and lets you pick one where more than one exists
* `/api/v1/withdraw/create` takes `bankAccountId`; **leave it out and the agreement's default account
is used**
Adding or changing an account is **not self-service**. Portal → Settings → Bank Accounts is a
read-only view of the accounts already bound to you — currency, account details, SWIFT code, bank
address. There is no add or edit action, because a new payout destination is a compliance change on
Waffo's side, not a settings change on yours.
To add or change one, [contact support](mailto:support@waffo.com). Have ready the account currency,
the account name and number, the bank name and SWIFT code, the bank address and branch, and the
correspondent bank details where the account needs one — those are exactly the fields the account
record holds.
## What do `openingRegion` and `fundsFrom` mean on a withdrawal, and do they limit how much I can withdraw?
They describe **which side sends the money**, not how much you can take.
| Field | Values | Meaning |
| - | - | - |
| `openingRegion` | `HKG`, `OTHER` | Where your recipient bank account is held |
| `fundsFrom` | Derived from `openingRegion` | Which of Waffo's remitting arrangements is used to pay it |
`fundsFrom` is not something you set: it follows from your account's `openingRegion`. In the Portal
it is what drives the paying entity shown in the **From** block of the withdrawal drawer, so that the
name on your bank statement matches what you were shown before you confirmed.
Neither field caps your withdrawable amount. The amount is governed by your withdrawable balance and
your agreement's minimum and maximum — see the two questions above.
***
Something in your withdrawal does not match this page?
[Contact support](mailto:support@waffo.com) with the withdrawal order id — it identifies the
agreement, the recipient account and the contract behind it in one lookup.
# Balances & Account History
Source: https://waffo.com/docs/en/support/balance-and-account-history-faq
How to read the three Waffo balances — pending settlement, settlement account and reserve — why your withdrawable amount can be lower than your settlement balance, what Account History records, and how Statement and Invoice PDFs are named.
Money you have earned sits in more than one place before it reaches your bank
account. This page explains each balance, the ledger behind it and the
documents you reconcile against. For the settlement cycle itself, see
[Merchant settlement](/docs/en/essentials/merchant-settlement).
## Which balances does Waffo show, and what does each one mean?
| Balance | What it holds | Withdrawable? |
| - | - | - |
| **Pending settlement** (unsettled / to be settled) | Funds from successful transactions that have not reached their settlement date yet. Refunds and chargebacks are netted off here first. | No — it has to settle first. |
| **Settlement account** | Funds that have completed settlement, held per settlement currency. | Yes, subject to the checks below. |
| **Reserve** | Amounts withheld under your contract: `Reserve balance = Reserve per contract + Reserve adjustment`. | No. |
Balances and settlement batches are presented **by settlement currency**, and
FX conversion is finished before funds land in the settlement account — it no
longer happens at withdrawal time.
## Why is my withdrawable amount lower than my settlement account balance?
Because the reserve has to stay whole. If the reserve balance covers what is
owed, your withdrawable amount equals the settlement account balance; if not,
it is the settlement account balance **minus the shortfall**, and the gap
stays held back until the reserve is topped up. When a reserve is drawn down,
Waffo replenishes it from your settlement account on a "replace exactly what
was used" basis, in the next processing cycle after the deduction — shown in
Account History as `RESERVE_ADJUSTMENT`, remark *Debit from settlement
account*.
## What is in the reserve, and can I refund out of it?
Your contract defines whether a reserve applies and of which type — a **fixed
reserve** (a set amount held for a contracted period) and/or a **rolling
reserve** (a share of each settlement's net amount held for a contracted
holding period). Both can apply, and collection can be switched on or
off per payment method. Your `Reserve Ratio` and `Reserve Period` are on the
Payin contract page in the Merchant Portal.
* **Nothing is refunded out of the reserve.** Refunds draw on the
pending-settlement balance only, and a negative settlement carries forward
rather than being charged to the reserve.
* **A settlement day with a negative net amount produces no hold** — it is
added on the next settlement day with a positive net amount.
* Holding periods are counted in **business days or calendar days, as your
contract states**. Where business days apply, they run against a fixed
reserve calendar — a different calendar from the one that shifts settlement
dates. A hold is released on its expected release date only if the reserve
balance exceeds the amount still required that day; at or below that,
nothing is released until the balance recovers. Details in
[Reserve Accounts](/docs/en/support/reserve-faq).
Reserve movements live in a separate **Reserve Account** module: total reserve
balance, *Reserve per contract* (holds and releases) and *Reserve adjustment*
(deductions and top-ups). The settlement ledger, labelled **Settlement
Account**, no longer mixes them in; the settlement calendar shows a **Reserve
Release Date**.
## What does Account History record, and what are the business types?
Account History is the ledger of movements on your accounts, in all settlement
currencies. Which accounts you see depends on where you read it:
| Surface | Scope |
| - | - |
| Portal **Account → Balance → Balance History** | Your **settlement account** |
| Portal **Reserve Account** module | Your **reserve account** — holds, releases and adjustments |
| `/api/v1/account/history/inquiry` | **Both** accounts; omit `accountType` and both are returned |
Each row carries Account No, Account Type, Business Id, Business Type,
Direction, Amount / Currency, Balance (the balance right after the movement),
Datetime and Description.
The business types you can filter on are:
| Business type | What it is |
| - | - |
| `SETTLEMENT` | A settlement crediting your settlement account |
| `WITHDRAWAL` | A withdrawal debiting it |
| `ADJUSTMENT` | A manual correction |
| `RESERVE_WITHHOLDING` | Funds moved into the reserve |
| `RESERVE_RELEASING` | Funds released back out of the reserve |
| `RESERVE_ADJUSTMENT` | The top-up that restores a drawn-down reserve from your settlement account |
FX `Conversion` rows no longer appear, because conversion now happens ahead of
withdrawal rather than at withdrawal time; older rows may still carry that
label, so read the list unfiltered when reconciling history. The filter values
and the account structure underneath are covered in
[Settlement Account Structure](/docs/en/support/settlement-account-structure-faq).
It defaults to the last **30 days**, newest first, same-timestamp rows sorted
by currency A–Z; settlement records default to the last **90 days**. Both
windows are UTC+8.
## Which balance funds refunds and chargebacks? Can it go negative?
Refunds are netted off the **pending-settlement** balance. Chargebacks and
chargeback fees are attempted in a fixed order: pending-settlement balance →
settlement account balance → pending-settlement balance **as an overdraft**,
where it may go negative and is cleared by later incoming funds. Payin
merchant accounts are overdraftable by default.
If a settlement day's net amount is negative — refunds and chargebacks
outweighing payments — no funds move that day; the negative amount carries
forward to the next settlement day with a positive net amount. A settlement
that could not run because the pending-settlement balance was short is retried
on later settlement days, oldest date first and largest amount first, until
even the smallest one cannot be funded.
## How are Statement and Invoice PDFs named, and what is in them?
| Document | File name |
| - | - |
| Settlement Statement | `settlement_statement_{merchant id}_{currency}_{date}.pdf` |
| Withdrawal Statement | `{merchant id}-{withdraw order id}_{account type}.pdf` (a timestamp is appended when regenerated) |
| Invoice | `invoice_{currency}_{date1}_{date2}.pdf` |
Dates are `YYYYMMDD`. All three share one four-block layout: the issuing Waffo
entity's name and address, the document block (date and number), your company
name and address, then the business detail table. `Total` is never a row inside
the table — it is stated separately below it. Amounts use a thousands separator
and 2 decimals with the currency in front (`{Currency} {Amount}`), and
Country/Region uses ISO 3166-1 alpha-3 codes (`USA`, `CHN`, `HKG`).
Settlement Statement columns are Country/Region, Transaction Amount, Fee
Amount, Tax Amount, Avg. Forex Rate and Settlement Amount, plus the totals. On
the Withdrawal Statement the rate column is named **Forex Rate**; where no
conversion was involved it shows `--` rather than being dropped. Reserve holds
do not appear in the settlement files or the Statement — they are taken after
settlement and are reconciled from the Settlement Batch list and the Reserve
Account module; see [Reserve Accounts](/docs/en/support/reserve-faq).
# Bank Transfers & Virtual Accounts
Source: https://waffo.com/docs/en/support/bank-transfer-and-virtual-account-faq
How virtual-account (VA) and bank-transfer payments behave: how long a VA number is valid and whether it can be reused later, what happens when the user types the wrong amount, and what a fiat overpayment or underpayment does to the order status.
Virtual-account (VA) and bank-transfer payments break two assumptions that hold
everywhere else: the user pays **outside** the checkout session, and the amount
that arrives is **whatever the user typed**. That combination produces the
questions on this page.
[Confirming Payment Results](/docs/en/support/payment-results-faq) explains the
two-stage result these methods report and why a long-pending order is normal.
[Timeouts & When a Result Is Final](/docs/en/support/payment-timeout-and-finality-faq)
covers the timeout side. This page is about the account number itself and about
amounts that do not match.
## What is a virtual account, and how does the flow differ from a redirect payment?
On a VA payment Waffo returns a **bank account number generated for that order**
instead of sending the user to a payment page. The user then transfers to that
number from their own banking app, ATM, or branch — entirely outside your
session and outside Waffo's.
Consequences you have to build for:
| Property | Effect on your integration |
| - | - |
| The user leaves and pays later | The order can stay pending for hours or days; that is not a symptom of failure |
| There are two results | Acceptance first (an account number was issued), payment result later |
| The bank collects the amount, not the checkout | The amount is not validated by a Waffo screen before it is sent |
| No redirect completes the payment | There is nothing for you to poll on the browser side; poll the order |
Related payment-method types on the checkout include `VA` (virtual account) and
the over-the-counter types, where the user pays in cash at a counter. The
customization parameters for showing only VA methods are documented in
[Checkout customization](/docs/en/developer-docs/integration/checkout/customization).
## How long is a VA number valid, and can I control it?
VA is one of the few method families where the expiry can actually be set on the
order rather than being fixed by the payment method. Where a VA method supports
it:
* The expiry window is capped at a small number of days; the exact range a row
accepts is shown in the `Order Expiry Customization` column on **Service →
Payin** (see [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq)).
* If you do not specify one, the method's default applies.
Every other method family generally uses its own default expiry, which you
cannot override; the published guidance on
[`orderExpiredAt`](/docs/en/developer-docs/tools-and-references/references/faq)
covers that. The Merchant Portal's PayIn page shows the expiry behaviour per
payment method.
## Is the VA number unique to my order? Could someone else's payment land on it?
Within the order's validity window the account number is **unique** — it belongs
to that order and nothing else.
After the order has expired, that guarantee ends. **Months later the same
account number can appear again**, issued for a different order. So:
* Never treat a VA number as a durable identifier for a customer, an
order, or an invoice.
* Never store it and reconcile against it after the order has expired.
* Never show an expired VA number to a user again, and never let a user pay
against a number you handed out for a previous order — reissue by creating a
new order.
Match on your own order identifiers instead. The VA number is a payment
instruction with a short shelf life, not a key.
## The user typed the wrong amount, or used the number after it expired. Is my order dead?
No. Both cases are rejected **at the bank**, before anything reaches your order.
| What the user did | What they see | Effect on the order |
| - | - | - |
| Entered an amount that does not match | The bank's own error message, in the bank's interface | **None** — the order stays open |
| Used a number whose order has expired | The bank reports the account as expired or invalid | None on an already-expired order |
Because the bank validates the account number and the amount before accepting
the transfer, an incorrect attempt usually cannot even be submitted. **The order
status is not affected**, and the user can simply try again with the correct
amount for as long as the order is still valid.
The practical support answer: "your transfer was not taken; the order is still
open; transfer the exact amount shown before the deadline."
## The user paid more than the order amount. What happens?
On bank-transfer methods that report the amount actually received, an
overpayment produces a **successful order whose received amount is larger than
the amount you requested**. The result notification carries both figures, so the
difference is visible to you.
What that means:
* The order is payable and fulfillable — do not treat it as an exception that
blocks delivery.
* The surplus is **not** something the payment method reverses on its own.
* Returning the surplus to the user is a **merchant action**. Depending on the
method it may not be refundable through the API at all, in which case it is
settled outside the payment.
Reconcile on the received amount, not on the amount you requested, or your
ledger will drift by exactly the surplus.
## The user paid less than the order amount. Can I still fulfil?
Only if they top it up in time. An underpayment is reported as a distinct
outcome — the notification says the amount received was insufficient and carries
the **shortfall** — and it resolves in one of two ways:
| What follows | Order outcome |
| - | - |
| The user transfers the shortfall before the deadline | The order becomes **successful** |
| The deadline passes with the amount still short | The order becomes **failed** |
So an underpaid order is not immediately failed, and it is not immediately
payable either. Hold fulfilment while it is short. When it ends up failed, the
amount already transferred has to be returned to the user — again a merchant
action, outside the original payment on methods that cannot refund a failed
bank transfer through the API.
Two rules that save the most trouble:
1. Never fulfil on the underpayment notification. Wait for the final state.
2. Never assume the shortfall notification is the end of the story — the user
may still complete.
## Is this the same as stablecoin overpayment and underpayment?
The **shape** is the same, the **handling is not**. Both are cases of "the
amount that arrived is not the amount requested", and both exist because the
payer controls the amount. But the resolution paths differ enough that you
should not reuse one code path for both.
| | Bank transfer / VA (fiat) | Stablecoin |
| - | - | - |
| Overpayment | Order succeeds; received amount exceeds requested; surplus returned by the merchant | Order succeeds; the surplus is handled per the documented case classification |
| Underpayment | Succeeds if topped up before the deadline, otherwise fails | Handled per the documented case classification |
| Wrong destination | Not possible — the bank rejects an invalid or expired account | Possible: wrong chain, wrong token, or an old address |
| Return of funds | Merchant action, frequently outside the payment method | Refund requires a `refundAddress` and is chain-locked |
The stablecoin rules are in
[Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq). Read
that page rather than inferring from this one, and vice versa.
## Can I refund a bank-transfer order through the API?
Not always — and this is worth checking before you accept the method rather than
after your first return request. Several bank-transfer payment methods are **not
refundable through the refund API at all**: the money did not come in over a
reversible rail, so there is nothing to reverse. Where the API can refund, some
methods need additional user details (bank account information) because the
money is returned by transfer rather than reversed on the original payment.
| Situation | What happens |
| - | - |
| The method cannot be refunded through the API | The refund request is rejected with `A0017` |
| The method can be refunded but needs more fields | The request is rejected with `A0003` and the missing field names are listed; resend with them filled in |
| The method can be refunded normally | The refund behaves like any other |
Do not decide which case you are in from the payment method's name in your own
code — required fields are configuration, not a property of the method you can
infer. Submit the refund and branch on the response. The full rules are in
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq)
and [Refund FAQ](/docs/en/support/refund-faq).
## Do over-the-counter payments work the same way?
Broadly yes — the user pays outside the session, the result arrives in two
stages, and a long pending period is normal. The difference is what you hand the
user: instead of an account number, over-the-counter methods return a **payment
code** the user presents at a counter, and a set of instructions describing where
and how to pay.
Two practical points:
* The instructions are text and a code, not an image or a link you can rely on
rendering. Put them somewhere durable — an email is the usual answer, because
the user will pay hours or days later.
* The order behaves like a VA order for everything on this page: do not cancel
early, do not treat elapsed time as failure, wait for the final notification.
## Should I cancel a bank-transfer order that has been pending for hours?
No. For VA, bank-transfer and over-the-counter payments a long pending period is
the normal case, because the user pays on their own schedule. Cancelling early
creates the worst failure mode there is: the order is closed on your side while
the money is still on its way.
Wait for the final notification, or poll `/api/v1/order/inquiry`. See
[Confirming Payment Results](/docs/en/support/payment-results-faq) for the
long-pending case and
[Timeouts & When a Result Is Final](/docs/en/support/payment-timeout-and-finality-faq)
for what a timeout does and does not tell you.
## What should I show the user after creating a VA or over-the-counter order?
Everything they need to complete the transfer, and nothing that expires
silently:
1. The account number or payment code exactly as returned, in a form the user
can copy.
2. The **exact** amount, including currency and decimals — an amount mismatch
is rejected by the bank, so precision is not cosmetic.
3. The deadline, in the user's own terms ("pay before …"), not as a raw
timestamp.
4. A note that the payment is confirmed after the transfer is received, not
immediately.
Send the same details by email or another durable channel as well. The user will
close your page and pay later — that is the whole point of the method — and a
payment instruction that exists only on a page they navigated away from becomes
a support ticket.
## Where do I look next?
| Question | Page |
| - | - |
| The two-stage result, missing webhooks, long-pending orders | [Confirming Payment Results](/docs/en/support/payment-results-faq) |
| Whether a timeout means the payment failed | [Timeouts & When a Result Is Final](/docs/en/support/payment-timeout-and-finality-faq) |
| Whether a bank-transfer order can be refunded through the API | [Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq) |
| The stablecoin version of over- and underpayment | [Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq) |
| Restricting the checkout to VA methods | [Checkout customization](/docs/en/developer-docs/integration/checkout/customization) |
# Batch Payout Statuses & Funding
Source: https://waffo.com/docs/en/support/batch-payout-faq
How to read a batch payout's status and its per-row statuses, why a batch can disappear from the list instead of showing as failed, why only some country templates are offered to you, and where the money for a payout is allowed to come from.
This page covers how to **read** a batch payout — its statuses, its per-row
results, and where its funding comes from. For the step-by-step of creating one
in the Portal, and for withdrawals, see
[Withdrawal & batch payout](/docs/en/support/withdrawal-and-batch-payout-faq). For
the account the money moves through, see
[Payout Wallet & coverage](/docs/en/support/payout-wallet-and-coverage-faq). For the
single-payout API, see the [Payout FAQ](/docs/en/support/payout-faq).
## What are the limits on the batch file itself?
| Limit | Value |
| - | - |
| File format | **`.xlsx` only** — the Portal upload accepts nothing else |
| File size | Under **2 MB** |
| File name | **100 characters** or fewer |
| Rows per file | **1,000** maximum — a file with more is rejected and the batch is cancelled |
| Files per upload | One |
A CSV will not upload, whatever your spreadsheet tool suggests. Save as `.xlsx`.
An empty file — a template with no data rows — is also rejected and cancels the batch, so a batch
that vanishes immediately after upload is usually a file whose rows did not sit where the template
expects them.
## What do Batch name and Batch description do? Does the payee see them?
Neither is shown to any payee. Both are labels on your own batch record:
| Field | Rules | Who sees it |
| - | - | - |
| **Batch name** | Required. Letters, digits, underscores and hyphens only — no spaces or other punctuation. Up to 100 characters. | Your Portal account |
| **Batch description** | Optional. Up to 256 characters. | Your Portal account, including whoever confirms the batch |
The description exists to tell a colleague approving the batch what it is for. What the payee
actually sees on their statement is the per-row description from the file, not either of these.
## A row failed validation. Can I just send the good rows?
No. Validation is all-or-nothing: **a single failed row cancels the whole batch**, and there is no
way to submit the rows that passed. Fix the file and upload it again.
You do get told exactly what was wrong with each row. After validation the batch record carries a
**Checked** file — the same rows you uploaded, with a failure-reason column filled in on the rows
that failed, carrying the error code and its message. Work from that file rather than guessing:
the reasons are per-row and specific, covering field format problems, a row whose country or
currencies differ from the rest of the batch, a payee account the receiving side rejected, and rows
that fell past the available balance.
## What statuses can a batch have?
Exactly two, from your side:
| Batch status | Meaning |
| - | - |
| **In Progress** | The batch has been submitted and is being worked through — funds debited, rows routed, results being collected and accounted for. |
| **Completed** | Every row in the batch has reached a final result, whether success or failure. |
There is no third value. The filter on the batch list offers these two and
nothing else.
Two things follow from that, and both surprise people:
* **In Progress covers several distinct internal stages.** A batch can sit in
In Progress for a while and still be moving. Do not read a long In Progress
as a stall.
* **Completed means "all rows finalised", not "all rows succeeded".** A batch
in which every row failed is still Completed. Always read the row statuses,
never the batch status, to know whether money moved.
## Why can't I see a batch I know I created?
Because a batch only appears on your list once it has been **submitted**.
Before that, and if it never gets there, it is not in the merchant view at all
— the batch list query itself filters to In Progress and Completed rather than
merely hiding other values in the UI.
The case to watch for: a batch that is abandoned before processing — for
example because the available balance was insufficient at submission, or
because it sat unsubmitted past its validity — **drops out of the list rather
than appearing as failed or cancelled**. If a batch you expected has vanished,
do not assume it silently went through. Re-check your balance at the time of
submission and create the batch again.
## What statuses can an individual row have?
Three, and these are the ones that tell you what actually happened:
| Row status | Meaning |
| - | - |
| **In Progress** | The row has been accepted and is being processed. |
| **Success** | Funds have been sent to the payee. |
| **Failure** | The row did not go out. Its amount is returned to your settlement account; whether its fee is returned depends on your payout agreement. |
Each row also carries a status timeline. The batch-level timeline is coarser —
it shows the submission, the processing start and the completion — so use the
row timeline when you need to explain a specific payee's delay.
If a row shows **no status at all**, it is a row that never got as far as being
routed — typically one that failed file validation. Treat a blank row status as
a failure to resubmit, not as a pending payout.
## What does the batch status timeline show?
Three points: **Submitted**, when the batch passed verification and was
accepted — before any money moved; **In Progress**, when the funds were debited
and processing began; and **Completed**, when every row finalised. Intermediate internal stages are not exposed as timeline nodes, so
the timeline is a progress indicator rather than an audit trail. For
reconciliation use the source, checked and result files attached to the batch
record.
## Why are only some countries offered when I download a batch template?
Because templates are issued **per contracted country**. The list you see is
derived from your signed payout agreements: one template per country covered by
an agreement that is currently active and inside its validity window.
* No active agreement means **no templates at all** — an empty list here is a
contracting question, not a bug.
* The restriction is enforced on the **download**, not only in the UI. Asking
for a country you are not contracted for is rejected rather than served.
* The country you are contracted for also determines which columns the template
asks for, which is why you cannot reuse one country's file for another.
If a country you have signed for is listed but the download fails, that is
worth raising with your Waffo contact — it means the agreement exists but the
template file for it has not been provisioned.
## Is there a second confirmation step before a batch goes out?
Yes — an explicit confirmation in the Portal after the preview. You see the
available settlement balance, the total payout amount, the recipient total and
the fee total, and you have to confirm before anything is debited. Insufficient
balance rejects the submission at that point rather than part-processing the
batch.
That confirmation is an in-Portal step. Do not build a runbook that expects a
verification code to arrive by email at this stage.
## I uploaded a batch but did not submit it. Can I cancel it?
You do not need to. A batch that has been uploaded but not submitted is **closed automatically
around four hours after it was created**, and closing it costs nothing — the debit happens at
submission, so an unsubmitted batch has never touched your balance.
Two consequences worth planning around:
* **There is no cancel action**, because there is nothing to cancel. If you uploaded the wrong
file, upload the right one as a new batch and leave the old one to lapse.
* **Do not prepare a batch well ahead of when you intend to send it.** A batch reviewed on one day
and submitted the next will not be there — it will have lapsed, and lapsed batches drop off the
list rather than showing as cancelled, as described above. Upload and submit in one sitting.
## Do batch payouts send webhooks?
No. The payout webhook is pushed only for payouts created through the API; a payout that
originated as a row in a batch upload produces no notification, whatever its outcome. Track batch
results from the batch record and its result file, or inquire each row's `payoutOrderId` through
the payout API. See
[Payout statuses, notifications & reports](/docs/en/support/payout-status-notifications-and-reports-faq).
## Where does the money for a payout come from?
From your **acquiring settlement proceeds**. Every balance check and every
debit on the batch payout path is taken against your **settlement account** for
the relevant currency — the preview you approve reads that same balance, so
what you see at preview is the account that will be charged.
Two consequences:
* Money credited to you anywhere other than the settlement account for the
funding currency is not reachable by a payout batch. The payout path does not
offer an account picker, and it will not fall back to another account if the
settlement balance is short — it rejects the submission instead.
* Payout is therefore downstream of settlement. If you need to increase payout
capacity, the lever is settlement timing, not a separate top-up flow. See
[Settlement cycle](/docs/en/support/settlement-cycle-faq) for what governs when
proceeds land.
## Which of my settlement accounts can fund a payout? Can I choose?
You cannot choose — it is determined by **currency**, and the currency comes
from your payout agreement.
* The payout agreement for a country records the funding currency alongside the
payee currency. That funding currency is the sole selector of which
settlement account is debited.
* The funding currencies available when your agreement is set up are themselves
limited to currencies in which you actually hold a settlement account, so an
agreement cannot be written against an account you do not have.
* Because the batch's funding currency is resolved per row from your agreements
and then rolled up, **all rows in one batch must share the same funding
currency, payee currency and country** — which is the underlying reason batch
validation is all-or-nothing on those three fields.
The practical planning rule: if you need to pay out in a corridor, you need
both an agreement for that corridor **and** settlement proceeds accumulating in
the funding currency that agreement names. Having the balance in a different
currency does not help, and Waffo will not convert it for you as part of the
payout.
## Why must every row in one batch share the same country and currencies?
Because a batch is funded as a unit. The funding currency is resolved from your
agreements per row and then rolled up to one value for the batch, and that
single value selects the one settlement account to debit. A batch mixing
corridors would need to debit more than one account, which the path does not
do.
That is why validation is **all-or-nothing** on country, funding currency and
payee currency, and why a single bad row cancels the whole file rather than
being dropped. Split multi-corridor payment runs into one batch per corridor
and submit them separately; there is no partial submission.
## A batch has been In Progress for a long time. What should I check?
In Progress spans several stages, so duration alone tells you nothing. Work
through this instead:
| Check | Why |
| - | - |
| Are any **rows** already final? | Rows finalise independently. A batch with successes already showing is moving, whatever the batch status says. |
| Does the **row** timeline show movement? | Row timelines are finer-grained than the batch timeline and will show where a specific payee is held up. |
| Has the **result file** appeared on the batch record? | It is produced as rows finalise and is the earliest reliable evidence of progress. |
| Did your settlement balance cover the batch at submission? | An underfunded batch is rejected at submission rather than stalling — if you cannot find the batch at all, see the question above about vanished batches. |
Do not resubmit the file to force progress. A second batch is a second debit
and a second set of payouts, so raise a stuck batch with your Waffo contact
rather than re-uploading it.
## How do I reconcile a batch once it is Completed?
Work from the files on the batch record rather than from the screen:
| File | Use |
| - | - |
| **Source** | The file you uploaded — your own reference for what you asked for. |
| **Checked** | The post-validation view, showing what was accepted. |
| **Result** | The per-row outcome, which is what you reconcile against your ledger. |
Row-level failures return their amount to the settlement account they were
debited from; check your Account History for the returned amounts rather than
inferring them from the batch total. See
[Balance & Account History](/docs/en/support/balance-and-account-history-faq) for
how those entries are described.
# Batch Refunds
Source: https://waffo.com/docs/en/support/batch-refund-faq
The Portal's Batch Refunds tab: template columns and limits, what upload validation checks and in which order, duplicate-file and one-batch-at-a-time rules, batch statuses, and where per-row failure reasons live.
Batch Refunds is a tab of the Portal's **Transactions** module, next to Payments, Refunds and Reports, and the only
place in the Portal where refunds are *initiated*. Each row of your file becomes an ordinary refund record, so
preconditions, partial refunds, cross-currency amounts and returned fees behave exactly as described in [Refund
FAQ](/docs/en/support/refund-faq).
The tab is enabled per account — if it is not in your Portal, it has not been switched on for you. Inside it,
**Export Batch Refund Template** and **Upload Batch Refund File** require the batch-refund upload permission,
while the list, its filters and **Export** are open to any role that can open the tab: if you can see the
buttons, you can use them.
## What does the flow look like end to end?
| Step | Portal action | What happens |
| - | - | - |
| 1. Download template | **Export Batch Refund Template** | 4-column CSV with one sample row, `Batch_Refund_Template.csv` |
| 2. Upload | **Upload Batch Refund File** | The file is validated as a whole; nothing is created yet |
| 3. Preview | Confirm dialog | Shows record count, total amount, currency |
| 4. Confirm | **Confirm** in the dialog | Rows are re-validated, then batch and refunds are created |
| 5. Track | Batch list filters and **Export** | Filter and export the batch list |
The whole flow is a Portal feature performed in your browser session; there is no batch endpoint on the
API-key-signed integration API — for an integration, use the single refund API instead. Confirming produces a
**Refund Batch ID** shaped `BRP` + MID + `YYYYMMDD` + a 6-digit daily sequence, e.g.
`BRP20260225000001`; the batch's **Batch Name** is the file name you uploaded, so name files
something you will recognise later.
## What must the file look like?
| Template column | Required | Notes |
| - | - | - |
| `Original Order Id` | Yes | Waffo acquiring order id of the payment being refunded |
| `Refund Amount` | Yes | Greater than zero, within the currency's decimal precision |
| `Refund Currency` | Yes | 3-letter code, identical for every row, equal to the order's currency |
| `Refund Reason` | No | Free text, carried onto the refund record |
Hard limits: `.csv` only, at most **2 MB**, at most **2,000 records**, non-empty and parseable — the Portal checks
extension and size before uploading, the server checks both again. Two things catch people out: **delete the
sample row** (a row carrying the template's example order id is rejected on its own, before any other check), and
**one currency per file**.
## Why was my whole file rejected?
Upload validation is all-or-nothing: nothing is created, and the Portal lists offending rows as *row, field,
message*. Checks run in a fixed order, which is why you often see only one category at a time:
1. File-level checks — format, size, emptiness, duplicate content, a batch already in progress.
2. The sample-row check; if it fires, it is the only error returned.
3. Per-row checks — order exists under your MID, no duplicate order inside the file, amount > 0, currency valid,
consistent, and equal to the order's currency.
4. The single-refund preconditions per row — order paid and refundable, no refund already in progress, inside the
refund window, amount within the remaining refundable amount, no dispute lock ([Refund FAQ](/docs/en/support/refund-faq) explains each).
5. The batch balance check, **only if every row passed** — the file's total must be covered by the same refund
funding source a single refund would use, or you get one batch-level error with no row number ("Insufficient
merchant refund account capacity for batch refund").
Stablecoin orders (USDT, USDC) cannot go in a batch: they require a refund address, for which the template has no
column. Refund those individually.
## Why can't I upload — duplicate file, or a batch in progress?
Duplicate detection works on file **content**, not name, scoped to your MID. Re-uploading the same content while
an earlier upload is still awaiting confirmation returns that same pending upload, so you can re-open the confirm
dialog; once a batch has been created from that content, the content is rejected permanently — to retry failed
rows build a new file, and any change, even the reason text, makes it a new file. A validated upload also expires
if left sitting: confirming later fails with *File token is invalid or expired*, and you upload again.
Separately, upload and confirm both refuse while **any refund in any of your batches is still in progress** ("You
have a batch refund in progress. Please wait for it to complete."), so plan large runs as a queue of files; single
refunds via the API are not blocked by this. And because confirm re-validates every row, a batch left sitting in
the confirm dialog while things changed — an order refunded elsewhere, a window closed, the balance moved — fails
at confirm with the first offending row's message instead of creating a partly invalid batch.
## What do the batch statuses mean?
| Status | Meaning |
| - | - |
| `INIT` | Transient — set as the batch record is created, recalculated immediately. Rarely visible, and not offered in the status filter |
| `PROCESSING` | At least one row is still pending or in progress |
| `PARTIAL_SUCCESS` | All rows finished; some succeeded, some failed |
| `FULL_SUCCESS` | Every row succeeded |
| `FAILED` | Every row failed |
Status is derived from the row counts rather than tracked separately, so it moves on its own as rows settle.
**Total / Success / Failed / Processing Count** are shown per batch: Failed Count includes rows that failed
validation at submit time, Processing Count rows not yet started. One bad row does not stop the batch — rows are
processed independently, which is what `PARTIAL_SUCCESS` means, and a batch has no retry action: put the failed
rows in a new file, or refund them one at a time.
## Where do I see which rows failed and why?
Click **Detail** on a batch: it opens the **Refunds** tab pre-filtered by that Refund Batch ID, carrying the batch
page's date range. Each refund is then a normal record — its detail page shows the Refund Batch ID, and the
failure reason when the status is *Refund Failed*. The Refunds tab also has a Refund Batch ID column and filter,
so you can reach the same view directly.
The batch list export (`Batch_Refunds_.csv`) is a **batch-level summary** — ids, name, amount, currency,
status, the four counts, operator, created and updated times — and does **not** contain per-row failure reasons;
times in it use the UTC offset your browser reports. The list itself filters on Refund Batch ID, status, currency
and created datetime, and opens on the last three months. Rarely a row fails so early that no refund record is
created: it still counts in Failed Count with nothing to open in the Refunds tab — contact support with the
Refund Batch ID for those.
## How does this relate to the single refund API?
Same engine, different entry point: identical lifecycle, account selection and settlement treatment. Two practical
differences. **Refund request id** — batch rows get a system-generated one you never see, so track them by Refund
Batch ID or by the refund order id in the Portal, not by your own reference. **Notifications** — batch rows carry
no per-row notify URL, so refund notifications go to the default refund notification URL configured for your
account; with none configured, batch rows send no webhook and the Portal is your only view.
***
A file that keeps failing? [Contact support](mailto:support@waffo.com) with it and, if one was created, the Refund Batch ID.
# Customizing the Checkout Page (Branding, Colors, Logos)
Source: https://waffo.com/docs/en/support/cashier-customization-faq
How to brand the Waffo checkout page: where Customization lives in the Portal, every color and size you can set, which field controls font size at order create, how order parameters override Portal settings, MID vs SubMID configuration, multiple logos and logo IDs, the live preview and its limits, and who on your team can save changes.
This page covers the look and feel of the Waffo-hosted checkout page — colors,
fonts, logos and merchant name. The *order* in which payment methods appear is
a different setting; that is covered by the Checkout Payment Method Display
Rules page. What the page does across the order lifecycle (reopened links,
processing states, result redirects) is covered by the Checkout Page Lifecycle
& Receipt page.
## Where do I configure how the Waffo checkout looks?
In the Merchant Portal, open **Checkout → Customization**. The page has three
tabs and a live preview beside them:
| Tab | What it holds |
| - | - |
| **Features** | Merchant logo upload and the logo list |
| **Style** | Theme and colors, text information (header layout and font sizes), and — where enabled for your account — amount display |
| **Cashier Ranking** | The order payment methods appear in, per country, plus the group-by-country switch |
Changes across all three tabs are staged locally and committed together when
you press **Save**.
## Which colors and sizes can I actually change?
The Style tab exposes four preset themes (**Default**, **Yellow**, **Dark**,
**Green**) as one-click starting points, then these individual controls:
| Control | What it paints | Range |
| - | - | - |
| `colorPrimary` | Primary/brand color — buttons and accents | Any color |
| `colorBackground` | Page background | Any color |
| `colorCard` | Card background | Any color |
| `colorText` | Primary text | Any color |
| `colorTextSecondary` | Secondary text | Any color |
| `colorTextTertiary` | Tertiary text | Any color |
| `colorDanger` | Error text and error states | Any color |
| `colorInfo` | Information/notice blocks | Any color |
| `colorBorder` | Borders and dividers | Any color |
| `fontSizeBase` | Base font size for the page | 12–24 px |
| `buttonRadius` | Corner radius of buttons | 0 px and up |
Under **Text Information** you additionally choose whether the header leads
with the **Order Description** or the **Merchant Name**, and set a font size
for each (14–24 px).
An **Amount Display** section — which currency is shown as the primary amount,
and the primary/secondary amount font sizes — is only present for accounts it
has been enabled for. If you do not see it, it is not enabled for your account.
Picking a preset theme replaces the colors and sizes but keeps your Text
Information and Amount Display choices.
## Which field controls the font size on the checkout page when I create an order?
`paymentInfo.cashierAppearance`. It is a **JSON string** carrying a `variables`
object, and the base font size is the `fontSizeBase` key inside it:
```json theme={null}
{
"variables": {
"fontSizeBase": "18px",
"colorPrimary": "#2749F8",
"buttonRadius": "24px",
"headerDisplay": {
"primaryDisplay": "orderDesc",
"orderDescFontSize": "18px",
"merchantNameFontSize": "14px"
}
}
}
```
The keys are exactly the ones listed in the table above, and sizes carry their
`px` unit as strings. Two behaviours worth knowing:
* The value must be **valid JSON**. A malformed string is discarded and the
order is still created — it will simply render with your Portal
configuration instead, with no error to tell you why.
* Only the keys you send are applied. Everything you omit falls back to your
Portal configuration, then to the system default.
## My Portal styling is not showing on the checkout page. What wins?
Styling is resolved in this order, highest first:
| Priority | Source |
| - | - |
| 1 | Values sent on the order (`paymentInfo.cashierAppearance`, and the brand fields below) |
| 2 | Your Portal **Checkout → Customization** configuration |
| 3 | Waffo system defaults |
The merge is **per key**, not all-or-nothing: a `cashierAppearance` that sets
only `colorPrimary` keeps your Portal values for every other key. So if a
single color looks wrong while the rest looks right, check what your
integration is sending on the order before checking the Portal.
The same precedence applies to the two brand fields:
| Field | Effect |
| - | - |
| `brandInfo.cashierDisplayName` | Overrides the configured merchant display name for that checkout session. Max 128 characters |
| `brandInfo.cashierLogoUrl` | Overrides the configured logo for that checkout session. Max 512 characters |
## I saved a new style. Which orders does it affect?
The configuration is read **when a checkout page is rendered**, not frozen at
order creation. In practice:
* A checkout page a customer already has open is unaffected — it keeps
rendering what it loaded with until it is reloaded.
* Any checkout page opened after the save picks up the new configuration,
including pages for orders that were created before you saved.
There is no per-order snapshot of your Portal styling. If a specific order must
keep a specific look regardless of later Portal edits, send the styling on the
order itself.
## Can each of my SubMIDs have its own look?
Yes. The Customization page has a **Configuration Scope** card listing your
SubMIDs, controlled by one switch:
| `Apply MID config to all SubMIDs` | Behavior |
| - | - |
| **On** | The SubMID list is hidden; you maintain one configuration |
| **Off** | Each SubMID is listed with a status tag and its own actions |
With the switch off, every SubMID row shows either **Using MID config** or
**Customized**. **Edit** opens that SubMID in the same three tabs — you are
editing that SubMID, not the MID, and the header tells you so. **Reset** (only
offered on a customized SubMID, and behind a confirmation) drops that SubMID's
own logos and style so it goes back to Using MID config.
Two details that surprise people:
* Opening a SubMID that is on **Using MID config** shows you the MID's style
as a starting point but **not** the MID's logos. If you change nothing, the
SubMID stays on Using MID config — it is not silently converted into a
customized one.
* Only SubMIDs that have payment methods available are listed.
## How do I upload more than one logo, and where does the logo ID come from?
Upload logos in the **Features** tab. Rules the page enforces:
| Rule | Detail |
| - | - |
| Formats | **PNG, JPG, JPEG** only — SVG is rejected |
| Recommended size | 40 × 40 px |
| Default logo | The first logo you upload becomes the default automatically; **Set as default** moves it to any other logo |
| Deleting | Deleting the default promotes the first remaining logo to default |
| Logo ID | Assigned by Waffo **on save**, not at upload time |
The ID format is stable and predictable:
| Level | Format | Examples |
| - | - | - |
| MID | `logo_M{n}` | `logo_M1`, `logo_M2` |
| SubMID | `logo_S{last 3 digits of the SubMID}_{n}` | `logo_S417_1`, `logo_S417_2` |
IDs are unique within your account, and once a logo has been saved its ID is
never reused.
## I have three logos but my newest one is logo\_M5. Is that a bug?
No. The sequence number is allocated from the highest number ever used on that
MID or SubMID, and deleted logos keep their number. Delete `logo_M2` and
`logo_M3`, upload a new logo, and it becomes `logo_M4` — the gaps are
permanent by design, so an ID you hard-coded in your integration can never
start pointing at a different image.
## Can I pass a logo directly on the order instead of using a logo ID?
Yes, and both go in the **same field** — `brandInfo.cashierLogoUrl`. It is
routed by prefix:
| Value starts with | Treated as | Behavior |
| - | - | - |
| `http://` or `https://` | A logo **URL** | That image is used for this checkout session |
| `logo_` | A logo **ID** | The matching uploaded logo is used |
| Anything else | Invalid | Silently dropped; the order is still created |
A logo URL takes precedence over a logo ID, so passing a URL overrides your
Portal logo list entirely for that session (your Portal *style* configuration
is still applied). Fallbacks when no usable logo is found:
* An ID that does not exist, or belongs to a deleted logo → the **default**
logo is used.
* No logo passed at all → the **default** logo is used.
* A URL that does not pass validation → dropped, then the same fallbacks apply.
* No logo configured anywhere → no logo is displayed.
## What does the header show if I have only a logo, or only a name?
Four combinations, all of them supported:
| Logo | Merchant display name | Header shows |
| - | - | - |
| Yes | Yes | Logo on the left, name beside it |
| Yes | No | Logo only |
| No | Yes | Name only |
| No | No | No merchant identity block — the page leads with the amount |
The name shown is always the **cashier display name** (from the order, from
your SubMID, or from your MID, in that order). Your KYB-registered legal name
is never used here. See the Checkout Page Lifecycle & Receipt page for the one
exception involving disclosure footnotes.
## How accurate is the live preview?
The preview renders a real checkout page next to the form, so colors, sizes,
logo, header layout and payment-method ranking all update as you edit. A
Mobile / Desktop toggle sits under it, the preview follows the country you have
selected on the **Cashier Ranking** tab, and it renders in your Portal
interface language.
Two limits are worth knowing before you report a difference as a bug:
* The preview cannot know **per-order availability**. Real checkout pages gray
out or drop a payment method that is temporarily unavailable, or that does
not apply to a specific order; the preview does not, so a real buyer may see
slightly fewer options.
* The preview and the real checkout share the **same cap on how many payment
methods can be shown on one flat list**. If you turn off group-by-country and
your deduplicated list exceeds that cap, both the preview and the real page
fall back to country grouping — the Portal warns you with the exact counts
when you cross it.
## Who on my team can change these settings?
Saving is gated by a single checkout-customization edit permission. An account
without it gets the whole page **read-only**: preset buttons, color pickers,
sliders, switches, drag handles and upload are all disabled, there is no Save
button, and the SubMID list offers **Detail** instead of **Edit**. The page
itself remains viewable.
By role, as the Portal role set is defined:
| Role | Customization page |
| - | - |
| Super Admin | View and save |
| Admin | View and save |
| General | View only |
| Read-only | View only |
| Developer | View only |
| Dispute Manager | No access |
## Do I need Waffo to approve a customization change?
No. Saving is immediate and self-serve — there is no review step. If a save is
rejected, it is a validation problem on the values you entered, not an
approval.
***
Still not rendering what you expect? [Contact support](mailto:support@waffo.com)
with the order ID and, if you send styling on the order, the exact
`cashierAppearance` string you sent — the most common cause is an order-level
value quietly overriding the Portal.
# Checkout Payment Method Display Rules
Source: https://waffo.com/docs/en/support/cashier-display-rules-faq
How the Waffo checkout decides which payment methods to show and in what order: merchant-defined ranking, newly signed methods, folding, unavailable methods, country grouping, and display-mode options.
This page explains the rules the Waffo checkout uses to decide which payment
methods your users see, how they are grouped, and in what order.
## Can I control the order payment methods appear in?
Yes. In the Merchant Portal, go to **Cashier Customization → Cashier Ranking**.
There you can:
* Configure ranking **per country**, at either MID or SubMID level.
* **Drag and drop** payment methods (and sub-methods within a type) to reorder
them, or use the **pin** action to move an item to the first position.
* Preview the resulting checkout layout in real time before saving.
* Click **Reset to Default Setting** to remove your customization for the
selected country and fall back to the platform default ranking.
The list only contains payment methods available under your active agreements.
If you have never customized a country, the platform default ranking applies.
## Where do newly signed payment methods appear?
If you have already customized the ranking, a newly activated payment method
never disrupts your existing order:
* Your configured items keep their exact positions.
* New payment methods are **appended to the end** of the list, in the
platform's default relative order.
* A new sub-method (for example a new wallet under eWallet) is appended to the
end of its own type group.
There is no visual "new" marker on the ranking page, so a newly signed method
appears silently at the bottom of its group. If ordering matters to you, check
the ranking page after signing a new payment method. If you are still on the
default configuration, the list simply refreshes with the new method in its
default position.
## Why are some wallets only visible after clicking "See more"?
When a payment type contains more than three sub-methods, the checkout shows
the first three (by ranking) and folds the rest behind a **See more (X more)**
button. Clicking it expands the full list instantly; **Show less** folds it
back. This keeps the page compact — your top-ranked methods get the visibility.
The same folding applies in the Portal's live preview.
## Why is a payment method grayed out on the checkout?
When a payment method is temporarily unavailable (for example, during channel
or provider maintenance), the checkout **grays it out instead of hiding it**:
* Graying out does not change the configured ranking, so the layout stays
stable.
* If every sub-method under a type is unavailable, the whole type icon is
grayed out and cannot be selected.
* The default selection automatically falls on the first *available* payment
method.
Availability is restored automatically once the maintenance ends.
## How are the country tabs organized? Why is a wallet listed under a country I did not expect?
Checkout tabs group payment methods by **Payment Method Country** — the
country a payment method itself belongs to (a local wallet belongs to its home
country; card schemes are Global). This mapping is defined by the platform and
cannot be changed per merchant.
* Methods with global support appear both under their home-country tab and
under the **Global** tab.
* The tab selector only appears when the order resolves to two or more tabs.
* The default tab is inferred from the user's IP; if no tab matches, the
Global tab is selected, then alphabetical order as a final fallback.
## Can I narrow what a user sees on a specific order?
Partly, and the exact behavior surprises people.
Pass the optional `payMethodCountry` field when creating the order and the
checkout hides **local** payment methods that belong to other countries.
**Globally available methods — card schemes and similar — are always kept**,
because they are not tied to any single country. So passing
`payMethodCountry=IDN` does not reduce the page to Indonesian methods only;
you will still see the global ones. That is expected behavior, not a bug.
Two more effects worth knowing:
* If you have at least one local method in the country you passed, the
checkout stops showing country tabs and presents a single list.
* If you have no local method for that country, the methods keep their own
country grouping, so tabs can still appear.
If none of your contracted methods survive the filter, order creation is
rejected rather than showing an empty page.
Omit the field and the checkout shows all payment methods under your
agreements, grouped into tabs as described above.
## Can I remove country tabs, or pull one wallet out of its group?
Two additional display options exist for merchants with specific layout needs:
* **Flat display** — all payment methods across countries are shown on a
single page with no country tabs. If the flattened list would be too long,
the checkout automatically falls back to country grouping for that order.
* **Standalone display** — a single payment method is pulled out of its type
group and ranked as an independent top-level item (card types cannot be
pulled out).
Both are off by default. Contact your Waffo account manager if you want either
display form enabled for your account.
## This wallet returned only a checkout URL — what does the user do on the checkout page?
Not every payment method has a deeplink. Several Southeast Asian wallet and
QR-code methods return a **Waffo checkout URL**, and the step that actually
authorizes the payment happens on that page:
* **QR-code methods** render the QR code on the Waffo checkout. The user scans
it with their own bank or wallet app.
* **Some wallets** first collect the user's phone number on the checkout
page. After the user submits it, the wallet app pushes an
authorization request to that user's phone, and the user approves it there.
Two integration consequences:
* Your app and WebView must be able to open the checkout page — if you only
handle deeplinks, these methods dead-end.
* Users need to be able to **scan, save or copy the QR code**, so do not render
the checkout in a container that blocks long-press or screenshots.
Which URL field to read (`webUrl` versus `deeplinkUrl`, driven by
`orderAction.actionType`) is covered in the
[integration FAQ](/docs/en/developer-docs/tools-and-references/references/faq).
## A payment method I expected is not on the checkout at all. How do I work out why?
Missing is different from grayed out. Grayed out means temporarily
unavailable — see above. Missing means the method never entered the list for
that order. Work down this list, in order; each step is something you can check
yourself:
| Check | How |
| - | - |
| Is the method active under your agreements **in this environment**? | `POST /api/v1/paymethodconfig/inquiry` — sandbox and production answer separately |
| Does it support the order's currency? | The same response lists the currencies per method |
| Is the order amount inside the method's accepted range? | The same response carries the amount range — check it before creating the order |
| Did you filter it out? | `payMethodType` / `payMethodName` / `payMethodCountry` on the order narrow the list |
| Does it support the terminal you sent? | A method that does not support your `userInfo.userTerminal` is not offered |
| Is it folded? | With more than three sub-methods under a type, the rest sit behind **See more** |
The two that catch people most often are the amount range and the environment.
An amount just outside a method's range silently removes it rather than
producing an error, and a method that is live in production is not necessarily
enabled in a test environment.
The mechanics of each check are in
[Payment method configuration and limits](/docs/en/support/payment-method-config-and-limits-faq).
If the method survives all six checks and is still absent,
[contact support](mailto:support@waffo.com) with the order ID, the amount, the
currency and the terminal you sent.
## Does every payment always involve a redirect? Can I remove that step?
No, and no. The number of hops is a property of the payment method, not
something the checkout adds or you can configure away:
| Payment method behaviour | Hops the customer sees |
| - | - |
| Card | Stays on the checkout. The card issuer may insert an authentication step |
| QR code | Stays on the checkout — the code is rendered there, and the customer scans it with another app |
| Codes and bank details | Stays on the checkout — the details are shown there, and the customer pays elsewhere |
| Wallets that authorize on their own surface | One hop out to the wallet, then back |
| Wallets that push an authorization to the customer's phone | No hop — the checkout collects an identifier and waits |
So a redirect only exists where the payment method requires the customer to
authorize somewhere Waffo does not control. There is no setting that removes it,
and building your own page does not remove it either — you receive the redirect
instead of the checkout doing it for you.
What you *can* remove is Waffo's own selection page: pin the payment method on
the order and the customer goes straight into that method's flow. And you can
remove the Waffo result page at the end if the bypass is enabled for your
account — see
[Checkout page lifecycle](/docs/en/support/cashier-order-lifecycle-faq).
## What does the amount breakdown show? Can price and tax be listed separately?
They already are. Where the order carries the relevant values, the checkout
itemizes the amount rather than showing one total:
| Line | When it appears |
| - | - |
| Price | The order amount, before any discount |
| Discount | A promotion code or coupon is applied to the order |
| Subtotal | Shown as order amount → customer amount, with the exchange rate underneath, for a cross-currency order |
| Customer fee | The order carries a fee charged to the customer |
| Tax | The order is taxable. The line is labelled with the applicable tax's own name, so customers see the local term rather than the word "tax" |
| Total | Always |
Two behaviours worth knowing:
* Where the tax cannot be computed until the customer supplies an address, the
tax line is shown with a prompt to enter the address, and it resolves once
they do — so the customer always sees that tax exists before they commit.
* On a subscription, the total line reads as the amount due today, which for a
trial or a delayed start is not the recurring amount.
Which currency leads the display, and the font sizes of the amounts, are part
of the customization surface — see
[Customizing the checkout page](/docs/en/support/cashier-customization-faq).
## Can I change the payment-method icons or brand names, or add my own text and links?
No. The customization surface covers your own branding — logo, merchant name,
colors, radii, base font size, header layout and amount display — and nothing
beyond it. Specifically, there is no control for:
* Payment-method icons or brand names. These are rendered from
platform-supplied assets so that customers recognise the method, and there is
no download of them in the Portal either. If you need a brand's mark for your
own pages, take it from that brand's own brand guidelines, where its usage
rules also live.
* Free text of your own anywhere on the page.
* Your own links — a payment guide, help centre or T\&Cs link.
If your checkout needs merchant-authored copy or links next to the payment
methods, the route is a merchant-built page, not a setting — see
[Building my own checkout page](/docs/en/support/merchant-built-checkout-faq).
The full list of what *is* configurable is in
[Customizing the checkout page](/docs/en/support/cashier-customization-faq).
## Is the default ranking a recommendation for my market?
It is the **platform default** — the order applied when you have not
customized a country. It is not derived from your traffic, it is not tuned per
merchant, and it is not advice about your market. Treat it as a sensible
starting point, not as a recommendation you should be reluctant to change.
If ranking matters commercially to you, the useful move is to customize the
countries you care about and compare conversion yourself; the ranking page is
per country, so you can change one market without touching the others. For
input on which methods to lead with in a given market, ask your Waffo contact
rather than inferring intent from the default order.
## The country switcher is easy to miss. How do I get customers to the right list?
Do not rely on the customer finding it. Two things reduce the problem, and the
first is much stronger than the second:
* **Pin the country on the order.** If you know where the customer is — from
their account, their shipping address, the storefront they are on — pass
`payMethodCountry` on `/api/v1/order/create`. The checkout then presents one
list with no tabs to find, while global methods such as card schemes stay
available. This also fixes the case where the customer's IP resolves to the
wrong country because they are on a VPN or a corporate network.
* **Pin the payment method.** If your own page already asked which method they
want, pass `payMethodType` and `payMethodName` and the customer skips the
selection page entirely.
The parameter behaviour, including what `payMethodCountry` does and does not
filter, is in the question above and in
[Checkout customization](/docs/en/developer-docs/integration/checkout/customization#when-to-pass-paymethodcountry).
## If I price in a local currency, can customers still pay by card?
The country tabs group by the **payment method's** country, not by the order
currency, so pricing in a local currency does not remove card methods. Card
schemes are global: they sit under the Global tab and stay there whichever
local tab a customer opens. A customer who switches to a local tab is seeing
that country's local methods — the cards have not gone away, they are one tab
over.
What can genuinely make a card method unavailable is the currency itself not
being enabled for your card agreement. That is visible to you before you create
the order: the currencies each method accepts are listed in the
`paymethodconfig/inquiry` response — see
[Payment method configuration and limits](/docs/en/support/payment-method-config-and-limits-faq).
# Checkout Forms, Prefill & Error Handling
Source: https://waffo.com/docs/en/support/cashier-forms-and-flow-faq
What the customer actually has to fill in on the Waffo checkout: whether the details you sent are prefilled, when the payment-method selection page is skipped, card form validation rules and lengths, how a saved card behaves, QR-code lifetime, and which checkout errors are retried versus sent straight to the failure page.
This page is about what the customer has to type and what happens when
something is wrong. Which payment methods appear and in what order is covered
by the Checkout Payment Method Display Rules page; page-level branding is
covered by the Customizing the Checkout Page docs; reopened links, processing
states and result redirects are covered by the Checkout Page Lifecycle &
Receipt page.
## I send the customer's email and name at order create. Why does the checkout still ask for them?
Because re-using what you sent is an **account-level setting that is off by
default**, and it is set per field. Until it is turned on for your account, the
checkout treats your values as data for the payment, not as something to show
back to the customer.
Why it works that way: a meaningful share of integrations send placeholder or
synthetic contact details. Displaying an obviously fake email back to the buyer
costs trust at the worst possible moment, and some of those values are rejected
downstream anyway. Opt-in per field keeps that out of the buyer's face.
These are the fields that can be re-used, selected individually:
| Field |
| - |
| `userInfo.userEmail` |
| `userInfo.userPhone` |
| `userInfo.userFirstName` |
| `addressInfo.postcode` |
| `addressInfo.address` |
| `addressInfo.city` |
| `addressInfo.region` |
| `addressInfo.addressCountryCode` |
There is a mutually exclusive **none** option: either you list fields, or you
declare that nothing is re-used. You cannot combine "none" with a field list.
Note that first name carries the full name — if you send a first name without a
last name, the last name is stored empty rather than being guessed.
To change this for your account, contact your Waffo contact with the list of
fields you want re-used.
## If the customer edits the email I sent, which value do you use?
The customer's. A value the buyer changes on the checkout page replaces what
you sent for the rest of that payment, including what is passed on to the
payment method. This is deliberate: the buyer is the one who knows their own
contact details, and for methods that verify the address or send a
confirmation, using a stale value would fail the payment.
Your original order data is unchanged — check the order record if you need to
know what you originally sent.
## Do returning customers have to type everything again?
No. Two separate mechanisms exist, and they are easy to confuse:
| Mechanism | Whose data | Controlled by |
| - | - | - |
| **Saved information** | What the customer typed on a previous purchase | The customer, via a "save my information" option on the form |
| **Merchant prefill** | What you sent on the order | Your account setting above |
## When does the checkout skip the payment-method selection page?
Three rules, evaluated in this order:
| Situation | What the customer sees first |
| - | - |
| The order resolves to exactly **one group, and it is the card group** | The card form directly |
| The order resolves to exactly **one group with exactly one method**, and that method needs a form | That method's form directly |
| Anything else | The selection page |
On top of that, if you passed a `payMethodId` on the order and that method
needs input, the checkout opens that method's form directly rather than the
selection page. If you passed a `payMethodId` that needs no input, its group is
preselected on the selection page.
When no `payMethodId` is passed, the preselection is the **first available**
method in the first group that has one — an unavailable method is never
preselected.
## What are the exact validation rules on the card form?
| Field | Rule |
| - | - |
| Card number | Required. **17–23 characters** as displayed, including the spaces the field inserts as you type |
| Expiry | Required. Exactly **5 characters** (`MM/YY`), and the date must be in the future |
| CVV | Required. Length depends on the card brand — see below |
| Cardholder name | Required. Up to **100 characters**. Latin letters only |
CVV length is resolved from the brand as soon as the card number identifies it:
| Brand | CVV length |
| - | - |
| American Express | Exactly 4 |
| Visa | Exactly 3 |
| Mastercard | Exactly 3 |
| Brand not yet identified, or other | 3–4 accepted |
So a CVV that was accepted while the number was incomplete can become invalid
once the brand is known. That is expected.
## Why do I only see one error message when several fields are wrong?
Each field shows **at most one message** — the first rule it fails, in the
order required → format → too short → too long. Messages appear once a field
has been interacted with; pressing the pay button marks every field as
interacted, so on submit each failing field shows its own single message.
One case behaves differently. **"This card is currently not supported"** is
evaluated live against the card number as it is typed, so it can appear before
you leave the field. But it is still a message on the card-number field, which
means a length or format problem on the number itself is reported first — fix
the number, and the unsupported-card message is what remains.
That message means the card itself is out of scope (its type, brand or issuing
region is not accepted), not that the form is malformed. Related codes you may
see on the same field:
| Code | Meaning |
| - | - |
| `008001B027` | This card is currently not supported |
| `008001B028` | The card's type is not supported |
| `008001B029` | The card's issuing region is not accepted |
| `008001B030` | The card's brand is not supported |
| `008001B125` | Cardholder name must contain only English letters |
## What does a customer with a saved card see?
If you pass a `userPaymentAccessToken` on the order, the checkout goes straight
to a short completion step: the card is shown masked, and the **only** field
the customer has to enter is the **CVV**. The card lookup step is skipped
entirely, so there is no per-keystroke card check on that page.
If you do **not** specify a payment method, the selection page lists the cards
already associated with that customer under your account, alongside the option
to add a new card. Each customer can have one default card per merchant, and
the checkout preselects it. You can also restrict, via the API, which saved
cards are offered on a specific order.
Removed or expired tokens are rejected rather than silently ignored — code
`008001B113` means the card has been removed.
## Can the customer choose to save their card for next time?
A save option is offered on the form, and whether it appears depends on the
method and on your account:
| Method type | The save option appears when |
| - | - |
| Credit / debit card | Card saving is enabled for your account for that method |
| Everything else | Saving the customer's entered details is enabled for your account |
You can also drive it from the order: `paymentInfo.bindForFutureUse` (`0` = no,
`1` = yes) asks for the payment method to be bound for future use on that
payment. Nothing is saved silently — the customer sees the option on the form.
Where saved cards then show up, and how to charge them later, is covered by the
tokenization documentation.
## I pass a fixed card prefix on the order. Does that change the form?
Yes. When the order fixes the leading digits of the card number, the checkout
renders those digits as a non-editable prefix in front of the input, and the
number field's accepted length shrinks by exactly the number of digits it
already shows. The customer types only the remainder, and the total accepted
length is unchanged.
## Are there test cards on the sandbox checkout?
Yes. On sandbox, the card form shows quick-fill card chips above the fields;
clicking one populates number, expiry, CVV and name. They are rendered only in
the sandbox environment — there is no code path that shows them in production,
so nothing needs to be switched off before go-live.
## Can a customer save a QR code and pay it later?
No, and this is worth putting in your own support macros.
* The QR code is valid for **that payment attempt only**. It is not a reusable
payment address and cannot be re-presented for a later order.
* Once the order has expired or succeeded, reopening the checkout link no
longer shows a QR code at all — the link resolves to the failure page or the
success page, according to the real order state.
* The **Save QR Code image** button is offered on **mobile only**. On desktop
it is not rendered, because a saved desktop screenshot is exactly the artefact
that leads customers to try to pay an expired code.
## The checkout page is showing an error but keeps spinning. Is it stuck?
No — that is the retryable path. Every error that reaches the checkout carries
a retryable flag, and the page behaves differently for each:
| Kind of error | What the customer sees |
| - | - |
| **Not retryable** — declines, limits, validation, expired links | The result is final: the page goes to the **failure page** with the reason |
| **Retryable** — timeouts, system errors, concurrent-operation conflicts | The page stays on the **processing view**, shows the reason, and Waffo keeps retrying in the background. When a retry produces a result, the page advances on its own |
The customer never has to resubmit on the retryable path, and you should not
build a client-side retry on top of it. Treat your payment notification or an
order inquiry as the authority for the final state — not what the page showed
at any moment.
## Which checkout error codes should I recognize?
The full list is long and mostly issuer-driven. These are the ones that come up
in integration testing, where the cause is on your side or the customer's:
| Code | Message | Usually means |
| - | - | - |
| `008000S006` | Timeout. Please try again later | Transient; retryable |
| `008001B003` | Payment link expired. Please create another payment | The checkout link's validity window has passed |
| `008001B007` | You have submitted, please refresh the page | Duplicate submission of the same step |
| `008001B011` | Please input the correct phone number | Phone number format rejected |
| `008001B025` | Sorry your payment order has expired | The order, not just the link, has expired |
| `008001B049` | Order has expired, please place a new order | Same, from the payment step |
| `008001B058` | OTP has expired. Please request a new one | One-time code timed out |
| `008001B059` | OTP verification failed. Please try again | Wrong one-time code |
| `008001B092` | No payment methods are available for this region | Nothing under your agreements matches the resolved country |
| `008001B099` | The transaction currency is not supported | Currency is not enabled for the resolved method |
| `008001B101` | Invalid postcode | Postcode failed the payment method's format check |
| `008001B116` | Receipt link expired | The receipt link is past its window; see the receipt docs |
| `008001B124` | Today's verification attempts have reached the limit | Daily verification cap hit; the customer must retry the next day |
Codes in the `008001B03x`–`008001B09x` range are almost all issuer or wallet
decisions relayed verbatim. They are shown to the customer as-is; you cannot
convert them into a success by retrying with the same instrument.
## Some payments ask the customer for an email or an email code. Why?
Certain payment methods require a verified email address before they will
accept the payment, so the checkout collects it and, where required, verifies
it with a one-time code sent to that address. It is a requirement of the
routed payment method, not something Waffo adds per merchant.
What to expect:
* If you sent a usable `userInfo.userEmail`, it can prefill the field. For some
methods the address is fixed for that payment and the customer cannot replace
it — in that case sending a different address on the same step is rejected.
* The one-time code is short-lived. A successful verification is remembered
for a limited window on that order, so the customer does not have to repeat
it while completing the payment.
* Email addresses are capped at 50 characters on this step.
* Once the customer has chosen a payment method in a checkout session, that
choice is fixed for the session.
***
Seeing a validation message you cannot reproduce?
[Contact support](mailto:support@waffo.com) with the order ID, the exact code
shown, and whether the customer was on mobile or desktop.
# Checkout Language
Source: https://waffo.com/docs/en/support/cashier-language-faq
Which field sets the language of the Waffo checkout, the exact BCP 47 format and validation errors, the full priority order the checkout uses to pick a display language, and the complete list of languages the checkout renders.
This page covers only the **language** of the Waffo-hosted checkout: the field
you pass, what values it accepts, and how the page decides which language to
render when you pass nothing.
Which payment methods appear and in what order is a different decision — see
[Checkout Payment Method Display Rules](/docs/en/support/cashier-display-rules-faq).
Country switching, redirect URLs and `orderAction.actionType` are covered in
[Checkout Redirects & Action Types](/docs/en/support/cashier-redirect-and-action-type-faq).
## Which field controls the language the checkout is displayed in?
`paymentInfo.cashierLanguage`, passed when you create the order.
| | |
| - | - |
| Field | `paymentInfo.cashierLanguage` |
| Required | No |
| Max length | 24 characters |
| Format | IETF BCP 47 language tag |
| Validated | At order creation — an invalid value fails the create call |
| Scope | That one order. It is not an account-level setting, so you pass it per order. |
There is no Merchant Portal setting that pins the checkout language. If you
want a fixed language, pass `cashierLanguage` on every order.
## What format does `cashierLanguage` have to be in?
`language[-script][-region]`, and nothing else:
| Subtag | Standard | Rule |
| - | - | - |
| language | ISO 639-1, two letters | Required. Must be a language the checkout supports — the set is closed. |
| script | ISO 15924, four letters | Optional. If present, must be `Hant` or `Hans` — the set is closed. |
| region | ISO 3166-1 alpha-2, two letters | Optional. Must be a valid country/region code. Any valid code is accepted, whether or not the checkout has copy specific to it. |
Examples that pass:
| Value | Meaning |
| - | - |
| `en` | English |
| `en-HK` | English, Hong Kong |
| `zh-Hant-HK` | Traditional Chinese, Hong Kong |
| `zh-Hans-HK` | Simplified Chinese, Hong Kong |
| `ja-JP` | Japanese, Japan |
Values that are rejected:
* Underscore separators — `en_US` is not a BCP 47 tag.
* Variant subtags — `de-DE-1996`.
* Extension subtags — `en-US-x-foo`.
* Anything longer than 24 characters.
Casing is normalised for you, so `en-us` is accepted and stored as `en-US`.
## What error do I get if the value is wrong?
Two distinct outcomes, and they mean different things:
| Situation | Error | Message |
| - | - | - |
| The tag is structurally invalid, carries a variant/extension subtag, or the region is not a real ISO 3166-1 alpha-2 code | `A0047` | `The cashier language format is invalid, it must follow IETF BCP 47: a two-letter ISO 639-1 language, an optional four-letter ISO 15924 script, and an optional two-letter ISO 3166-1 alpha-2 region` |
| The tag is well formed, but the language or the script is outside the supported set | `A0026` | `The cashier language specified by merchant currently is not supported` |
Both are returned as HTTP 400 at order creation, so a bad value never reaches
the checkout page. If you get `A0047`, fix the shape of the string. If you get
`A0026`, the shape is fine but the language itself is not one Waffo renders —
check the list below.
## Does the same field work for subscriptions?
Yes. `paymentInfo.cashierLanguage` exists on subscription creation with the
same name, the same 24-character limit, the same BCP 47 rules and the same two
errors. It is validated in the same place in the subscription create flow.
## Does `cashierLanguage` affect anything besides the checkout page?
For subscriptions, yes: **end-user subscription notifications reuse the same
decision**. When Waffo sends a subscription notification to your customer, the
language is `cashierLanguage` if you supplied one, and otherwise is inferred
from the customer's payment currency.
Note the difference from the checkout page: a notification is not a browser
session, so there is no `Accept-Language` header and no country tab to read.
The fallback for notifications is **currency-based**; the fallback for the
checkout page is **country-based** (see the next question).
## How does the checkout decide the display language? Is there a priority order?
Yes — five layers, evaluated top to bottom. The first layer that produces a
value wins.
| Layer | Source | Notes |
| - | - | - |
| 0 | **Frozen display language** | A snapshot of the language the customer was actually looking at when they submitted on the payment-method selection page. Present only after that submission. |
| 1 | **`paymentInfo.cashierLanguage`** | Your value, used as-is (already validated and normalised at order creation). |
| 2 | **Browser `Accept-Language`** | The highest-priority entry of the header only. See below for exactly what is read. |
| 3 | **Country inference** | Before submission, the customer's *real* location. After submission, the checkout's display country. See below. |
| 4 | **`en`** | Final fallback whenever layer 3 yields nothing usable. |
Two consequences worth designing around:
* **Layers 0, 1 and 2 lock the language.** Once one of them hits, the language
no longer follows the country: the customer can switch the country tab and
the copy stays put.
* **Only layer 3 follows the country tab.** So a customer with no
`cashierLanguage` and no usable `Accept-Language` will see the copy change
as they switch countries — and a customer with either of those will not.
Language resolution is display-only. If it fails for any reason, the payment
itself is unaffected.
## Which country does layer 3 actually use?
It depends on where the order is in its lifecycle, and the distinction matters
because the "display country" and the "customer's country" are not the same
thing.
| Order state | Country used for layer 3 |
| - | - |
| Still on the payment-method selection page | The customer's **real** location: the country tab they switched to, if they switched to a real country; otherwise the country inferred from their IP. |
| After submission | The checkout's **display country** (`countryCode`). |
On the selection page the display country can be back-filled to some country
from the order's candidate list when the customer's own country is not among
them — that back-filled value does not describe where the customer is, so the
language decision deliberately does not use it.
Selecting the **Global** tab is not a location, so it is not treated as one: it
falls through to the IP-inferred country.
## What is the frozen display language, and why does layer 0 exist?
When the customer submits their choice on the payment-method selection page,
the checkout stores the language they were looking at onto the order. From then
on that value wins over everything else.
It exists to stop the language from drifting mid-payment. A globally available
payment method moves the order's display country to **Global**, which is a
sentinel rather than a real country. Without the snapshot, an order that was
being displayed in a local language would flip to the English fallback the
moment the customer picked a global method — the customer would watch the page
change language between choosing and paying. The snapshot pins whatever they
already saw.
You cannot set or read this value; it is written by the checkout at submission
time. Its practical effect on your integration is only this: **after the
customer has submitted on the selection page, nothing changes the language any
more.**
## What exactly does the checkout read from `Accept-Language`?
Layer 2 is narrower than most people expect:
* Only the **highest-weighted** entry of the header is considered. If that one
does not map to a supported language, the checkout does **not** try the next
one — it drops to layer 3.
* Only the **primary language subtag** is used. `zh-Hant-TW` in the header
collapses to `zh`; the script and region in the header are discarded.
* The accepted primary subtags are `en`, `es`, `id`, `ja`, `ko`, `ms`, `pl`,
`pt`, `th`, `tr`, `vi`, `zh`. Anything else — `fr`, `ru`, and so on — yields
nothing and falls through to layer 3.
* A malformed header (some older in-app browsers emit repeated quality values)
degrades to reading the first language tag in the raw string, with the same
mapping.
Because layer 2 resolves at the primary-subtag level, a `zh` from the browser
renders **Traditional** Chinese — see the script fallback below.
## Which languages does the checkout support?
These are the values `cashierLanguage` accepts as complete tags:
| Tag | Language |
| - | - |
| `en` | English |
| `pt-BR` | Portuguese (Brazil) |
| `es-MX` | Spanish (Mexico) |
| `es-PE` | Spanish (Peru) |
| `es-CO` | Spanish (Colombia) |
| `es-CL` | Spanish (Chile) |
| `id-ID` | Indonesian |
| `vi-VN` | Vietnamese |
| `ru-RU` | Russian |
| `th-TH` | Thai |
| `ja-JP` | Japanese |
| `ko-KR` | Korean |
| `ms-MY` | Malay |
| `pl-PL` | Polish |
| `tr-TR` | Turkish |
| `en-KE` | English (Kenya) |
| `en-NG` | English (Nigeria) |
| `en-HK` | English (Hong Kong) |
| `zh-Hant-TW` | Traditional Chinese (Taiwan) |
| `zh-Hant-HK` | Traditional Chinese (Hong Kong) |
| `zh-Hans-HK` | Simplified Chinese (Hong Kong) |
| `zh-Hans` | Simplified Chinese |
You are not restricted to exactly these strings. Validation is per subtag, so
any combination of a supported language, an optional supported script, and a
valid ISO region is accepted — `en-SG`, for example. What the *region* changes
is covered next.
## I passed `en-HK` and the page looks identical to `en`. Is the region subtag ignored?
For the page copy, effectively yes. Checkout copy is maintained in **13
language buckets**, and a tag is matched to a bucket by language plus script
only — the region subtag never selects a different set of copy:
| Bucket | Tags that land in it |
| - | - |
| `en` | `en`, `en-HK`, `en-KE`, `en-NG`, any `en-*` |
| `pt` | `pt-BR`, any `pt-*` |
| `es` | `es-MX`, `es-PE`, `es-CO`, `es-CL`, any `es-*` |
| `zh-Hant` | `zh-Hant-TW`, `zh-Hant-HK`, and bare `zh` |
| `zh-Hans` | `zh-Hans`, `zh-Hans-HK` |
| `vi` | `vi-VN` |
| `th` | `th-TH` |
| `id` | `id-ID` |
| `ja` | `ja-JP` |
| `ko` | `ko-KR` |
| `ms` | `ms-MY` |
| `pl` | `pl-PL` |
| `tr` | `tr-TR` |
So `es-PE` and `es-CL` render the same Spanish copy, and `en-KE` and `en-NG`
render the same English copy. The region subtag is still carried on the order
and is still used for locale-aware formatting, but it does not fork the wording.
Two specific rules that surprise people:
* **Bare `zh` renders Traditional Chinese.** With no script subtag there is
nothing to distinguish the two, so the checkout resolves `zh` to `zh-Hant`.
If you want Simplified, pass a `Hans` script explicitly — `zh-Hans` or
`zh-Hans-HK`.
* **A language with no bucket renders English.** `ru-RU` passes validation,
but Russian is not one of the 13 copy buckets, so the page renders in
English.
## I passed nothing at all. What language does the customer see?
Layers 2 to 4 decide, in that order:
1. If the customer's browser sends a header whose top entry is one of the 12
accepted primary subtags, that language is used, and it will not change if
they switch country tabs.
2. Otherwise the language is inferred from the country — their real location
before submission, the display country after — and **does** change as they
switch tabs.
3. If neither yields anything, the page is in English.
If a deterministic language matters to you — a localized brand experience, a
regulated market, a screenshot-based support flow — pass `cashierLanguage`
rather than relying on this chain.
## Which languages can the customer pick when downloading a receipt?
The same 13 buckets, offered by their own names: English, Português, Español,
繁體中文, 简体中文, Tiếng Việt, ไทย, Bahasa Indonesia, 日本語, 한국어, Bahasa
Melayu, Polski, Türkçe.
The receipt language is the customer's own choice at download time. It is not
driven by `cashierLanguage`, and picking one does not change the language of
the checkout page.
## Can I let the customer choose the checkout language themselves?
There is no language selector on the checkout page. The language is whatever
the five-layer chain resolves to.
What the customer *can* change is the country, on the payment-method selection
page, and that shifts the language only when the chain reached layer 3 — that
is, only when you did not pass `cashierLanguage` and their browser header did
not match. See
[Checkout Redirects & Action Types](/docs/en/support/cashier-redirect-and-action-type-faq)
for when the country selector is shown at all.
# Checkout Page Lifecycle & Receipt
Source: https://waffo.com/docs/en/support/cashier-order-lifecycle-faq
How the Waffo checkout page behaves across the order lifecycle: reopening links after payment, processing states, result-page redirects, merchant display name, logos, cross-currency amounts and returning-customer prefill.
This page covers how the Waffo-hosted checkout page behaves before, during
and after payment. For payment-method display order and grouping, see the
cashier customization documentation.
## What happens if a customer reopens a checkout link after the order has ended?
For an extended period after the order reaches a final state — a
platform-configured number of days, counted from when the order completed — the
link still resolves to the real result:
* A successful order shows the **success page** — it will not be misreported
as failed just because the order validity period has passed.
* A failed order shows the **failure page**, with the relevant error code
(for example, order expired).
After that period, the link can no longer be queried and the dedicated
expired-link page is shown instead. Orders that are still processing remain
queryable for as long as they are in progress.
## The checkout page is stuck on "processing". Is the payment lost?
Not necessarily. When the payment flow hits an error that cannot be resolved
immediately, the checkout page stays on a processing view and displays the
reason. Behind the scenes, Waffo keeps retrying the order automatically.
As soon as a retry produces a result, the page advances on its own to the
success or failure page — what the customer sees is driven entirely by the
backend order state, so there is no need for the customer to resubmit.
Rely on your payment notification or order inquiry for the final state.
## After payment, does the customer get redirected back to my site?
That depends on what you passed at order creation:
| Order creation | Result-page behavior |
| - | - |
| Redirect URL provided | After the customer confirms on the result page, they are redirected to your URL. |
| No redirect URL | The result page shows a **Close** button; clicking it closes the checkout page. |
## Which merchant name is shown on the checkout page and receipt?
The checkout page, payment instruction page and receipt only ever show the
**Cashier Display Name**, an optional field you configure in the Merchant
Portal. Your KYB-registered legal name (Merchant Name) is **never**
displayed to customers.
If you have not configured a Cashier Display Name, the merchant-name field in
the page header is simply hidden — the registered company name is not used as
a fallback there.
One exception worth knowing: on pages that carry a Merchant-of-Record
disclosure footnote, the footnote falls back to the registered legal name when
no Cashier Display Name is set. If you do not want the registered name to
appear anywhere, configure a Cashier Display Name.
## Can I show different logos for different product lines under one MID?
Yes. You can upload multiple logos in the Merchant Portal (at MID or SubMID
level); each logo gets a unique logo ID. When creating an order, pass the
logo ID to control which logo that checkout session displays.
Fallback rules:
* If the parameter is omitted, or the ID is invalid or does not exist, the
**default logo** is shown.
* The first uploaded logo is automatically the default; you can switch the
default to any other logo in the Portal.
* With no logo uploaded at all, no merchant logo is displayed.
## How are amounts displayed for a cross-currency order?
When the order currency differs from the customer's payment currency, the
checkout page shows the customer payment amount prominently together with the
conversion breakdown — original order amount, exchange rate and any tax.
For card payments the breakdown appears once the exchange rate can be
determined: if several payment currencies are possible, it is shown after the
customer enters enough card details to resolve the rate.
When the order currency and the payment currency are the same, there is no
conversion, so no breakdown is shown.
Advanced layout options (such as which currency is the primary display and
independent font sizes) are enabled per merchant — contact your Waffo
representative if you need them.
## Do returning customers have to fill in their details every time?
No, two mechanisms reduce repeated typing:
* **Saved information** — the first time a customer fills in the form, a
"save your information" option is checked by default. If they keep it,
their details are brought out automatically on their next purchase.
* **Merchant-passed prefill** — fields you already supplied at order
creation that are marked user-editable are prefilled into the checkout
form, where the customer can review and change them before paying.
## Which page states does the checkout have? I need something to test against.
The page is driven entirely by the order state, so during integration testing
you are really watching the order move. These are the states a customer can be
on:
| Page state | What the customer sees | What it means |
| - | - | - |
| Payment-method selection | The list of payment methods, grouped and ranked | The order has not resolved to a method yet |
| Payment form | Card fields, or the fields a method requires | A method is chosen and needs input |
| Redirecting | A short interstitial with a **Pay** and a re-check action | The customer has to go out to the payment method |
| Silent form post | The processing view | Waffo is posting to the payment method in a hidden frame; there is nothing for the customer to do |
| Instruction | A QR code, a payment code, or bank details plus instructions | The customer pays elsewhere and Waffo waits |
| Processing | A spinner with the reason, where there is one | Waiting on the payment method, or retrying |
| Success | The result page, or an immediate redirect to your URL | The payment succeeded |
| Failure | The failure page with the error code | The payment failed and is not retryable |
| Expired link | "This link has expired" | The link no longer resolves to a live checkout |
Two of these commonly surprise people during testing. The **silent form post**
looks identical to processing and is supposed to — the frame is deliberately
hidden. And **instruction** and **processing** are pending states in which the
page polls the order and advances by itself, so a page that appears to sit still
is usually working correctly.
Subscription orders can additionally start on a mode-selection step before any
of the above.
## After a failure, can the customer pick a different payment method on the same checkout?
Yes, for failures that leave the order retryable. The page returns to the
**payment-method selection** state and shows the reason for the failure as an
error message, so the customer can choose another method and pay on the same
order and the same link — no new order, no new link.
For a failure that is final — a hard decline, an expired order, a link that no
longer resolves — the page goes to the failure page instead, and the customer
needs a new order from you.
You cannot tell these apart from the order creation response, because whether
the failure is retryable is a property of the failure. Your integration should
not assume the first failure notification is the end of the order; see
[Payment results](/docs/en/support/payment-results-faq).
## Can I skip the Waffo result page entirely and send the customer straight back to my site?
Yes, but not through an order parameter. When the bypass is enabled for your
account, a checkout that reaches success or failure redirects immediately to
your `successRedirectUrl` or `failedRedirectUrl` without rendering the Waffo
result page at all — the customer never sees an intermediate confirm step.
Two conditions both have to hold, so plan for the fallback:
* The bypass has to be enabled for your account. There is no
`cashierAppearance` key and no create-order field for it — ask Waffo support
to enable it.
* The corresponding redirect URL has to be present on the order. Without it the
Waffo result page is shown, bypass or not.
The warning that applies to every redirect applies here too, and more sharply
because there is no page in between: arriving on your URL is not proof of
payment. Read the state from your notification or from
`POST /api/v1/order/inquiry` before you fulfil.
## Is an expired link the same error as a link that has been altered?
No, they are different, and the difference is visible to the customer:
| What happened | What the customer sees |
| - | - |
| The link resolves, but the checkout behind it is no longer live — already paid, or the session has passed | The dedicated **"This link has expired"** screen, which says the payment has already been processed or the session expired |
| The link cannot be read at all — a truncated URL, a mangled or edited identifier, a URL copied out of another order | A generic **"Something went wrong"** screen |
Two things follow from this. If a customer reports the generic screen, suspect
the link's integrity first — most often the URL was rewritten, truncated by a
messaging client, or passed through something that dropped part of it; this is
the same failure mode as rewriting URLs between your App and a WebView. And if
you are seeing the expired screen on a link you believe is fresh, the order
behind it has reached a final state — query the order rather than re-issuing the
link.
## Is 3-D Secure done on the checkout, or is it another redirect?
Both parts happen, and only one of them is visible:
* The **device-data step** runs invisibly while the customer is still on the
checkout, before the payment is submitted. Customers do not see it and there
is nothing for you to integrate.
* The **challenge**, where it is required, is presented by the card issuer.
That is an additional step out to the issuer's authentication page, after
which the customer comes back to the checkout and the flow continues.
Whether a challenge happens at all is the issuer's decision on that
transaction, so you cannot promise a customer a single-screen card payment. If
you build your own card page with the Waffo front-end SDK you have some control
over how the challenge is presented — see
[Google Pay and SDK 3DS flow control](/docs/en/developer-docs/integration/elements/frontend-integration#3ds-flow-control).
For what happens when a challenge times out, see
[Payment timeouts and finality](/docs/en/support/payment-timeout-and-finality-faq#3ds-authentication-timed-out-can-i-retry-on-the-same-order).
# Checkout Redirects & Action Types
Source: https://waffo.com/docs/en/support/cashier-redirect-and-action-type-faq
How orderAction.actionType is decided and why webUrl and deeplinkUrl are mutually exclusive, how actionType relates to userInfo.userTerminal, when customers can switch country on the checkout, what a subscription checkout can and cannot narrow by request parameter, and which currency wins when a payment method is configured in several.
This page answers the integration questions that come up once an order exists:
which URL your app should open, whether you can steer the checkout by request
parameter, and why the currency the customer is charged in is sometimes not the
one you sent.
For ranking, grouping, folding and unavailable methods, see
[Checkout Payment Method Display Rules](/docs/en/support/cashier-display-rules-faq).
For the display language, see
[Checkout Language](/docs/en/support/cashier-language-faq).
## `webUrl` and `deeplinkUrl` are mutually exclusive — what rule decides which one I get?
Two conditions have to hold, in this order.
**First, the payment step has to be a redirect at all.** Redirect fields are
produced only when the payment method the order routed to returns one of these
action forms:
| Action form | Produces redirect fields? |
| - | - |
| Web/URL redirect | Yes |
| 3-D Secure | Yes |
| Iframe/form post | Yes |
| App push (authorization pushed to an app the customer already has) | **No** |
An app-push flow produces **no** URL at all — neither `webUrl` nor
`deeplinkUrl`. Nothing has gone wrong; there is simply nothing for you to open.
**Second, if redirect fields are produced, the URL's own type decides the pair:**
| URL type returned by the payment method | Field populated | `orderAction.actionType` |
| - | - | - |
| Web | `orderAction.webUrl` | `WEB` |
| Deeplink | `orderAction.deeplinkUrl` | `DEEPLINK` |
That is why exactly one of the two is ever present: `actionType` and the URL
field are set together, from the same branch.
## So can I ask for a deeplink instead of a web URL?
No. `actionType` is **not a parameter and not a merchant setting**. It is
determined by the shape of the redirect artifact that the payment method this
specific transaction actually routed to hands back.
Practically, that means two orders of yours can differ in `actionType` for
reasons that are invisible to you at creation time — a different payment method
chosen by the customer on the checkout, or the same method behaving differently
for a different customer device. Your integration has to tolerate that.
## What is the correct way to read the redirect, then?
**Branch on `orderAction.actionType`. Never predict it.**
```
if (orderAction.actionType == "WEB") {
open(orderAction.webUrl);
} else if (orderAction.actionType == "DEEPLINK") {
open(orderAction.deeplinkUrl);
} else {
// no redirect artifact: an app-push style flow, or nothing to open.
// Wait for the payment notification / poll order inquiry.
}
```
Three rules that keep this robust:
* Treat "no `actionType` and no URL" as a valid outcome, not an error.
* Do not infer the URL field from the payment method you *think* the order
used, from the customer's device, or from what the same payment method
returned last week.
* A redirect landing on your page is **not** proof of a successful payment. The
authoritative result is the payment notification (webhook) or an order
inquiry — always.
## Is `actionType` related to `userInfo.userTerminal`?
**They are two independent dimensions.** `actionType` describes the shape of the
redirect artifact; `userTerminal` describes the customer's device type. Neither
determines the other.
There is, however, a real **indirect** link, and it is worth understanding
because it is the source of most of the confusion:
* Some payment methods use `userTerminal` to decide *which flow to open at all*.
A method may show a QR code when the customer is on a PC and launch a wallet
app when the customer is on a phone.
* Those two flows produce different artifacts. An app launch yields a scheme
URL, so you get `actionType = DEEPLINK`. A QR-code display is not a redirect
at all, so it yields **no** `actionType` and no URL — the code is rendered on
the Waffo checkout page instead.
So `userTerminal` can influence what you end up receiving, via the payment
method's own behaviour. What it does **not** do is let you predict it:
* Under the same `userTerminal`, different payment methods return different
`actionType` values.
* Not every payment method looks at `userTerminal` at all.
Conclusion: `userTerminal` is an input that describes your customer honestly.
`actionType` is an output you read at runtime. Do not use the first to guess
the second.
## What does `userTerminal` actually do, then?
| | |
| - | - |
| Field | `userInfo.userTerminal` |
| Required | **Yes** |
| Max length | 32 |
| Values | `WEB` (customer on a PC), `APP` (customer on a mobile phone), `IN_WALLET_APP` (customer inside a payment method's wallet app), `IN_MINI_PROGRAM` (customer inside a payment method's mini program) |
Its primary, documented job is **eligibility**: not every payment method
supports every terminal type, so the value is used to check whether a given
method can be offered to this customer at all. A method that does not support
the terminal you sent will not appear on the checkout.
Send the value that is true for the session. Sending `WEB` for a customer who
is actually inside a wallet app does not unlock anything; it just gives the
platform wrong information to filter with.
## Can customers switch country on the checkout themselves?
Yes, with two conditions.
| Condition | Behaviour |
| - | - |
| The order resolves to **two or more** country tabs | A country selector is shown, and the customer can freely switch between the resolved tabs. |
| The order resolves to **one** country tab | No selector is shown at all. |
The selector only exists on the payment-method selection step. Once the
customer has chosen a method and moved on, there is no country switch.
Switching country reloads the payment-method list for that country. Anything
tied to the previous selection — such as an entered promotion code — is
cleared by the switch, so the customer re-enters it if they still want it.
## Which country is selected by default?
Resolved in this order:
1. If the customer already switched tabs in this session, that tab.
2. Otherwise, the country inferred from the customer's IP — **if** that country
is one of the order's resolved tabs.
3. Otherwise, the **Global** tab, if the order has one.
4. Otherwise, the first tab in the list. Tabs are ordered alphabetically, with
**Global** placed first when present, so this is the alphabetically first
country.
Note that step 3 and step 4 pick a tab, not a location. The tab that gets
selected does not tell you where the customer is — which is why the language
decision deliberately uses a different notion of "the customer's country". See
[Checkout Language](/docs/en/support/cashier-language-faq).
## For a one-time payment, can I pin the country and payment methods by parameter?
Partly. On order creation:
| Field | Effect |
| - | - |
| `paymentInfo.payMethodCountry` (max 3) | Hides **local** payment methods belonging to other countries. Globally available methods are always kept. |
| `paymentInfo.payMethodType` | Restricts to the listed method types. |
| `paymentInfo.payMethodName` | Restricts to the listed specific methods. |
`payMethodCountry` is a filter, not a country lock — passing one country does
not reduce the page to that country's methods only, because global methods
survive the filter by design. The full behaviour, including what happens to the
country tabs and what happens when nothing survives the filter, is documented
in
[Checkout Payment Method Display Rules](/docs/en/support/cashier-display-rules-faq).
## For a subscription, can I pin the country and payment methods by parameter?
Yes — the same three filters exist on `/api/v1/subscription/create`, under
`paymentInfo`, and they behave the same way as on a one-time order:
| Field | Available on subscription create? |
| - | - |
| `payMethodCountry` (max 3) | Yes — hides **local** payment methods belonging to other countries; globally available methods are kept |
| `payMethodType` | Yes — narrow by method type |
| `payMethodName` | Yes — narrow to specific methods |
| `payMethodProperties` | Yes |
| `payMethodUserAccountType` / `payMethodUserAccountNo` | Yes |
| `payMethodUserAccessToken` | Yes |
| `cashierLanguage` | Yes |
| `receiptTemplateId` | Yes |
| `cashierAppearance` | Yes |
Two things specific to subscriptions:
* The country you pass is **stored on the subscription** and re-applied every
time Waffo re-resolves the eligible payment methods for it — for example when
the customer adds or changes a payment method later. A subscription created
for one country therefore keeps offering that country's local methods (plus
global ones) for its whole life.
* `payMethodCountry` is a filter, not a lock, exactly as for one-time orders:
passing one country does not reduce the page to that country's methods only,
because global methods survive the filter by design.
So the honest answer to "can I specify the country and its payment methods for
a subscription checkout" is: yes for both — narrow the country with
`payMethodCountry`, narrow the methods with `payMethodType` / `payMethodName`,
and set the language with `cashierLanguage`. If you want a strictly
single-country experience with no global methods, express it as a method
restriction — name the methods you want offered — because a country filter
alone will not remove global methods.
## Do my Cashier Ranking settings apply to my subscription checkout too?
No. Ranking and display configuration are stored against **two** dimensions:
**country** and **product type**. `ONE_TIME_PAYMENT` and `SUBSCRIPTION` are
separate product types, so each country has one configuration for one-time
payments and a separate one for subscriptions. They do not inherit from each
other in either direction.
At checkout time, the configuration looked up is the one matching the order's
country *and* the order's product type. If no configuration exists for that
pair, the platform default ranking applies for that pair — a customized
one-time ranking does not leak into the subscription checkout, and vice versa.
One consequence to plan for: **configuring your one-time checkout is not
configuring your subscription checkout.** If you offer both, set the ranking
twice, once per product type, in the Merchant Portal under
**Cashier Customization → Cashier Ranking**.
When product type is not specified at all in a configuration lookup, it
defaults to `ONE_TIME_PAYMENT` — which is another reason a subscription
checkout will look untouched until you configure it explicitly.
## I created the order in one currency and the customer was charged in another. Why?
Because a single payment method can be configured for **several payment
currencies** under your agreements, and the checkout shows **one entry per
payment method**, not one entry per currency. Something has to choose which of
the configured currencies that entry uses — and the currency you passed as the
order currency is not automatically the winner.
The order currency you passed is still the currency of your order and of your
settlement view; what changes is the currency the customer actually pays in,
with the conversion shown to them on the checkout page.
## Which currency wins when a payment method is configured in several?
The candidates for that one method are narrowed level by level. At each level:
exactly one match ends the process; several matches narrow the pool and
continue; no match leaves the pool untouched and continues.
| Level | Rule |
| - | - |
| 1 | The payment currency equals the **local currency of the country the payment method itself belongs to**. The Global grouping is excluded here — it is a sentinel, not a country, so it has no local currency. |
| 2 | The payment currency equals the **currency of the country inferred from the customer's IP**. |
| 3 | The payment currency equals the **order currency** you passed. |
| 4 | The payment currency is **USD**. |
| 5 | If more than one candidate still survives, one is chosen by a **reproducible tie-break seeded on the order** — the same order always resolves to the same currency. |
Worked example, and the one that generates most of these tickets:
> You create an order with `currency = HKD`. Your customer's IP resolves to
> China. The eWallet-type method they want belongs to China and is configured
> under your agreements in both USD and CNY.
>
> * **Level 1** — the method's home country is China, whose local currency is
> CNY. Of the two candidates, exactly one matches. Resolved.
> * The customer pays in **CNY**, even though you sent HKD.
The order currency only decides the outcome at level 3 — that is, only when no
candidate matched the method's own local currency and none matched the
customer's IP currency. This is deliberate: for a local payment method, the
currency the customer's own account is denominated in is a better outcome than
the currency you happened to invoice in.
This convergence is platform behaviour and is not merchant-configurable.
## How do I avoid the surprise entirely?
Two things make it predictable rather than mysterious:
* **Read the payment currency from the order, do not assume it.** The checkout
displays the customer payment amount and the conversion breakdown, and the
same values are on the order — see
[Checkout Page Lifecycle & Receipt](/docs/en/support/cashier-order-lifecycle-faq).
* **Narrow the methods you offer** with `payMethodType` / `payMethodName` if a
specific payment currency is a business requirement. Restricting the method
restricts the candidate set the convergence runs over.
## Why does the same payment method never appear twice on the checkout?
Because the convergence above runs per method name: multiple currency
configurations of one method collapse to a single entry before the page is
built. The customer sees one tile per method and cannot pick "the USD version"
of it.
Card brands are not affected by this collapse — the convergence only chooses
among configurations that share a name, so different card brands each keep
their own entry, and the card group looks the same on a country tab as on the
Global tab.
# Chargeback Evidence Guide
Source: https://waffo.com/docs/en/support/chargeback-evidence-guide
How to choose representment evidence: the three questions every defence has to answer, which question each dispute reason turns on, how the material differs for physical goods, digital goods and services, what to send when the reason is unknown, why a second cycle can appear without any action from you, and why a customer dispute can close on its own.
> [!NOTE]
>
> Everything on this page is **guidance for improving your chances**, not a promise about
> outcomes. Chargeback decisions are made by the issuing side under card scheme and payment
> method rules; assembling every recommended item does not guarantee a case is won, and Waffo
> does not warrant a result. Use the **Evidence Requirements** list on the individual case as
> the authoritative statement of what that case needs.
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq) covers the process — how cases arrive,
how to submit, deadlines and statuses. This page is about *what to put in the submission*.
Field-level meanings are in
[Chargeback field reference](/docs/en/support/chargeback-field-reference).
## How does Waffo decide what evidence to recommend for my case?
Every case carries an **Evidence Requirements** list, generated from the case's dispute
reason and marking each item as mandatory or recommended. A submission missing a mandatory
item is blocked before it is sent, so read the list before you start collecting.
Underneath that list is a single idea worth understanding, because it lets you assemble a
strong submission even when the list is thin: whatever the reason code, a defence is only
ever answering three questions.
## What are the three questions a defence has to answer?
| Dimension | The question | What answers it |
| - | - | - |
| **A — Transaction authenticity** | Did this transaction genuinely happen, initiated by this person? | The authorisation result, device and IP information, account and login activity, prior transaction history with the same account |
| **B — Fulfillment** | Was the product or service actually delivered? | Delivery or access confirmation, usage records, invoices and receipts |
| **C — Policy compliance** | Were cancellation and refund handled according to your published policy? | The policy exactly as it was shown at checkout, the buyer's acknowledgement of it, your communication with the buyer |
The dispute reason decides which of the three carries the weight; the **product type**
decides what form the evidence takes. The industry you are in changes neither — it only
changes the vocabulary of the same documents.
## Which question does each dispute reason turn on?
Ten reasons cover essentially all cases. Use this to know where to spend your effort;
use the case's own Evidence Requirements to know exactly what to attach.
| The buyer is claiming | Primary dimension | Also worth including |
| - | - | - |
| Fraud / the transaction was not authorised | **A** — authenticity | B, to show the buyer received and used what they paid for |
| The goods or service were never received | **B** — fulfillment | A, to tie the delivery to the same account |
| The goods or service did not match the description | **B** — fulfillment | C, for your returns policy and any prior resolution offered |
| The goods or service were cancelled | **C** — policy compliance | B, to show what had already been delivered at the point of cancellation |
| A subscription was cancelled but was still charged | **C** — policy compliance | A, for the record of the authorisation given for recurring charges |
| A refund was promised but never arrived | **B** — the refund itself is the fact in dispute | C, for the communication trail |
| The charge was duplicated, or already paid another way | **A** — authenticity of *each* charge | B, to show two separate fulfilments where there were two |
| The transaction was processed incorrectly (amount, currency, date) | **A** — authenticity | — |
| Something authorisation-related | **A** — authenticity | — |
| Reason unknown | **B** first, then C — see below | A where you have it |
The two patterns to notice: a **fraud or authorisation** claim is answered with
authenticity, and a **cancellation or subscription** claim is answered with your policy.
Sending delivery proof against a "subscription cancelled but still charged" claim answers a
question nobody asked.
## What changes when I sell digital goods or services rather than physical goods?
The dimension stays the same; the document changes. This is where most weak submissions come
from — a digital business sending nothing under dimension B because it has no shipping
paperwork.
| Dimension | Physical goods | Digital goods | Services |
| - | - | - | - |
| **A** authenticity | Authorisation result, device / IP, account history | Same, plus the account's login and session record | Same, plus the booking or account record |
| **B** fulfillment | Delivery confirmation and tracking, and where possible a signature or handover record | Download, activation, entitlement or access logs showing the buyer's account actually used what was bought | Records that the service was rendered — appointments kept, sessions attended, work delivered |
| **C** policy compliance | Returns and refund policy as shown at checkout, plus the buyer's acknowledgement | Same, plus the cancellation path actually available in the product | Same, plus the terms of the engagement |
"Delivered" for a digital or service purchase means **the buyer's own account consumed it**.
A record showing that the entitlement was granted is weaker than one showing it was used.
Where you can show usage, lead with usage.
## The reason code is unknown or unhelpful. What do I send?
Send the fallback combination:
1. **Proof of delivery or access** (dimension B)
2. **The invoice or receipt** for the transaction
3. **Your communication record** with the buyer (dimension C)
Together these answer "it happened, it was delivered, and here is how we handled it" without
committing to a defence of a specific claim.
Before submitting, try to find out what the actual claim is — the case file on the issuing
side, or a direct conversation with the buyer, usually reveals it — and then add the
material for that claim. A targeted submission beats a broad one.
## Are there file format or size limits I should know about?
The upload API itself accepts JPG, JPEG, PNG and PDF up to 10 MB per file — see
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq).
Beyond that, some payment methods enforce stricter file type or file size rules than the API
does, so a file Waffo accepts can still be refused further along. Where a case has such a
restriction, it is stated in that case's Evidence Requirements. If you are producing evidence
programmatically, generating PDFs rather than large images is the safer default.
## The case came back as `MORE_EVIDENCE_REQUIRED`. Do I re-upload everything?
No — and re-uploading everything can lose you material. When more evidence is asked for, the
case returns to an evidence-required state and you submit again, but the submission is a
**field-level replace**: a field you send replaces whatever was in it before, and a field you
omit is left untouched.
So the correct move is to send only the field carrying the new material. If you resend a
field with a shorter list of file references than before, the earlier references in that
field are gone. Keep your own record of what you attached in each field on each round; the
case shows you the current state, not the history of what each field held.
`MORE_EVIDENCE_REQUIRED` is also a signal about content, not just quantity. It usually means
one of the three questions was left unanswered rather than that more of the same is wanted —
re-read the case's Evidence Requirements and check which dimension is thin before adding
anything.
## What can I actually submit in a second cycle?
A defence note, and nothing else. Second-cycle (Pre-Arbitration) cases **do not accept
evidence files at all** — the submission is the decision to defend, optionally with a note.
Most card scheme rules also refuse material at this stage that was not presented in the first
cycle, so there is generally nothing new to add even where a channel would take it.
The practical consequence is that **the first cycle is your one real opportunity**. Material
you hold back, or do not have ready in time, cannot be introduced later. Assemble the full
submission the first time round rather than sending a minimum and planning to supplement it.
Mechanics for both cycles are in
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq).
## I passed 3DS and the buyer still claimed fraud. Why did a second cycle appear when I never responded?
Under some card scheme flows, a chargeback raised as fraud on a transaction that **was**
3DS-authenticated is defended on your behalf — the defence is submitted without waiting for
a response from you, because the authentication record itself is the answer to the claim.
That defence can succeed, and a successful defence is exactly what allows the issuing side
to escalate the case to a **second cycle (Pre-Arbitration)**.
The consequence catches merchants out:
* You never chose to defend, so you were not expecting a second cycle at all.
* A second cycle books **another chargeback fee** against you — see
[Chargeback field reference](/docs/en/support/chargeback-field-reference) for the settlement
lines.
* If you have set a blanket strategy of never defending, that strategy did not prevent this,
because the first-cycle defence was not yours to decline.
What to do about it: watch for the `AUTO_SUBMITS_EVIDENCE` timeline event, which is how a
submission made on your behalf shows up on the case, and treat any case whose Life Cycle is
`SECOND_CYCLE` as needing a decision even if you never touched the first cycle. If the fee
exposure from this pattern matters to your business, raise it with Waffo rather than
assuming your default strategy covers it.
## Will I be warned before an evidence deadline passes?
Do not build your process on being warned. Every case carries its own `expiryDateTime`, and
that value is authoritative. Two things you can rely on:
* Both dispute lists offer an **expiring soon** filter, returning open cases whose deadline
falls in the next **3 days**.
* A deadline event appears on the case timeline (`NOTIFY_OVERDUE`), so a case that was
flagged shows it in its own history.
Poll the lists, or track `expiryDateTime` per case from the API, and act on your own
schedule. The deadline consequence differs sharply by case type — an expired chargeback is a
loss, an expired customer dispute is still workable — which is set out in
[Chargeback field reference](/docs/en/support/chargeback-field-reference).
## My customer dispute went to `CLOSED` and I did not close it. Did I lose?
No — `CLOSED` on a customer dispute is not a verdict. It is the end of that record, and a
customer dispute never books money against you by itself. Three things move a customer
dispute to `CLOSED`:
| How it closed | What it means |
| - | - |
| You or Waffo closed it in the Portal | Recorded as closed by the merchant side |
| A close event arrived from the payment method's side | The complaint was resolved or withdrawn there |
| The **linked chargeback** reached a final status | The case was already escalated; the chargeback outcome is the real outcome |
The third is the one that surprises people: once a chargeback exists on the same payment, the
customer dispute record stops being the place the decision happens, and it is closed out when
the chargeback finishes. Look at the chargeback for the result — and remember that a `CLOSED`
customer dispute can reopen if the buyer complains again.
## How do I stop the Chargeback Alert service?
Not from the Portal — it is not a self-service switch. Alert coverage is held as part of your
merchant configuration, and it is enabled and disabled **per alert service type**, so you can
stop one and keep another.
To stop it, contact Waffo and say which service type you want deactivated. Waffo sets that
entry to inactive; it stops applying from that point. Fees already incurred on alerts already
delivered still clear — billing follows delivery, which is covered in
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq).
Worth deciding deliberately rather than reflexively: stopping alerts removes the chance to
refund before a chargeback is raised, so the cases that alerts were absorbing arrive as
chargebacks instead, with the chargeback fee and the amount attached. Compare against what
the alerts were costing you before switching them off.
# Chargeback & Dispute FAQ
Source: https://waffo.com/docs/en/support/chargeback-faq
Answers to the most common merchant questions about chargebacks: how notifications arrive, what to do when a chargeback opens, how to submit representment evidence, deadlines and status flow, and why disputed orders cannot be refunded.
This page answers the questions merchants ask most often about chargebacks and
disputes. Field-level contracts live in the
[Chargeback API Specification](/docs/api-reference/introduction).
## How am I notified when a chargeback opens?
Waffo pushes a webhook (`eventType: CHARGEBACK_NOTIFICATION`) to the
`notifyURL` you provided. The payload carries the full chargeback object —
the same structure returned by `/api/v1/chargeback/inquiry`.
* A notification is sent whenever the chargeback **status changes**; no
notification is sent for `UNDER_REVIEW`.
* Respond with `message: "success"` after processing. If Waffo receives
`failed` or `unknown`, Waffo retries the notification on a fixed back-off schedule; see [Webhook retry](/docs/en/developer-docs/webhook/retry) for the attempts and intervals.
You can also poll `/api/v1/chargeback/list` (filter by status or `lifeCycle`;
the time window filters on creation time and must be within 3 months) and
fetch full detail — including `message` and `evidence` — with
`/api/v1/chargeback/inquiry`.
## What happens to my funds when a chargeback is created?
The chargeback `amount` and the chargeback fee (`feeAmount`) are debited when
the chargeback is **created**, not when it closes. If the case later closes in
your favour (`CASE_WON`, `CANCELED`, `SETTLED`), the amount is refunded and
the fee is retained. When a case escalates to Second Cycle, a further fee is
debited as the Second Cycle case opens; and because a Second Cycle normally
follows a First Cycle you **won** (the amount was already returned to you), the
disputed amount is debited **again** for the Second Cycle. Only if the First
Cycle was not closed in your favour is the Second Cycle debit limited to the fee.
## I fully refunded the order. Why was it still charged back?
A refund does not exempt an order from the chargeback process. The issuing
bank does not see that you already refunded the user through Waffo, so the
case proceeds as a normal chargeback. If the defense ultimately fails, you
end up bearing the refunded amount, the chargeback amount and the chargeback
fee.
Such cases are usually strong candidates for representment: submit your
refund confirmation as evidence instead of accepting the case.
## What happens when a case escalates to Second Cycle (Pre-Arbitration)?
Pre-Arbitration is the issuer's follow-up after you won the First Cycle: the
issuer rejects your representment result and asks for the funds again. When
the Second Cycle case opens, a further chargeback processing fee is debited —
it follows the same contractual fee rule as the First Cycle — and the disputed
amount, which was returned to you when you won the First Cycle, is debited
again. If the issuer accepts your defense (`CASE_WON`), the disputed amount is
credited back.
You have two options:
* **Accept** — concede the case; the funds go to the cardholder. This is the
most common choice, taken to avoid arbitration costs.
* **Defend** — if the issuer still does not concede, the case escalates to
**Arbitration**, the card scheme's final and binding ruling. At that stage
you cannot submit any new material, and the losing party bears a
substantial arbitration fee. Most card schemes also do not allow evidence
in Pre-Arbitration that was not presented in the First Cycle.
## When does a chargeback debit appear in my settlement?
Chargeback settlement is always triggered **after the original payment order
has settled**, because it reuses the original order's settlement exchange
rate:
* If the original order is already settled, the chargeback is settled on the
next settlement day after the case event is confirmed.
* If the original order has not settled yet, the chargeback is settled
together with the original order on its settlement day.
In your settlement report these entries appear with transaction type
`CHARGEBACK`; the settled amount can be negative (a debit when a case opens)
or positive (a repayment when a case is won).
## How is the chargeback fee determined?
The chargeback fee comes from your merchant contract and is configured per
merchant account (MID). It is applied automatically when a case is created
and cannot be adjusted case by case; Pre-Arbitration (Second Cycle) cases
use the same fee rule as First Cycle chargebacks. The fee is a contract
term, not a per-case setting — contact your Waffo account manager to discuss
it.
## What should I do when I receive a chargeback?
1. Read `chargebackStatus`, `reasonCode`, `reason` and `expiryDateTime` from
the notification (or from `/api/v1/chargeback/inquiry`).
2. Decide: **defend** the case with evidence, or **accept** it via
`/api/v1/chargeback/accept`.
3. If you defend, submit evidence before `expiryDateTime` (see below).
If no evidence is submitted before the deadline, the case becomes
`EXPIRED` and the funds are not refunded.
Accepting is **irreversible**: the case closes, you are not refunded, and no
further evidence can be submitted. Accept is callable while the case is
waiting on you — `EVIDENCE_REQUIRED`, `ACTION_REQUIRED` or
`SECOND_CYCLE_RESPONSE_REQUIRED` — and rejected once the case is in a final
status; there is no additional debit, because the amount was already booked
when the case was created.
A note on timing: chargeback amounts and fees are booked against you when the
case is created, but the money moves with your next settlement cycle rather
than instantly. Your settlement statement is the place to reconcile them.
## How do I submit representment evidence?
For a First Cycle case in `ACTION_REQUIRED`:
1. Upload each evidence file with `/api/v1/chargeback/file/upload`
(`multipart/form-data`, part name `file`; JPG, JPEG, PNG or PDF; max
10 MB). The response returns a `fileId` — it contains a slash, so
URL-encode it when used as a query parameter.
2. Call `/api/v1/chargeback/update` with the file IDs (comma-separated) in
`message.documents` or `evidence.othersFile`. At least one evidence file
is required. Re-submitting a field replaces its previous value; omitted
fields are unchanged.
3. On success the status becomes `UNDER_REVIEW` — this means Waffo accepted
the submission and forwards it to the payment channel; it is **not** a
channel decision.
For a Second Cycle case in `SECOND_CYCLE_RESPONSE_REQUIRED` (Pre-Arbitration),
evidence files are **not** accepted: call `/api/v1/chargeback/update` with
`merchantId` and `chargebackId` (optionally `message.notes` as a defense
note) to defend, or accept the case. Update calls after `expiryDateTime` are
rejected. The file upload and download APIs are authenticated by `X-API-KEY`
only and do not participate in RSA signing.
## What evidence should I prepare for different dispute reasons?
Whatever the reason code, effective representment evidence answers three
questions:
| Dimension | Question | Typical evidence |
| - | - | - |
| Transaction authenticity | Did the transaction genuinely happen? | Authorization result, device / IP information, account activity |
| Fulfillment | Was the product or service delivered? | Delivery confirmation, usage or access logs, invoices / receipts |
| Policy compliance | Were refunds and cancellations handled per your published policy? | The policy as shown at checkout, the user's acknowledgment, communication records |
Each case carries an **Evidence Requirements** list generated from its
dispute reason, marking items as mandatory or recommended. A submission
missing mandatory items is blocked, so review the list before uploading.
## What are the chargeback statuses?
| Status | Meaning | Funds |
| - | - | - |
| `ACTION_REQUIRED` | Submit evidence before `expiryDateTime` | Amount and fee are booked against you |
| `UNDER_REVIEW` | Evidence submitted, under channel review | — |
| `EVIDENCE_REQUIRED` | The channel asked for additional evidence after review | — |
| `SECOND_CYCLE_RESPONSE_REQUIRED` | Pre-Arbitration: defend or accept; no evidence files | Fee debited; the amount is debited again if the First Cycle had been won |
| `ESCALATE_TO_2ND_CYCLE` | Escalated to Second Cycle | No further debit |
| `CASE_WON` | Channel ruled for you | Amount refunded, fee retained |
| `CANCELED` | Withdrawn by the channel or cardholder | Amount refunded, fee retained |
| `SETTLED` | Settled offline | Amount refunded, fee retained |
| `CASE_LOST` | Channel ruled against you | Not refunded |
| `ACCEPTED` | You accepted the chargeback | Not refunded |
| `EXPIRED` | No evidence before `expiryDateTime` | Not refunded |
A dispute has two rounds, tracked by `lifeCycle`: `FIRST_CYCLE` and
`SECOND_CYCLE` (Pre-Arbitration); the two cases are linked through
`relevantChargebackId`. `CASE_LOST`, `ACCEPTED` and `EXPIRED` are not
absolutely final: the only way they change is by the issuer escalating the
case to Second Cycle. `CASE_WON`, `CANCELED`, `SETTLED` and
`ESCALATE_TO_2ND_CYCLE` never change. Always treat the latest notification as
authoritative.
## What is the deadline for submitting evidence?
Each case carries its own `expiryDateTime` (ISO 8601, UTC). There is no
single fixed window — read it per case. Submissions after `expiryDateTime`
are rejected, and a case with no evidence by then becomes `EXPIRED`.
## Can I refund an order that has a chargeback dispute?
No. A refund request against an order with a chargeback dispute is rejected
with error `A0019` ("Refund not allowed due to a chargeback dispute") — see
[Error codes](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes).
The chargeback flow itself decides whether the disputed amount is returned.
## Does Waffo send chargeback summary emails?
Yes — two daily emails, and no monthly report:
* **Previous-day disputes report** — sent only when new cases were recorded the
day before; no email on a given day simply means nothing new was recorded.
The attachments are split into two lists: **Customer Disputes** and
**Chargebacks**. If you are enrolled in the Chargeback Alert service, the
report also includes an alert list with each alert's refund status and expiry
time.
* **Daily volume report** — the day's counts of chargeback alerts, chargebacks
and customer disputes, with a link into the Portal.
Both go to the same recipients — see *Which email address do dispute
notifications go to?* below.
## How am I notified when Chargeback Alert auto-refunds an order?
If the alert service has already refunded an order, a refund you submit for
the same order is rejected with `A0039` — that is the signal to check the
order rather than retry.
Whether your account receives alert notifications, and in what form, depends
on how Chargeback Alert is set up for you; confirm the details with
integration support before building against them. What is consistent is the
downstream effect: when an alert relates to a subscription, Waffo can cancel
that subscription automatically — see the next question.
If the automatic refund fails, Waffo processes the case manually and
contacts you if it still cannot be completed; an unresolved alert may
develop into a formal chargeback.
## Can a chargeback or fraud signal automatically cancel the user's subscription?
Yes, as an opt-in capability — contact Waffo to enable it. It is configured
per merchant account and per signal type (chargeback alert, chargeback,
reported fraud, customer dispute), and is **off by default**.
When enabled, Waffo cancels the affected subscription automatically as soon
as the signal is recorded, without waiting for you to call the cancel API:
* Only the disputed subscription is cancelled — the user's other
subscriptions are untouched.
* Payments already collected are not refunded automatically; the capability
only stops future deductions.
* The cancellation is final and cannot be reversed.
* Your own merchant-initiated refunds never trigger it.
Notifications arrive in order: for alert and chargeback signals you first
receive the usual chargeback notification, then a
`SUBSCRIPTION_STATUS_NOTIFICATION` whose status indicates the subscription
was cancelled by the platform (`PLATFORM_CANCELLED`), with a `cancelReason`
naming the triggering signal.
## My settlement bill has a Chargeback Alert line. What is it?
Chargeback Alert service fees are cleared as their own transaction type,
**Chargeback Alert**. Any alert fee not yet settled in the period enters
clearing alongside your `PAYMENT`, `REFUND` and `CHARGEBACK` rows, appears in
both the settlement detail report and the summary report, and is deducted from
the settlement amount. If the fee is billed in a currency other than your
settlement currency, it is converted to the settlement currency before being
deducted.
Alert fees also count towards the net-amount test that decides whether a
settlement happens at all: a period whose alert fees and chargebacks outweigh
its incoming payments settles nothing and carries forward to the next period —
see [Settlement Cycle & Calendar](/docs/en/support/settlement-cycle-faq).
## Why was I billed for an alert on an order I had already refunded?
Because every chargeable alert is billed once it is delivered and matched to an
order. Billing reflects delivery, not whether the alert actually prevented a
chargeback — so an alert can be billed even where no chargeback was ever
possible:
| Situation | Alert fee | Chargeback fee |
| - | - | - |
| The alert arrived and the order was refunded in response | Charged | — |
| You had already refunded the order before the alert arrived | Charged | — |
| The original transaction had failed, so no chargeback was possible | Charged | — |
| The alert arrived after the order had already gone to chargeback | Charged | Also charged |
| No action was taken and the case became a chargeback | Charged | Also charged |
| The alert could not be matched to any order in Waffo | Not charged | — |
The last row is the only case that is free of charge by rule. Where an alert
case ends up in chargeback as well, the alert fee and the chargeback fee are
both due — they are separate charges for separate events.
## Where do I see the refund that an alert triggered?
On your side it is an ordinary refund. It clears through the normal refund flow
and appears in your transaction records and settlement reports as a regular
`REFUND` row, with nothing marking it as alert-initiated — you cannot (and do
not need to) tell it apart from a refund you submitted yourself.
Once an order has been refunded through the alert flow, Waffo makes sure the
same order does not also produce a chargeback. That is why your own refund
request for such an order comes back with `A0039`: the money has already gone
back to the cardholder.
## Which email address do dispute notifications go to?
Customer dispute, chargeback and chargeback alert emails — the new-dispute
notification and both daily reports — are sent to the Portal users of your
merchant account who hold the **Super Admin**, **Admin** or **Dispute Manager**
role. There is no separate recipient list: to add, replace or remove a
recipient (for example when your dispute handler changes), add the person as a
Portal user with one of those roles, or change their role, in the Portal's user
management. If nobody holds one of those roles, the emails are not sent.
## What is the difference between a Customer Dispute and a Chargeback?
They are two different case types, not two names for the same thing. Every dispute
record carries a `disputeType` of either `CUSTOMER_DISPUTE` or `CHARGEBACK`, and the
two types have their own status sets, their own effect on your money, and their own
menu in the Portal — **Customer Disputes** and **Chargebacks** are separate lists
that open a shared detail page.
| | Customer Dispute | Chargeback |
| - | - | - |
| What it is | The customer raised a complaint about the payment and is waiting for your response | The issuing side formally reversed the payment |
| Status set | `EVIDENCE_REQUIRED` / `UNDER_REVIEW` / `ESCALATED` / `CLOSED` | `ACTION_REQUIRED` … `CASE_WON` / `CASE_LOST` etc. (see the status table above) |
| Effect on funds | None by itself — the case does not book an amount or a fee against you | Amount and fee are booked when the case is created |
| Can you still refund the order? | **Yes** — a customer dispute does not lock refunds, and refunding is often how the case is resolved | No — refunds are rejected with `A0019` |
| Webhook | No — `CHARGEBACK_NOTIFICATION` is sent for chargebacks only | Yes, on every status change |
| API visibility | Not returned by `/api/v1/chargeback/list` or `/inquiry` — those are filtered to chargebacks | Yes |
| Where you see it | Portal **Customer Disputes**, and the Customer Disputes attachment of the dispute reports | Portal **Chargebacks**, webhook, chargeback API, settlement reports |
The practical consequence: a customer dispute is a conversation you can still settle
commercially, and it produces no webhook — so if you rely only on webhooks you will
never learn about it. Watch the Portal list (the tab carries a count badge) or the
daily dispute report.
## How does a Customer Dispute progress, and what can I do at each step?
| Status | Meaning | What you can do |
| - | - | - |
| `EVIDENCE_REQUIRED` | The case was opened, or the channel asked for more material. `ACTION_REQUIRED` carries the same meaning — both mean your response is needed | Submit evidence, reply, or close the dispute |
| `UNDER_REVIEW` | You submitted evidence and it is being reviewed | Reply; you may still be moved back to `EVIDENCE_REQUIRED` if more material is wanted |
| `ESCALATED` | A chargeback was raised on the same payment — the case continues as that chargeback | No further status change on this record; work the chargeback instead |
| `CLOSED` | The case is finished, closed by you or by the channel | Nothing, unless the customer complains again |
Notes that follow from the state machine:
* **Submitting evidence requires at least one file.** The submission moves the case to
`UNDER_REVIEW`; evidence can only be edited while the case is in `EVIDENCE_REQUIRED`
or `UNDER_REVIEW`.
* **Closing is available only from `EVIDENCE_REQUIRED` or `UNDER_REVIEW`** and is
recorded as closed by the merchant. An `ESCALATED` case cannot be closed.
* **A `CLOSED` customer dispute can reopen.** If the customer complains again, the case
returns to `UNDER_REVIEW` rather than starting over as a new dispute.
* **Escalation only happens while the case is open.** If a chargeback arrives on a
payment whose customer dispute is still open, that dispute flips to `ESCALATED` and the
chargeback record links back to it through `relevantCustomerDisputeId`. A dispute
already `CLOSED` is left alone.
* **Expiry behaves differently from a chargeback.** Passing the evidence deadline on a
customer dispute only flips the separate **Expire Status** column from `ACTIVE` to
`EXPIRED`; the dispute status is unchanged and the case stays workable. On a chargeback,
the deadline turns the case itself into `EXPIRED` and the amount is not refunded.
Where the payment method supports it, the detail page also offers **Reply Message** —
a free-text reply with optional attachments that goes into the dispute conversation
without changing the status, and an unread counter for messages from the customer's side.
## Can I reply to repetitive customer disputes automatically?
Yes, per payment method, on the **Dispute Config** tab of the Customer Disputes page.
When a customer's complaint is recorded for the first round, Waffo posts your configured
message as the first reply on your behalf — typically to point the customer at your
support channel while you work the case.
The tab appears only for accounts whose contracted payment methods support dispute
replies and for users holding the auto-reply permission; **if you can see the tab, the
feature is available to you.** Auto-reply covers customer disputes only, never chargebacks.
How the rules match and behave:
* **The only matching dimension is the payment method** of the original order. There is no
matching on dispute reason, amount or customer. The currency shown next to each rule is
derived from the payment method for information — it is not a second matching key.
* **One rule per payment method.** Creating a second rule for the same payment method is
rejected, and the payment method cannot be changed after the rule is created — edit the
message or the status instead.
* **The dropdown only offers payment methods you have contracted that also support dispute
replies.** A payment method missing from the list cannot be configured.
* **Only an Active rule fires.** There is no delete: set a rule to **Inactive** to stop it.
* The reply does not change the dispute status and does not count as evidence submission —
you still need to submit evidence or close the case.
Fields and history: a rule holds `paymentMethodId` (shown as **Apply to**),
`messageContent` (up to 256 characters) and `status` (Active / Inactive). The Dispute
Config list is the configuration history — every rule for your account, newest first, with
its payment method, currency, message, status and creation time, each editable in place.
These rules are created, edited and switched on or off only on the Dispute Config
tab of the Portal; there is no signed public API for auto-reply configuration.
# Chargeback Field Reference
Source: https://waffo.com/docs/en/support/chargeback-field-reference
A field dictionary for reading chargebacks in the Merchant Portal and in your settlement statement: the four CHARGEBACK Event Types and their signs, the Compulsory Channel Review flag, every Customer Dispute and Chargeback timeline event, which events move the unread counter, the 26 columns of the chargeback CSV export, how Dispute Amount is derived, the chargeback fee currency, and the Alert Status values in the daily dispute report.
This is the field dictionary behind
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq) — use that page for how the process
works and what to do, and this page when you are staring at a value in the Portal, in a
CSV or in a settlement line and need to know what it means. Evidence selection is on
[Chargeback evidence guide](/docs/en/support/chargeback-evidence-guide).
## My settlement detail has a `CHARGEBACK` row. What are the Event Types and which way does the money go?
For chargeback movements `Transaction Type` is always `CHARGEBACK`. The **`Event Type`**
column is what tells you whether the line is a debit or a repayment, and whether it
carries the amount, the fee, or both:
| `Event Type` | Raised by | Order amount on the line | Fee on the line | Settlement amount |
| - | - | - | - | - |
| `FIRST_CB_PRE_DEDUCT` | A first-cycle chargeback opened against you | The disputed amount | The chargeback fee | **Negative** — `−(amount + fee)` |
| `FIRST_CB_AMOUNT_REFUND` | The first cycle closed in your favour | The disputed amount | Zero | **Positive** — `+amount` |
| `SECOND_CB_PRE_DEDUCT` | A second cycle (Pre-Arbitration) opened | **Zero** — no second debit of the amount | The chargeback fee | **Negative** — `−fee` only |
| `SECOND_CB_AMOUNT_REFUND` | The second cycle closed in your favour | The disputed amount | Zero | **Positive** — `+amount` |
Reading the pattern rather than the amounts is the reliable way round:
`*_PRE_DEDUCT` always takes money, `*_AMOUNT_REFUND` always gives it back, and the fee
only ever appears on a `PRE_DEDUCT` line. A case that opens, escalates and is finally won
therefore produces three or four separate `CHARGEBACK` rows across different settlement
dates — not one net row.
Which case statuses produce which event:
| Case status when the settlement event is raised | Event Type |
| - | - |
| `ACTION_REQUIRED`, `EVIDENCE_REQUIRED`, `UNDER_REVIEW`, `SECOND_CYCLE_RESPONSE_REQUIRED` | `PRE_DEDUCT` for the matching cycle |
| `CASE_WON`, `SETTLED`, `CANCELED` | `AMOUNT_REFUND` for the matching cycle |
| `CASE_LOST` | `PRE_DEDUCT` — the amount stays with the cardholder, nothing is returned |
Note the consequence for `CASE_LOST`: there is no separate "loss" event. You were already
debited when the case opened, so losing produces no new line — the absence of an
`AMOUNT_REFUND` row *is* the loss. Timing and the settlement-day rule are in
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq).
Older records can carry an empty `Event Type`. Those predate this breakdown and are always
debits.
## What is `Compulsory Channel Review` on the chargeback detail page?
`compulsoryChannelReview` is a true/false flag on the case, returned to the Portal, that
records whether the payment side mandates its own review of the case. Chargebacks are
created with it set to **true**; customer disputes raised through an in-app complaint flow
are created with it set to **false**.
Read it as an expectation-setter for the outcome: where it is true, the case is decided by
the reviewing side and your submission is input to that review, not a resolution of it.
Where it is false, resolving the case with the customer directly is a real option, and
closing the dispute is how that resolution is recorded.
What actually gates the **Close** action is the case status, not this flag: closing is
available from `EVIDENCE_REQUIRED`, `ACTION_REQUIRED` and `UNDER_REVIEW`, and an
`ESCALATED` case cannot be closed. So if Close is greyed out, check the status first.
## What does each event in a Customer Dispute timeline mean?
The timeline records 11 event values. Anything not in this list is not a timeline event.
| Event | When it appears | What you do |
| - | - | - |
| `CREATE_DISPUTE` | The dispute was opened against a payment | Read the reason, decide whether to defend, reply or refund |
| `MERCHANT_SUBMIT_EVIDENCE` | Your evidence submission was accepted; the case moves to `UNDER_REVIEW` | Nothing — wait for review |
| `MORE_EVIDENCE_REQUIRED` | More material was asked for; the case returns to `EVIDENCE_REQUIRED` | Upload the additional material |
| `DISPUTE_ESCALATED` | A chargeback was raised on the same payment; this record stops progressing | Work the linked chargeback instead |
| `CLOSE_DISPUTE` | The case was closed — by you, or by the reviewing side | Nothing, unless the customer complains again |
| `NOTIFY_OVERDUE` | The case was flagged as approaching its evidence deadline | Submit before the deadline |
| `EXPIRED` | The evidence deadline passed | The case is **still workable** — see the expiry question below |
| `USER_RESPONSE` | The customer replied in the dispute conversation | Read it; reply if useful |
| `PLATFORM_RESPONSE` | A response arrived from the reviewing side | Read it; it often specifies what is missing |
| `MERCHANT_RESPONSE` | Your own reply was posted | Nothing |
| `USER_CREATE_COMPLAINT` | The customer raised a further complaint — this is what reopens a `CLOSED` case | Respond; the case returns to an open state |
## What does each event in a Chargeback timeline mean?
14 event values, a different set from customer disputes:
| Event | When it appears | What you do |
| - | - | - |
| `CREATE_DISPUTE` | The chargeback was created | Read `reasonCode`, `reason` and `expiryDateTime`; decide defend or accept |
| `MERCHANT_SUBMITS_EVIDENCE` | Your evidence submission was accepted | Wait |
| `AUTO_SUBMITS_EVIDENCE` | Evidence was submitted automatically on your behalf under your configured handling strategy | Verify what went in — you are on record as having defended |
| `MORE_EVIDENCE_REQUIRED` | Additional material was asked for after review | Upload it before the deadline |
| `NOTIFY_OVERDUE` | The case was flagged as approaching its deadline | Submit or accept |
| `MERCHANT_ACCEPTED_DISPUTE` | You accepted the case | Nothing — this is irreversible |
| `MERCHANT_WON_DISPUTE` | Ruled in your favour | Expect an `AMOUNT_REFUND` settlement line |
| `MERCHANT_LOST_DISPUTE` | Ruled against you | Nothing further on this cycle; a second cycle may follow |
| `CHARGEBACK_SETTLED` | The case was settled outside the normal flow | Amount returned, fee retained |
| `CHANNEL_CLOSE_DISPUTE` | The case was withdrawn on the raising side | Amount returned, fee retained |
| `CHARGEBACK_EXPIRED` | No response before the deadline | Nothing — the amount is not returned |
| `ESCALATED_TO_2ND_CYCLE` | The case escalated to Pre-Arbitration | A new second-cycle record now exists; work that one |
| `CREATE_SECOND_CYCLE_DISPUTE` | The second-cycle record was created | Defend or accept; **no evidence files are accepted** in the second cycle |
| `MERCHANT_DEFENDS_SECOND_CYCLE` | You chose to defend the second cycle | Understand the arbitration exposure before doing this |
`AUTO_SUBMITS_EVIDENCE` and `CREATE_SECOND_CYCLE_DISPUTE` are the two events that can
appear without any action from you. If you have set a blanket handling strategy, these are
where you find out it fired.
## Which timeline events increase the unread counter?
Exactly three, and all three are inbound messages rather than status changes:
* `USER_RESPONSE`
* `PLATFORM_RESPONSE`
* `USER_CREATE_COMPLAINT`
Your own `MERCHANT_RESPONSE` does not increment it, and neither does any status change,
any evidence submission or any deadline event. So an unread count is always *someone said
something to you*, never *something happened to the case* — a case can move all the way to
a final status without the counter ever moving. Do not use the counter as an activity
indicator; use the timeline.
## How do I export my chargeback detail, and does it go through the Reports tab?
The Chargebacks list has its own export, and unlike most Portal exports it is a **direct
synchronous download**: the file is streamed to your browser when you click, and **no task
row appears in any Reports tab**. If you are looking for it in
[Exports & report downloads](/docs/en/support/export-center-faq), it is not there — that page
covers the queued exports, this one is not queued.
The file name is:
```
chargebacks_{merchant id}_{from}_{to}.csv
```
Both dates are `YYYYMMDD` in UTC+8 and come from the created-time filter you had applied.
The export follows your current list filters, so filter first, export second.
## What are the columns in the chargeback CSV export?
26 columns, in this order:
| # | Header | What it holds |
| - | - | - |
| 1 | `Chargeback Id` | The case id |
| 2 | `Life Cycle` | `FIRST_CYCLE` or `SECOND_CYCLE` |
| 3 | `Relevant Chargeback Id` | The other cycle's case id |
| 4 | `Original Order Id` | Waffo's id for the disputed order |
| 5 | `Original Payment Request id` | Your payment request id for that order |
| 6 | `Subscription Request` | Your subscription request id, for subscription cases |
| 7 | `Related Subscription Id` | Waffo's subscription id, for subscription cases |
| 8 | `Merchant User Id` | Your id for the buyer |
| 9 | `Merchant Order Id` | Your order id |
| 10 | `Merchant Id` | Your MID |
| 11 | `Sub Merchant Id` | Sub-MID, where you use one |
| 12 | `Chargeback Amount` | The amount disputed **against you**, in the order currency — see below |
| 13 | `Chargeback Currency` | Currency of column 12 |
| 14 | `Chargeback Reason Code` | The reason code on the case |
| 15 | `Chargeback Reason` | The reason text |
| 16 | `Transaction Type` | `CHARGEBACK` |
| 17 | `Country/Region` | Country of the payment |
| 18 | `Payment Method Type` | For example `CREDITCARD` |
| 19 | `Payment Method` | The specific method used |
| 20 | `Chargeback Status` | The case status |
| 21 | `Settlement Status` | Whether the movement has cleared into a settlement — see below |
| 22 | `Created Datetime(UTC+8)` | When the case was created |
| 23 | `Updated Datetime(UTC+8)` | When the case last changed |
| 24 | `Expiry Datetime(UTC+8)` | The evidence deadline |
| 25 | `Chargeback Fee Amount` | The chargeback fee booked on the case |
| 26 | `Chargeback Fee Currency` | Currency of column 25 — your configured fee currency, see below |
Every header is unique, so a parser keyed on header text is safe. Note that the export
says **Chargeback** where the Portal detail page says **Dispute** for the same values —
columns 12 and 25 are the disputed amount and the fee respectively, not two amounts.
## Column 21 says the case is not settled yet. Has the money been taken or not?
That is exactly what `Settlement Status` is for, and it is the column to check when the
case status and your bank balance disagree.
`Chargeback Status` and `Settlement Status` answer different questions and move at different
times:
| Column | Question it answers |
| - | - |
| `Chargeback Status` | Where the case stands with the reviewing side |
| `Settlement Status` | Whether the resulting money movement has been through a settlement yet |
A chargeback is booked against you when the case opens, but it only reaches a settlement
statement once the **original payment order has settled**, because it reuses that order's
settlement exchange rate. So a case can sit at `ACTION_REQUIRED` with the amount already
booked and `Settlement Status` still showing unsettled — the debit is coming, on the
settlement day of the original order or the next settlement day after that. Pair this
column with the `Event Type` table above to reconcile a specific `CHARGEBACK` line.
## Why is `Dispute Amount` different from what the cardholder disputed?
Because they are two different figures, and the Portal shows you yours.
The amount raised on the payment side is denominated in the **payment currency** of the
original transaction. The `Dispute Amount` you see — and the amount you are actually
debited — is the **merchant share, expressed in the order currency**:
1. Any buyer-side fee on the original payment is funded by Waffo, not by you, and is
excluded from your disputable balance.
2. Your share of the disputed amount is taken in proportion to your share of what the
buyer paid, and any remainder is borne by Waffo.
3. That share is converted to the order currency and **rounded down (FLOOR) to 2 decimal
places**, so the figure is never rounded up against you. A sub-cent remainder stays
with Waffo.
Consequences worth knowing:
* On a cross-currency order, your `Dispute Amount` will not equal the figure quoted on the
payment side, and the difference is not an error.
* Where the order was already partly or fully refunded, your remaining disputable balance
is lower, and your share is capped by it.
* Your `Dispute Amount` can never exceed the order amount.
The proportional split itself — how much of a given dispute falls to you versus to Waffo —
is a funding rule, not a self-service setting; ask support if a specific case does not
reconcile.
## What currency is my chargeback fee in? It is not USD.
The chargeback fee currency comes from **your** merchant configuration, not from a global
default, and it is returned on the case as `feeCurrency` alongside `feeAmount`. It is not
guaranteed to match your settlement currency and it is not guaranteed to be USD.
When the fee currency differs from your settlement currency, the fee is converted to the
settlement currency at settlement time, using the settlement-side rate for that
settlement — the same rate basis as the rest of that settlement, not a rate captured on
the day the case opened. Where the two currencies are the same, no conversion happens.
So if your statement shows a chargeback fee in a currency you did not expect, that is your
configured fee currency showing through. The fee amount itself is a contract term — see
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq).
## How do I find the cases that are about to run out of time?
Both the Chargebacks and Customer Disputes lists carry an **expiring soon** filter, which
returns the open cases whose evidence deadline falls within the next **3 days**. That is
the supported way to triage by deadline; each case still carries its own
`expiryDateTime`, and that value is authoritative for the individual case.
## What happens when the evidence deadline passes?
The two case types behave differently, and this is the most commonly misread difference
between them:
| | Customer Dispute | Chargeback |
| - | - | - |
| What changes at the deadline | Only a separate **Expire Status** column flips from `ACTIVE` to `EXPIRED` | The case **status itself** becomes `EXPIRED` |
| Case status after the deadline | Unchanged — stays `EVIDENCE_REQUIRED` / `ACTION_REQUIRED` / `UNDER_REVIEW` | `EXPIRED`, a final status |
| Can you still submit? | **Yes** — the case stays workable | No |
| Money | Unaffected by expiry itself | The disputed amount is not returned |
So an expired customer dispute is a nudge, not a closed door: submit late material and
keep working the case. An expired chargeback is a loss. When you are prioritising a queue
under time pressure, chargeback deadlines are the hard ones.
Each case's window is set on the payment side and arrives on the case as
`expiryDateTime` — there is no single Waffo-wide window, and it is not derived from the
transaction date, so always read it per case rather than computing it.
## What do the `Alert Status` values in the daily dispute report mean?
If Chargeback Alert is enabled for you, the daily dispute report carries an alerts
attachment. `Alert Status` is the overall stage of the alert case:
| `Alert Status` | Meaning | Does it need you? |
| - | - | - |
| `PROCESSING` | The case is being worked | No |
| `MANUAL PROCESSING` | The case hit an exception and is being handled by hand **at Waffo** | **No** — despite the name |
| `PROCESSED` | A refund was initiated and the alert answered; final confirmation is still pending | No |
| `FAILED` | Final: the case could not be completed | Only if Waffo contacts you |
| `EXPIRED` | Final: the case did not reach a final state within 72 hours of being received | No |
**`MANUAL PROCESSING` does not mean "manual processing by you".** It is the single most
misread value in this report: it means Waffo took the case out of the automated path. You
are contacted directly if anything is actually needed from you.
The attachment reports the alert case's stage and its refund; it does not expose the
internal handling steps behind a `MANUAL PROCESSING` case, so there is nothing further to
read into that value than "not automated, being handled".
## What is `Refund Status`, and what does an Alert ID look like?
`Refund Status` in the same attachment tracks only the refund that the alert triggered:
| `Refund Status` | Meaning |
| - | - |
| `PROCESSING` | The refund has been submitted and is not confirmed yet |
| `SUCCESS` | The refund completed |
| `FAILED` | The refund did not complete |
A `FAILED` refund does not mean the alert was ignored — it is exactly the case that gets
picked up by hand, which is why `MANUAL PROCESSING` and a `FAILED` refund often appear on
the same row.
Alert IDs are formatted like `CBA-00-20251125-00003`: a fixed `CBA` prefix, a two-digit
segment, the date, and a sequence number. Quote the whole string when you contact support
about a specific alert. Where an alert relates to a subscription, the attachment also
carries the subscription id.
Once the alert has refunded an order, your own refund request for that order is rejected
with `A0039` — see [Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq) for what that
means and how alert fees are billed.
# Chargeback Reason Codes & Reported Fraud
Source: https://waffo.com/docs/en/support/chargeback-reason-codes-and-reported-fraud
The complete Visa and Mastercard dispute reason codes Waffo recognises, grouped by category, with what each one is claiming; plus what Reported Fraud is, how it differs from a chargeback, and how to query it.
Chargeback Analytics shows the most frequent reason codes plus an **Others** bucket, and a case's
own detail shows its single code. This page is the full list behind both, so you can look up any
code you receive. What to *send* for a given code is in the
[Chargeback Evidence Guide](/docs/en/support/chargeback-evidence-guide); the case's own fields are in
the [Chargeback field reference](/docs/en/support/chargeback-field-reference).
## How are reason codes organised?
Every recognised code belongs to one card scheme and one of five categories. The category is the
useful thing: it tells you which of the three defence dimensions the case turns on before you read
the code text at all.
| Category | What the issuer is claiming, in one line |
| - | - |
| **Fraud-related** | The cardholder says they did not authorise this |
| **Authorization Errors** | The transaction should not have been authorised the way it was |
| **Processing Errors** | The transaction was submitted incorrectly — wrong amount, currency, date or account |
| **Customer Disputes** | The cardholder authorised it but is unhappy with what they got |
| **Miscellaneous Chargebacks** | Region-specific and residual cases |
A code you receive that is not in the tables below comes through as `0000/00` / *Not available* —
see the last question on this page.
## Visa reason codes
### Fraud-related
| Code | Reason |
| - | - |
| `10.1` | EMV Liability Shift Counterfeit Fraud |
| `10.2` | EMV Liability Shift Non-Counterfeit Fraud |
| `10.3` | Other Fraud – Card Present Environment |
| `10.4` | Other Fraud – Card Absent Environment |
| `10.5` | Visa Fraud Monitoring Program |
`10.4` is the one most online merchants see: the cardholder denies a card-not-present transaction.
### Authorization Errors
| Code | Reason |
| - | - |
| `11.1` | Card Recovery Bulletin |
| `11.2` | Declined Authorization |
| `11.3` | No Authorization |
### Processing Errors
| Code | Reason |
| - | - |
| `12.1` | Late Presentment |
| `12.2` | Incorrect Transaction Code |
| `12.3` | Incorrect Currency |
| `12.4` | Incorrect Account Number |
| `12.5` | Incorrect Amount |
| `12.6` | Duplicate Processing / Paid by Other Means |
| `12.7` | Invalid Data |
`12.6` is the code to check first when a cardholder says they were charged twice — it is often
answerable with the two authorisation records alone.
### Customer Disputes
| Code | Reason |
| - | - |
| `13.1` | Merchandise / Services Not Received |
| `13.2` | Cancelled Recurring |
| `13.3` | Not as Described or Defective Merchandise / Services |
| `13.4` | Counterfeit Merchandise |
| `13.5` | Misrepresentation |
| `13.6` | Credit Not Processed |
| `13.7` | Cancelled Merchandise / Services |
| `13.8` | Original Credit Transaction Not Accepted |
| `13.9` | Non-Receipt of Cash or Load Transaction Value |
`13.2` and `13.7` are the subscription and cancellation codes: what decides them is your
cancellation record and the policy the buyer accepted, not proof of delivery.
## Mastercard reason codes
### Fraud-related
| Code | Reason |
| - | - |
| `4837` | No Cardholder Authorization |
| `4849` | Questionable Merchant Activity |
| `4870` | Chip Liability Shift |
| `4871` | Chip Liability Shift – Lost / Stolen / Never Received Issue (NRI) Fraud |
`4837` is the code that drives the Mastercard fraud-rate card in Chargeback Analytics — see
[Portal analytics](/docs/en/support/portal-analytics-faq) — so it is the one with a compliance
consequence attached, not just a financial one.
### Authorization Errors
| Code | Reason |
| - | - |
| `4808` | Authorization-Related Chargeback |
### Processing Errors
| Code | Reason |
| - | - |
| `4831` | Point-of-Interaction (POI) Error |
### Customer Disputes
| Code | Reason |
| - | - |
| `4841` | Canceled Recurring or Digital Goods Transactions |
| `4850` | Installment Billing Dispute – Participating Countries |
| `4853` | Cardholder Dispute |
| `4854` | Cardholder Dispute – Not Elsewhere Classified |
| `4855` | Goods or Services Not Provided |
| `4859` | No Show / Addendum / ATM Dispute |
| `4860` | Credit Not Processed |
### Miscellaneous
| Code | Reason |
| - | - |
| `4999` | Domestic Chargeback Dispute (European region) |
## Two schemes use similar-looking codes. How do I read mine correctly?
Always read the code **together with the card scheme** on the case. The codes are only unique
within a scheme, and Waffo resolves a code by scheme precisely to avoid the ambiguity — so a code
looked up against the wrong scheme can land you on the wrong claim and the wrong evidence. The
case record carries both; use both.
## My case shows an unknown or unhelpful reason. What do I do?
A code Waffo does not recognise, or a case that arrives without one, is represented as `0000/00`
with the reason *Not available*. It is a real state, not a data error — some raising sides do not
pass a code through.
It changes nothing about the deadline and nothing about your ability to defend. What it changes is
how you build the submission: with no code to aim at, send the fallback combination in the
[Chargeback Evidence Guide](/docs/en/support/chargeback-evidence-guide) and, where you can, find out
what the actual claim is before you submit.
## What is Reported Fraud, and is it the same as a chargeback?
No — they are separate records and one does not imply the other.
| | Reported Fraud | Chargeback |
| - | - | - |
| What it is | The card scheme reporting that a transaction was flagged as fraudulent | A formal dispute claiming the money back |
| Does money move? | **No** | Yes — the amount and fee are booked against you |
| Can you contest it? | There is nothing to contest | Yes, within the deadline |
| Where it shows | The Reported Fraud API; its count feeds the Visa fraud-rate card in Chargeback Analytics | The Chargebacks list, the chargeback API and the daily dispute report |
A transaction can be reported as fraud and never charged back, or be charged back and never
reported. Both matter for compliance ratios, and one of the ratio cards deliberately combines
them — see [Portal analytics](/docs/en/support/portal-analytics-faq) for exactly which card counts
what.
The practical point: Reported Fraud is a **signal**, not a liability. It costs you nothing
directly and there is no action to take on an individual record, but a rising volume is the early
warning that chargebacks and a monitoring-programme problem are coming. Read it as a trend.
## How do I query Reported Fraud?
Through `/api/v1/fraud-report/list` — a paged POST. There is no Reported Fraud list page in the
Portal, so the API is the only place to get the individual records.
| Parameter | Required | Notes |
| - | - | - |
| `merchantId` | Yes | Your Waffo merchant id |
| `startDate`, `endDate` | Yes | `yyyy-MM-dd`, both inclusive, spanning at most three months — this is the fraud **report** date, not the transaction date |
| `pageNum` | Yes | Starts at 1 |
| `pageSize` | Yes | 1–100 |
| `paymentMethod` | No | An array of card schemes (`VISA`, `MASTERCARD`); multiple values are OR'd, empty means all |
| `acquiringOrderId` | No | Look up a single transaction |
The date range filters on when the fraud was reported, which is **later** — often much later —
than when the transaction happened. A window that looks empty for a period you know had fraud is
usually a window aimed at transaction dates.
## What does each Reported Fraud record contain?
| Field | What it gives you |
| - | - |
| `acquiringOrderId` | The Waffo order, so you can find the transaction |
| `paymentRequestId` | Your own request id — populated for one-off payments, empty for subscriptions |
| `subscriptionId` / `merchantSubscriptionId` | Populated for subscription payments, empty for one-off |
| `merchantUserId` | Your own user identifier for the payer |
| `transactionDateTime` | When the original transaction happened (UTC) |
| `fraudReportDate` | When the report arrived |
| `fraudAmount` / `fraudCurrency` | The amount reported as fraudulent |
| `orderAmount` / `orderCurrency` | The original order amount |
| `fraudReason` | The raising side's own wording, e.g. *Account takeover* |
| `fraudReasonCode` | The card scheme's own fraud code, e.g. `VI:6`, `MC:5` |
| `paymentMethod` | The card scheme |
| `cardType` | Credit / Debit / Prepaid / Unknown |
| `cardMaskedNo` | The masked card number as supplied by the raising side |
Two things to note when you build against it. `fraudReasonCode` is the **scheme's fraud
taxonomy**, which is a different code space from the chargeback reason codes on this page — do not
look one up in the other's table. And the identifier that is populated tells you which flow the
transaction came from: a record with `subscriptionId` set and `paymentRequestId` empty is a
subscription charge, which usually means the useful evidence is the subscription's consent and
billing history rather than a single order.
## Why does one of my cases have no Defend button?
The action on a dispute row reads **Defend** only when both of these hold, and reads **View**
otherwise:
1. **The case is in a status that accepts a response** — one of `EVIDENCE_REQUIRED`,
`ACTION_REQUIRED`, or `SECOND_CYCLE_RESPONSE_REQUIRED`. A case in a final status, an expired
case, and a case still in `UNDER_REVIEW` all show View.
2. **Your Portal user has the dispute-update permission.** Without it every case shows View, no
matter what state it is in. This is the cause people miss — if a colleague can defend a case
you cannot, it is permissions, not the case. Ask your account administrator.
A third case has a status of its own: a chargeback that arrived without enough information to open
properly sits in `INCOMPLETE` until Waffo completes it. `INCOMPLETE` is not a respondable status,
so it shows View — the case will move to `ACTION_REQUIRED` and become defensible, or be closed
out, without you doing anything. Its deadline clock does not start while it is incomplete.
## What decides whether I can close a customer dispute myself?
Two conditions, both on the case:
* The status is one that still accepts action — `UNDER_REVIEW`, or one of the response-required
statuses.
* **Compulsory Channel Review is false.** Where the raising side requires the case to go through
its own review, you cannot close it out yourself; the Close action is not offered. What
Compulsory Channel Review means is in the
[Chargeback field reference](/docs/en/support/chargeback-field-reference).
Replying is separate and more permissive: a customer dispute can be replied to in
`UNDER_REVIEW`, in a response-required status, and even in `CLOSED`, where the case itself allows
replies. That is why a closed customer dispute can still show an unread count.
***
Code you cannot find, or a Reported Fraud record you cannot tie to an order?
[Contact support](mailto:support@waffo.com) with the `acquiringOrderId`.
# Loading the Checkout Inside My Own App (WebView, App Launch, Coming Back)
Source: https://waffo.com/docs/en/support/checkout-in-app-webview-faq
The App-side questions the WebView integration guide does not answer: whether Waffo detects a WebView and warns the customer, which domains to allow, how your App learns the payment result when the customer never returns, whether the checkout refreshes itself after a wallet App round trip, whether a payment redirect link can be opened twice, what to capture when the wallet does not open, why App launch cannot be tested in sandbox, and whether a payment method can be hidden only in the App environment.
The mechanics of embedding the checkout in an App WebView — what your WebView
has to allow, how to intercept a non-HTTP(S) scheme on Android and iOS, and how
to triage `net::ERR_UNKNOWN_URL_SCHEME` — are in
[Checkout integration steps](/docs/en/developer-docs/integration/checkout/steps#app-webview-integration-considerations),
and the per-payment-method exceptions are in
[Payment method integration notes](/docs/en/developer-docs/tools-and-references/references/payment-method-integration-notes).
Read those first. This page answers the questions that come up around them.
## Is there a checklist I can run before I ship the WebView integration?
Yes — these are the capabilities a payment flow can depend on. Each one that
your App blocks removes payment methods rather than degrading gracefully.
| Capability | Why a payment flow needs it | Where the detail is |
| - | - | - |
| Open an external App from a non-HTTP(S) scheme | Wallet methods that authorize inside their own App | [Steps → custom schemes](/docs/en/developer-docs/integration/checkout/steps#handle-wallet-app-custom-schemes) |
| Open an external browser | Methods that state their flow cannot run in a merchant App WebView | [Payment method integration notes](/docs/en/developer-docs/tools-and-references/references/payment-method-integration-notes) |
| Download, copy and long-press save | QR codes, payment codes, bank details | Steps → WebView considerations |
| Preserve the URL and every query parameter | Some methods append required values to the redirect | Steps → WebView considerations |
| `allow="payment"` and a `strict-origin-when-cross-origin` referrer, if you also embed in an iframe | Browser payment APIs and referrer-sensitive methods | [Steps → iframe](/docs/en/developer-docs/integration/checkout/steps#iframe-embedding-considerations) |
| Send `userInfo.userTerminal` honestly | Terminal type decides which methods are eligible at all | [Redirects and `actionType`](/docs/en/support/cashier-redirect-and-action-type-faq) |
## Can I detect from the checkout whether I am inside a WebView? Does Waffo warn the customer?
The checkout does not attempt to detect App WebViews in general, and it does not
show a "please open in a browser" prompt for them. There is one exception: when
the checkout is opened inside a **messaging app's built-in browser** where the
payment flow is known not to work, it shows a blocking dialog that asks the
customer to open the link elsewhere, displays the full link, and offers a copy
button. The dialog cannot be dismissed, because continuing on that page would
fail anyway.
For your own App, detection belongs on your side: you control the WebView, so
you already know. Use that knowledge when you create the order — see the
question on hiding a method in the App environment below.
## Which domains do I have to allow in my WebView?
Allow the Waffo checkout domain, and do not use a domain allowlist as your
control on the payment flow.
The checkout URL is HTTPS on a Waffo domain, but the payment step afterwards
navigates to the payment method's own domain, and for wallet launches to a
non-HTTP(S) scheme that the payment page decides at runtime. That downstream set
is a property of each payment method and changes without any change on your
side, so there is no list you can pin. An App that only permits a fixed set of
hosts will pass integration testing and then fail on a method you did not test.
The supported shape is the one in
[Steps → custom schemes](/docs/en/developer-docs/integration/checkout/steps#handle-wallet-app-custom-schemes):
keep HTTP(S) navigation in the WebView, hand validated non-HTTP(S) navigation to
the operating system.
## The customer left my WebView to pay in a wallet App. How does my App learn the result?
Through your server, not through the WebView. The order of reliability is fixed
and does not change in an App context:
| Signal | Use it for |
| - | - |
| Payment notification to your `notifyUrl` | The authoritative result. Your server tells your App |
| `POST /api/v1/order/inquiry` | Confirming or catching up — on App resume, for example |
| `successRedirectUrl` / `failedRedirectUrl` / `cancelRedirectUrl` | Navigating the customer's screen only |
A practical App pattern: when your App returns to the foreground, ask your own
backend for the order state (which it knows from the notification, or by
inquiring) rather than reading anything out of the WebView. This is also the
pattern that survives the customer switching back to your App by hand instead of
following the redirect.
For the full reasoning on why a redirect is not a result, see
[Payment results](/docs/en/support/payment-results-faq).
## The customer switched straight back to my App without going through the redirect. What do I do with that order?
Nothing special — and specifically, do not treat it as a failure. From the
platform's point of view the order is in exactly the state the payment method
left it in, and the redirect being skipped tells you nothing about that state.
* Query the order, or wait for the notification. Both give the real state.
* Do not close, cancel or re-create the order because the customer did not
come back. That is how duplicate payments happen.
* If the payment is still pending, the customer may well complete it later, in
the wallet App, inside the order's validity window.
## The customer came back into the WebView. Does the checkout page refresh the result by itself?
Yes. While the checkout is on any pending state — waiting on a redirect, waiting
on an instruction such as a QR code, or processing — it polls the order state on
a backing-off interval and advances the page on its own when a result lands.
Certain QR-code methods, where customers typically pay very quickly, are polled
more aggressively right after the code is shown.
The redirect page also carries an explicit re-check action. Pressing it queries
the order immediately; if the payment still has not gone through, the page says
so rather than pretending to be finished.
So the customer does not have to reload anything, and your App should not
force-reload the WebView on resume — that can restart a flow that was fine.
## Can the same payment redirect link be opened twice? Will it stop working if the customer taps pay again?
It depends on the payment method, and you cannot control it. Some payment
methods issue a redirect URL that is **single-use** — once consumed, opening it
again does not work. Others allow it to be reopened.
The hosted checkout knows which case applies and reflects it: where reopening is
allowed, the page keeps offering a **Pay** button so the customer can go back
out to the wallet; where it is not, the page offers only the re-check action, so
the customer is not sent to a URL that will fail.
Two consequences:
* On the hosted checkout you do not have to do anything — the page already
handles both cases.
* On a merchant-built checkout, **treat the redirect URL as single-use**. This
property is deliberately not part of the merchant-facing `orderAction`, so
there is nothing for you to branch on. Present the redirect once, then fall
back to querying the order.
## The customer tapped pay in the WebView and nothing happened. What do I capture before contacting support?
Capture these five things at the moment of failure. Without them the report
cannot be separated from a normal "customer changed their mind".
1. The **last navigation URL** the WebView received, complete and unmodified —
including its scheme and every query parameter
2. Whether your WebView navigation callback fired at all for that URL
3. Whether any installed App on the device can handle that scheme
4. The order ID and the timestamp
5. Whether the customer was in your App's WebView, an external browser, or an
in-app browser of another App
The triage table in
[Steps → troubleshooting](/docs/en/developer-docs/integration/checkout/steps#troubleshoot-err_unknown_url_scheme-and-forbidden)
maps the common symptoms onto causes. Note the rule there: diagnose from the
failed URL's scheme and your WebView's handling of it, not from the device model
or the OS version. The same App build behaving differently across OS versions is
almost always a difference in how the navigation callback is reached, not a
platform-level block.
## Why can I not reproduce the wallet App launch in sandbox?
Because the sandbox does not connect to real payment methods. The sandbox
checkout is a simulator: you press a button to declare the outcome, and there is
no real payment-method selection, no card entry and no wallet App handover. See
[Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator).
So the App-launch part of your integration — scheme interception, handing the URL
to the operating system, coming back — has to be verified in production with a
small real payment. What sandbox *is* good for is everything around it: order
creation, notification handling, App resume logic, and your own
order-state-on-resume flow.
Plan a production smoke test per payment method that involves an App handover,
and budget for the fact that it needs a real wallet account.
## Can I hide a payment method only in my App environment?
Not through a setting, and there is no "environment" parameter on the checkout.
What you can do — and what the platform is designed around — is decide per order:
* Your App knows it is your App. Have your server pass `payMethodType` and, for
a specific wallet, `payMethodName` on `/api/v1/order/create` so the order only
ever offers what works in that environment.
* Send `userInfo.userTerminal` truthfully. Methods that do not support the
terminal type you sent are not offered at all.
Filtering per order is more robust than an account-level switch anyway, because
the same account usually serves a web flow that has no such restriction. The
parameter rules are in
[Checkout customization](/docs/en/developer-docs/integration/checkout/customization#payment-method-filtering).
## Should I just open the checkout in an external browser from the start?
It is a legitimate design, and for some payment methods it is the only one that
works — the per-method notes list the cases where a method's own flow cannot run
inside a merchant App WebView.
The trade-offs to weigh:
| | In-App WebView | External browser from the start |
| - | - | - |
| Customer stays in your App | Yes | No — a visible App switch |
| Wallet App launch | Needs scheme interception in your App | Handled by the browser |
| Download / copy / long-press save | Needs explicit support in your WebView | Works |
| Returning to your App after payment | Your redirect handling | Depends on the customer, or a deeplink redirect URL |
| Methods that refuse a merchant App WebView | Must be hidden or opened externally | No special handling |
A middle path many integrations settle on: keep the WebView for the flows that
work in it, and open the external browser only for the orders whose payment
method requires it — which you know at create time, because you chose the method.
Note that `successRedirectUrl`, `failedRedirectUrl` and `cancelRedirectUrl`
accept deeplinks as well as HTTPS URLs, so an external-browser flow can still
hand the customer back to your App.
## Is there an SDK that wraps the App-side launch for me?
The Waffo front-end SDK covers the web surface — rendering card fields, and
embedding the checkout in your page. The App-side pieces — intercepting a
non-HTTP(S) navigation in your WebView and handing it to the operating system —
are platform APIs in your own App, and there is no Waffo library that replaces
them. The platform documentation links for both Android and iOS are in
[Steps → custom schemes](/docs/en/developer-docs/integration/checkout/steps#handle-wallet-app-custom-schemes).
## Can I send the checkout link to the customer directly, by SMS or email?
That is what a payment link is for, and it is a separate product surface with
its own expiry and reuse rules — see [Payment links](/docs/en/support/payment-link-faq).
Do not repurpose a checkout URL from an API-created order as a link you send
out: it belongs to one order, it expires with that order, and once the order
reaches a final state the link shows the expired screen.
***
Hitting a WebView failure you cannot place? [Contact support](mailto:support@waffo.com)
with the five items from the capture list above.
# Conditionally Required Fields by Payment Method
Source: https://waffo.com/docs/en/support/conditional-required-fields-faq
Why fields marked Optional on /api/v1/order/create — goodsId, goodsUniquePrice, skuName, userFirstName, userPhone, address and friends — can still be rejected as missing, which field paths can appear in a missing-field list, and why the correct pattern is to submit and read the list back rather than predict it.
The acquiring specification marks `goodsInfo`, `addressInfo` and several `userInfo`
fields as optional, and the backing objects really do carry length limits only — no
required constraint. Yet a request that passes schema validation can still come back as
`A0003 Parameter validation failed` naming one of those fields. `payMethodType` values
used below are listed in the
[data reference](/docs/en/developer-docs/tools-and-references/references/data-reference).
## Why is a field marked Optional rejected as missing?
Because "required" is not a property of the API — it is a property of the **payment
method actually used** plus the **scenario** you are in. One integration sends card
payments and needs nothing from `goodsInfo`; another sends an installment payment and
needs the full itemised basket. A single schema cannot mark both. Two consequences:
* **Requirements are not a stable function of the payment method alone.** The same
method can ask for different fields depending on how the payment is processed, and the
set can change without a change on your side, so a per-method list hard-coded in your
integration will drift out of date silently.
* **There is no pre-flight discovery endpoint.** `/api/v1/paymethodconfig/inquiry`
returns availability, maintenance windows, amount range, currency and refund policy —
it does **not** return field requirements.
## What is the correct pattern, then?
Submit, read the list, resend. Do not predict.
1. Send the request with everything you already hold.
2. If required fields are missing, the call fails with `A0003`. For `A0003` specifically
the response `msg` carries the field-level detail appended in parentheses; on
`/api/v1/order/refund` the missing paths also come back as a `missingFields` list.
3. Fill in exactly those paths and resend.
Paths are returned in dotted form — `userInfo.userFirstName`, `addressInfo.postcode`,
`goodsInfo.goodsUniquePrice` — so they map straight onto your request body.
**If you use the hosted cashier you usually never see this.** When the chosen method
needs fields you did not supply, Waffo routes the order to the cashier and the cashier
renders those inputs for the buyer. The loop above matters most where there is no
cashier to fall back on: merchant-initiated transactions (`merchantInitiatedMode`) and
agent payments settled directly.
## Which field paths can appear in a missing-field list?
| Object | Paths that can be requested |
| - | - |
| `userInfo` | `userFirstName`, `userLastName`, `userPhone`, `userEmail`, `userId`, `userIDNumber` |
| `userInfo.userBankInfo` | `bankAccountNo`, `bankCode`, `bankName`, `bankBranch` (refunds paid out by transfer) |
| `addressInfo` | `address`, `city`, `region`, `postcode`, `addressCountryCode` |
| `goodsInfo` | `goodsId`, `goodsName`, `goodsUniquePrice`, `goodsQuantity`, `skuName`, `goodsCategory`, `goodsUrl` |
| `paymentInfo` | `payMethodUserAccountType` (`EMAIL` / `PHONE_NO` / `ACCOUNT_ID`) with `payMethodUserAccountNo` |
| `payMethodProperties` | market-specific identifiers — `cpf`, `curp`, `nin`, `cc`, `ci`, `bankCode`, `accountNumber`, `receiveBank`, `cryptoChain`, `cardToken`, `paymentToken` |
| `extendInfo` | `refundAddress`, as a key inside the `extendInfo` JSON string |
## Roughly which method types ask for what?
Directional only — useful for sizing your data collection, **not** a contract. Let the
returned list decide. Requirements also vary by market for the same method type, which
is the other reason a static list does not hold.
| `payMethodType` | Commonly requested |
| - | - |
| `BNPL` | The largest set: the itemised basket (`goodsId`, `goodsName`, `goodsUniquePrice`, `goodsQuantity`, `skuName`, `goodsCategory`, `goodsUrl`) plus buyer name, phone, email and a full shipping address |
| `EWALLET` | Often nothing extra; some ask for buyer phone, some for the wallet account pair (`payMethodUserAccountType` + `payMethodUserAccountNo`) |
| `ONLINE_BANKING`, `DIGITAL_BANKING` | Buyer name and contact details; **refunds** on these rails commonly need bank account details |
| `VA`, `OTC` | A small set — typically buyer first and last name |
| `CREDITCARD`, `DEBITCARD` | Nothing from `goodsInfo`; in some markets a national identifier via `payMethodProperties` |
## Which of these are actually unconditional?
Do not spend effort on fields that are simply mandatory. On `/api/v1/order/create`,
`userInfo.userId`, `userInfo.userTerminal` and `orderDescription` are required for every
request. `userInfo.userEmail` is required in practice: with no real address collected,
send a unique fallback in the form `userId@examples.com` — never a shared or placeholder
value, which can trip risk controls. Inside `goodsInfo`, the contract expects **either**
`goodsUrl` **or** `appName` (not both) for compliance and risk review; the create call
does not reject their absence on its own — only a payment method's conditional rule
does, through the missing-field list above.
## Requirements driven by the scenario, not the method
Triggered by *how* you integrate, and checked before any method-specific rule runs:
| Scenario | Additionally required |
| - | - |
| Card data or `paymentTokenData` passed directly in the API | `userInfo.userBrowserIp` and `userInfo.userAgent`, plus the card fields — number, expiry month and year, CVV, cardholder name |
| `merchantInitiatedMode` set | `userPaymentAccessToken` |
| Agent payment in hosted mode | `successRedirectUrl` |
| Refunding a stablecoin order | `refundAddress` inside `extendInfo` |
## Do refunds use the same list?
No. Refunds run their own check and can ask for identity and payout details a payment
never needs, because the money often goes back by transfer rather than as a reversal on
the original payment — which is why the refund request accepts `userInfo` (including
`userBankInfo`), `addressInfo` and `extendInfo`. Same submit-and-read loop.
## A field is present but still rejected. Why?
Format rules apply to every field you send, whether or not it was requested:
| Field | Rule |
| - | - |
| Any string field | An empty string is normalised to absent — sending `""` is the same as omitting it, and will not satisfy a missing-field requirement |
| `userPhone` | `+CountryCode-Number`, e.g. `+62-08990013157` — must start with `+` and contain `-` |
| `userEmail` | Must be a valid email address — an invalid one is the one format failure that is **not** `A0003`: it comes back as `B0007 Invalid email format` |
| `goodsQuantity` | An integer, or a numeric string; anything else is rejected at parse time |
| `userBirthDay`, `userIDIssueDate`, `userIDExpiryDate` | `dd/MM/yyyy` |
| `addressCountryCode`, `nationality` | A recognised ISO country code |
| `userIDType` | Currently `ID` |
Length limits are separate, are listed per field in the acquiring specification, and
also produce `A0003` with the field named.
***
Still blocked after resending exactly what the response asked for?
[Contact support](mailto:support@waffo.com) with the `paymentRequestId` and the full
`A0003` message.
# Coupons & Promotion Codes
Source: https://waffo.com/docs/en/support/coupons-and-promotion-codes-faq
Waffo's discount engine end to end: percent-off vs amount-off coupons, Once / Repeating / Forever duration, single and batch promotion codes, redemption records, discount analytics, account-level caps, subscription trials, refund reversal, and the two API entry points.
Waffo has a first-party discount engine. You define **coupons** (what the discount is) and issue **promotion
codes** (the strings customers type, or that you distribute), then read redemptions and discount spend back out
of the Portal. It all lives under the **Marketing** menu: Coupons, Promotion Codes, Discount Analytics and
Coupon Settings. The feature is permission-gated — if the Marketing menu is visible in your Portal, it is
available on your account.
## How do I define the discount itself?
| Discount type | What you configure | How it applies |
| - | - | - |
| `PERCENT_OFF` | One integer percentage, 1–100 | Applies in any currency. 100 makes the order free |
| `AMOUNT_OFF` | A fixed amount **per currency** | Applies only in the currencies you configured |
A currency left unconfigured on an `AMOUNT_OFF` coupon is **not** eligible — an order in that currency is
refused rather than silently charged full price. Configure every currency you sell in.
Duration counts **billing cycles**, so it only bites on subscriptions; a one-time payment consumes the discount
once either way. `ONCE` — a single payment only, and a second attempt on the same discount is refused.
`REPEATING` — N cycles, where you also set **Duration in Cycles**. `FOREVER` — every cycle.
## What else do I fill in when creating a coupon?
| Field | Rules worth knowing |
| - | - |
| **Coupon Name** | Customer-facing, shown on invoices and receipts. Max 40 characters |
| **Max Redemptions** | Total redemptions across **all** promotion codes on this coupon. Supply a value — the create request expects one |
| **Daily Redemption Limit** | Optional. Per **customer**, per day. Needs a customer identifier on the payment |
| **Expiry Date** (`redeemBy`) | Optional. After this datetime the coupon can no longer be redeemed |
| **Category Tags** (`appliesTo`) | Optional. Only orders whose `goodsCategory` matches are eligible; empty means all |
| **Metadata** | Free key/value pairs for your own tracking, not shown to customers |
Discount type, discount value and duration are **immutable after creation** — the page warns you before you
submit, and afterwards only **Name** and **Metadata** are editable. **Deactivate** stops further redemptions and
is reversible; **Archive** is irreversible and also deactivates the coupon's promotion codes. Neither touches
discounts already applied. The list shows a lifecycle tag rather than a raw status: archived wins, then expired
(past `redeemBy`), then the coupon's own state.
## How do I issue promotion codes?
| | Single code | Batch |
| - | - | - |
| Code string | You type it. Alphanumeric only, auto-uppercased, unique on your account, **not editable afterwards** | Generated from a **prefix** (alphanumeric, up to 10 characters), e.g. `BFK2X9P`, `BFM7L3Q` |
| Quantity | 1 | 1–10,000 per batch |
| Restrictions | Max Redemptions for this code, expiry, First Time Only, per-currency Minimum Amount | The same set, applied to every code in the batch |
Batch creation is a four-step wizard — pick the coupon, set prefix and quantity, set restrictions, then a
**preview** returning sample codes and an estimated generation time before you commit. Generated codes are
permanent, so plan the prefix and quantity first. The batch runs asynchronously: **Batch Tasks** lists Task ID,
coupon, status (`PENDING` → `PROCESSING` → `SUCCESS` / `FAILED`), success count and creation time, and links
through to the codes a task produced. A code-level expiry cannot extend the coupon — the coupon's own `redeemBy`
is still checked separately, so the earlier of the two ends redemption.
## Why was a code refused at checkout?
Eligibility is decided before the amount is calculated, roughly in this order:
* Coupons are **disabled on your account**, or no coupon configuration exists yet.
* The coupon or the code is inactive, expired, or has exhausted its redemption stock; or the code does not
belong to the coupon passed in the same request.
* The order amount is below the code's **Minimum Amount** for that currency, or `AMOUNT_OFF` has no amount
configured for the order currency.
* A per-customer limit, a daily limit or **First Time Only** applies, but the request carried **no customer
identifier** — the discount is refused rather than applied unrestricted.
* **First Time Only** and the customer already has a completed redemption — scoped to your account as a whole,
not to this one coupon.
* A `ONCE` coupon was already used, or a `REPEATING` coupon has used up its cycles.
## Where do I see who redeemed what?
The coupon detail page has Overview, Promotion Codes and **Redemption History** tabs. Each redemption row
carries Record ID, promotion code, customer, source type and source ID, **Original / Discount / Final** amount
in the order currency, status and creation time. Filter by customer, source ID, status and date range, then
export the filtered view. Status runs `CREATED` (reserved, not yet final) → `COMPLETED` (confirmed — the only
state analytics counts), or `VOIDED` (the payment never completed, reservation released) / `REVERSED` (rolled
back after a refund).
## How is Discount Analytics calculated?
Three caveats decide whether the numbers match your own. It counts **completed redemptions only**, so `CREATED`,
`VOIDED` and `REVERSED` are excluded. Amounts are **summed across currencies with no conversion**, which is why
the cards and the performance table show bare numbers without a currency symbol — use the redemption export for
per-currency totals. And **Redemption Rate** = completed redemptions ÷ the summed Max Redemptions of the coupons
in scope, so coupons with no cap contribute 0 to the denominator.
Default range is the last 30 days. The trend chart buckets by each redemption's **confirmation** time in your
Portal time zone, and the Daily / Weekly / Monthly toggle re-buckets the same data. Coupon Performance splits
the period per coupon: Total Codes, Redemptions, Total Discount, Redemption Rate, Average Order Value.
## What do the account-level coupon settings do?
| Setting | Effect |
| - | - |
| **Enable Coupons** | Master switch. Off refuses every discount request, whatever the coupon says |
| **Coupon Scope** | `SELF_ONLY` or `PLATFORM_WIDE` — whether discounts are scoped to your own merchant only |
| **Max Active Coupons** | The concurrent-active-coupon ceiling recorded for the account |
| **Max Discount Percent** | Caps `PERCENT_OFF` at redemption: the effective percentage is the **lower** of the coupon's value and this cap. A 60% coupon under a 50% cap discounts 50% — quietly capped, not refused |
| **Refund Reversal Strategy** | See below |
## What happens to a discount on refund, and during a subscription trial?
Under the **Full Reversal** strategy, a **full refund** reverses the redemption — the record becomes `REVERSED`
and the slot returns to both the coupon and the code. A **partial refund** does **not** reverse it: the record
stays `COMPLETED` and the slot stays consumed. A payment that never completed releases the reservation as
`VOIDED` instead. Reversal returns only what was actually consumed, so a cycle that never received a real
discount cannot push a counter negative.
A zero-amount trial period **occupies that period's redemption slot** on the coupon and code, but does **not**
burn the discount's own lifecycle count. A `ONCE` coupon on a subscription with a free trial therefore still has
its single real discount available for the first paid cycle. Outside subscriptions, a zero-amount order with no
discount actually applied consumes nothing.
## How do I apply a discount through the API?
Order create carries a `promotionInfo` object with two mutually exclusive fields, each up to 64 characters.
`promotionInfo.promotionCode` is used by you or by the Waffo-hosted checkout page — the checkout discount input
accepts a **promotion code only**, so a customer can never type a coupon ID. `promotionInfo.couponId` is yours
alone, at order create, to apply a specific coupon with no code involved. Pass one or the other, never both; if
a code and a coupon ID both arrive, the code must belong to that coupon. Field-level rules for order create and
the checkout page's own behaviour are covered in the order-create field guide and the checkout page docs.
## What can I export from the Marketing pages?
Everything the Marketing pages manage is a Portal feature — create, edit, deactivate and archive coupons; create
single codes and code batches; browse redemptions and analytics — and there is no API-key-signed integration
endpoint for any of it. The only API touchpoint is `promotionInfo` on order create, above.
Four lists have an **Export** button: Coupons, Promotion Codes, Redemption History and Discount Analytics. Each
export runs as a task and hands you a CSV named `-.csv` — prefix `coupon`, `promotion-code`,
`promotion-record` or `promotion-analytics`. An export honours the filters on screen and covers up to 10,000
rows. Timestamps in lists and exports render in your Portal time zone; the Portal sends its offset with the
request.
***
Numbers not adding up? [Contact support](mailto:support@waffo.com) with the coupon ID, the promotion code, or
the redemption Record ID.
# Do I Have to Enable a Currency Before I Can Use It?
Source: https://waffo.com/docs/en/support/currency-and-method-enablement-faq
How currencies and payment methods become usable on a Waffo account: order currency versus user currency, where to read what you are enabled for, which error a currency you are not enabled for actually returns, whether enablement is per merchant ID or account-wide, whether one payment method can be priced locally in one market and in USD in another, and whether adding a method affects the ones already running.
Short answer: **there is no separate "enable this currency" switch.** A currency is usable when it
is on one of your payment-method agreements, so "is my currency enabled?" and "am I enabled for
this payment method?" are the same question asked twice. This page works through the consequences,
including the error code that catches everybody out.
Amount formats, decimal-place rules and the full currency list are in the published
[Currency handling](/docs/en/developer-docs/core-concepts/currency); the pricing strategies themselves in
[Pricing and currency](/docs/en/developer-docs/use-cases/pricing-currency). Which of an order's several
amount fields to verify, ship against and reconcile — and why `finalDealAmount` legitimately differs
from `orderAmount` — is in
[Which amount and currency fields do I check?](/docs/en/support/order-amount-and-currency-fields-faq).
What follows is about enablement, not fields or formatting.
## Which two currencies does an order have?
Every payin agreement carries both, and they do different jobs:
| Currency | What it is | Where you send it |
| - | - | - |
| **Order currency** | The currency you price and are accounted in | `orderCurrency` on order creation |
| **User currency** | The currency the payer is actually charged in | `userCurrency`, optional |
When the two are the same, the order is same-currency. When they differ, it is cross-currency and an
exchange rate is applied — see [Exchange Rates](/docs/en/support/exchange-rates-faq).
The distinction matters for enablement because **each is checked separately**: your agreement has to
carry the order currency, and — if you pin the user currency yourself — the pair has to be
contracted too.
## Where do I see which currencies I can use?
Three views of the same configuration:
| View | Shows |
| - | - |
| **Service → Payin** in the Portal | One row per contracted combination, with an `Order Currency` and a `User Currency` column |
| `POST /api/v1/paymethodconfig/inquiry` | The same set from code — one entry per contract, each with its `currency` and its `orderAmountMin` / `orderAmountMax` |
| Published [Payment Methods](/docs/en/essentials/payment-methods) | The payment currency each method is offered in generally — useful for planning, **not** a statement about your account |
Use the first two to answer "can I do this today", and only those two. Column-by-column reading is
in [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq); the
response fields in
[Payment Method Configuration & Limits](/docs/en/support/payment-method-config-and-limits-faq).
## Does a given payment method take USD, or only the local currency?
It depends on the row, not on the method. The same payment method can be contracted in more than
one order currency, and it is normal for a method to appear several times on Service → Payin — one
row per currency — with different limits on each.
So the question is answered per row: find the method, read its `Order Currency`, and if you intend
to pin what the payer is charged, read its `User Currency` too. Do not generalise from one market to
another, and do not cache the answer in your integration — read it from the inquiry call.
## I sent a currency I am not contracted for. Which error do I get?
**`A0018`** — *"Payment method in merchant order does not match with current merchant contract."*
Not a currency-specific code. This is the single most misleading thing about currency enablement:
the message talks about the payment method, but the rejection may be entirely about the currency.
Three currency-shaped ways to get `A0018`:
| What you sent | Why it fails |
| - | - |
| An `orderCurrency` no agreement of yours carries | The lookup matches the order currency exactly; nothing matches, so no agreement survives |
| A `userCurrency` that is not contracted for that payment method | The user-currency dimension is matched exactly as well |
| A cross-currency pair for which no rate can be applied | The agreement exists, but the order cannot be priced |
Reading `A0018` as "the payment method is wrong" sends you off to check the wrong thing. Treat it as
"**no agreement of mine matched this whole request**", then work through the dimensions — see
[When my order is rejected, what do I check?](/docs/en/support/order-rejection-self-diagnosis-faq).
## How do I avoid the currency-pair version of this?
**Omit `userCurrency`.** If you do not pin it, the checkout resolves what the payer is charged and
converts your order amount at the current rate, so you only need the order-currency half to be
contracted. Send `userCurrency` only when you have a reason to fix the payer-facing currency
yourself, and only for pairs you can see on Service → Payin.
Existing guidance on the same code from the payment-method side is in
[Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide); which currency the payer
ends up being charged, and why it can surprise you, is in
[Cashier Redirects & Action Types](/docs/en/support/cashier-redirect-and-action-type-faq).
## Can I price locally in one market and in USD in another?
Yes. Pricing lives on the agreement row, and rows are per country and per currency, so different
markets can be set up differently — local-currency pricing in some, a single global currency in
others — including for the same payment method. There is no account-wide setting that forces one
choice on all of your markets.
What you cannot do is decide it per request: you send an `orderCurrency` that one of your rows
carries. If a market is contracted only in its local currency, an order priced in USD for that
market is an `A0018`, and the fix is a contract change, not a parameter change.
The three pricing shapes and the parameters each implies are in the published
[Pricing and currency](/docs/en/developer-docs/use-cases/pricing-currency).
## Is the currency my money is settled in the same thing?
No — settlement currency is a third, separate dimension, configured on your settlement setup rather
than on the payin agreement. An order can be priced in one currency, charged to the payer in a
second, and settled to you in a third. See
[Settlement account structure](/docs/en/support/settlement-account-structure-faq) and
[Settlement calculation explained](/docs/en/support/settlement-calculation-explained).
## Is a payment method enabled for my whole account, or per merchant ID?
**Per merchant ID.** An agreement belongs to one merchant ID, and order creation looks up agreements
for the merchant ID you signed the request with. So enabling a method for one of your merchant IDs
does not enable it for another, and if you run several — one per business line, one per region —
name every merchant ID that needs it when you ask.
The practical consequences:
* The same request that works against one merchant ID can return `A0018` against another. Check
which merchant ID your API key belongs to before you suspect the payload.
* Service → Payin and the inquiry call always answer for one merchant ID at a time.
* If you got a parameter error naming your merchant ID, the mismatch is between the ID in the body
and the ID the key belongs to — see
[Payment Method Configuration & Limits](/docs/en/support/payment-method-config-and-limits-faq).
## Can I be enabled by region rather than country?
The dimension on an agreement row is a **country**, plus a distinct **Global** entry for the payment
methods that do not belong to any one country. There is no region unit in between, so "Europe" or
"Japan and Korea" is expressed as the set of countries you want, each becoming its own rows.
That is worth knowing when you plan a rollout: ask for a list of countries rather than a region
name, and expect the resulting Service → Payin page to have one row per country and currency
combination rather than one row per region.
## Will adding a payment method disturb the ones I am already running?
No, and no downtime is involved. Agreements are independent rows: adding one creates new rows and
leaves the status, limits and refund rules of your existing rows untouched. Live traffic on the
methods you already run is unaffected while the new one is being set up.
On your side, nothing has to change either — unless you want to name the new method explicitly. If
you let the checkout show whatever you are contracted for, a newly `Active` row simply starts
appearing; if you pass `payMethodName` / `payMethodType` yourself, you decide when to start sending
the new value. Where newly signed methods show up on the checkout is in
[Cashier Display Rules](/docs/en/support/cashier-display-rules-faq); how the request progresses in
[Payment Enrollment](/docs/en/support/payment-enrollment-faq).
## Are the limits and currencies I saw in the sandbox the same in production?
No — and nothing you read in the sandbox should be treated as a production value or as a quotation.
Read the production values from your production Portal, or from
`/api/v1/paymethodconfig/inquiry` against production credentials, and validate against those before
you create an order. Why the sandbox figures exist at all, and what they are and are not evidence
of, is in [Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq).
Fees are commercial terms and appear only on your own Payin page — they are not published anywhere.
# Limits, Currency & Configuration Errors
Source: https://waffo.com/docs/en/support/error-codes-and-limits-supplement
The order-creation rejections not covered elsewhere: A0008 vs A0020 vs A0021 amount limits, why an uncontracted currency returns A0018 rather than A0007, A0026/A0047 checkout language, A0024 expired FX rate (not A0023), A0014 cross-currency refunds that round to zero, A0022 ambiguous payment method — plus why limits do not move with exchange rates, why Settlements can be missing from your Portal, sandbox versus production availability, and recurring-payment rules.
[Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide) covers
the codes that generate the most tickets: `A0001`, `A0002`, `A0003`, `A0018` and
the `E`xxxxx family. This page covers the rest of the order-creation
rejections — limits, currency, language, FX and configuration — and then the
configuration questions that produce the same "why can't I…" tickets without
producing an error code at all.
## Which error code means which limit? I have three different ones
They are three different ceilings, checked at different levels. Reading them as
the same thing is the usual reason a merchant "fixes" the wrong setting.
| Code | Message | What it means | Where the limit lives |
| - | - | - | - |
| `A0008` | Payment amount should be within the range | The order amount is outside the min/max range of the **payment method** for that currency | Payment-method configuration |
| `A0020` | Payment amount exceeds transaction limit | The **single order** exceeds a per-transaction limit on your account for that order currency | Account-level limit, per order currency |
| `A0021` | Accumulative payment amount exceeds daily transaction limit | This order **plus everything already taken today** in that order currency exceeds a daily ceiling | Account-level limit, per order currency |
Three things follow:
* `A0020` and `A0021` are **per order currency**. Raising one currency's limit
does nothing for another.
* `A0021` depends on the volume already taken today, so the same request can
succeed in the morning and fail in the evening. Retrying immediately will not
help; retrying the next day will.
* `A0008` is a payment-method range, not an account limit. Where to read the
ranges is covered in
[Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide).
If either account-level limit is set too low for your business, it is a
configuration change — contact your Waffo integration contact.
## I sent a currency I am not contracted for. Do I get `A0007`?
No — you get **`A0018`** *"Payment method in merchant order does not match with
current merchant contract"*. There is no currency-specific rejection on the
ordinary order-creation path: the contract lookup matches on your merchant ID
**and** the order currency together, so a currency none of your agreements carry
simply leaves no agreement to match, and the failure is reported as a
payment-method mismatch. The message misleads; the cause is the currency.
`A0007` *"Transaction currency is not supported"* does exist in the error table,
but it is reserved for a specialised stablecoin-protocol payment path that only
accepts one settlement currency. If you are creating a normal order and see a
currency problem, the code you are debugging is `A0018`.
| Symptom | Code |
| - | - |
| The order currency is not contracted | `A0018` |
| The payment method, or the `orderCurrency` → `userCurrency` pair, is not contracted | `A0018` |
| The amount is outside the contracted range | `A0008` |
`/api/v1/paymethodconfig/inquiry` returns one entry per contract, each with its
own `currency` — match on the currency you intend to charge in before you build
the request. The currency-shaped ways to earn an `A0018` are worked through in
[Do I Have to Enable a Currency?](/docs/en/support/currency-and-method-enablement-faq).
## `A0026` and `A0047` — what is wrong with my `cashierLanguage`?
They separate "you asked for a language we do not have" from "that is not a
valid language tag".
| Code | Meaning | Typical cause |
| - | - | - |
| `A0026` | The checkout language you specified is not supported | A valid tag for a language Waffo does not serve |
| `A0047` | The checkout language format is invalid | Wrong syntax, or extra subtags |
The value must be an IETF BCP 47 tag in the form
`language[-script][-region]`:
* **language** — a two-letter ISO 639-1 code, and it must be one Waffo supports.
The supported set cannot be extended by passing something else.
* **script** — optional, four letters (ISO 15924). If present it must be a
script Waffo uses; `Hant` and `Hans` are the ones in service.
* **region** — optional, a two-letter ISO 3166-1 alpha-2 code. Any valid country
code is accepted here; the region is not restricted to a supported list.
Anything else is `A0047`: underscores instead of hyphens (`en_US`), variant
subtags (`de-DE-1996`), and private-use extensions (`en-US-x-foo`) are all
rejected as malformed rather than unsupported. Casing is not an error — `en-us`
is normalised to `en-US`.
## Cross-currency order rejected because of the exchange rate — which code is it?
**`A0024`** — "Order can not be processed due to the fx rate is expired."
> [!WARNING]
>
> `A0024` is the code, not `A0023`. `A0023` is **"Order cannot be created due to
> expired token"** — an entirely different failure, about a card token, with an
> entirely different fix. If you have integration notes that map an expired or
> worsened FX rate to `A0023`, they are wrong; correct them.
On a cross-currency order the quoted rate is validated at order creation. If the
quote is no longer valid — it expired, or the current rate has moved against the
quote — the order is **rejected outright**. Waffo does not silently reprice the
order at the new rate, because that would change what the user pays after you
committed to a price.
What to do:
1. Treat `A0024` as retryable. Re-create the order; it picks up a current quote.
2. Do not carry a quote across a long checkout funnel. The longer the gap
between quoting a price and creating the order, the more often you will see
this.
3. Show the user the refreshed amount if your UI displayed the local amount
before order creation.
How rates are quoted and for how long they hold is in
[Exchange Rates in Payments & Settlement](/docs/en/support/exchange-rates-faq).
## `A0023` vs `A0024` — a summary I can paste into my code comments
| Code | Message | Subject | Fix |
| - | - | - | - |
| `A0023` | Order cannot be created due to expired token | A card `tokenId` is no longer usable | Re-tokenize the card; see [Tokenization & Card Payments](/docs/en/support/tokenization-faq) |
| `A0024` | Order can not be processed due to the fx rate is expired | The FX quote on a cross-currency order | Re-create the order to pick up a current rate |
## My cross-currency partial refund was rejected with `A0014`. The amount looked fine
Check the amount **after conversion**. On a cross-currency order the refund
amount you send is converted before validation, and if the converted amount
comes out as **zero or less** the refund is rejected with `A0014` ("Order cannot
be refunded due to refund rules").
This bites in two situations:
* A very small partial refund on a currency pair with a large rate — the
converted amount rounds down to zero.
* A currency with fewer minor units than you assumed, so the converted amount
truncates away entirely.
Note what `A0014` does **not** cover: asking for more than is left. A refund
whose amount, added to everything already refunded or in progress, exceeds the
original payment is rejected as a plain parameter failure — `A0003 Parameter
validation failed` — not as a refund-rule breach. So on a cross-currency order,
read `A0014` as "the converted amount did not survive the refund rules" and
`A0003` as "you asked for too much"; in both cases check the figure after
conversion rather than the one you sent. The conversion rules are in
[Exchange Rates](/docs/en/support/exchange-rates-faq); the per-method refund rules
are in
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq).
## `A0022` "multiple products are found" — what am I supposed to specify?
The product. `A0022` means the payment method you asked for resolves to **more
than one contracted product** on your account — for example the same method
signed both for one-off payments and for subscriptions. Waffo will not guess
which one you meant, so the order is rejected instead of being created against
the wrong product.
The fix is to send `productName` in `paymentInfo` so exactly one contract
matches. See
[Checkout customization](/docs/en/developer-docs/integration/checkout/customization)
for the parameter combinations, including how `payMethodType`, `payMethodName`
and `payMethodCountry` narrow the match.
## Do my signed amount limits change when the exchange rate moves?
No. The minimum and maximum order amounts on your contract are **fixed values**.
They are calculated once, when the payment method is signed for your account,
and they do not move with the exchange rate afterwards — including on
cross-currency orders.
Two details worth knowing:
* When your pricing currency differs from the payment method's own currency, the
bounds are converted at signing time and then held: the minimum is rounded
**down** to the currency's minor units (never to zero — it floors at one minor
unit), and the maximum is rounded **up**.
* A limit you request for your account has to sit **inside** the range the system
calculated. Your range is a subset of it, never wider. If you ask for a wider
range, the configuration is rejected rather than clamped.
So a rate movement never explains an amount rejection. If an amount used to pass
and now fails, look at the account-level daily limit (`A0021`) or a
configuration change, not at FX. Where to read the values is in
[Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide).
## The payment failed because of a limit, but not one of mine. What is that?
Some wallet payment methods enforce limits on the **user's own wallet** — most
commonly an annual ceiling on how much the user may spend through it. When the
user hits it, the payment fails, and there is nothing you or Waffo can configure
to allow it.
How to handle it:
* Show the user a neutral message and offer another payment method rather than
retrying the same one.
* Do not count these against your own limits or your integration's health; they
are user-side.
* Do not attempt to split the order into smaller payments to get under the
ceiling — an annual ceiling is not evaded by splitting, and it degrades the
user's experience.
## Why can't I see Settlements, Fees or Settlement Cycle in my Portal?
Because of what you have signed. Parts of the Merchant Portal are driven by your
contracted products, not by your role — an Admin cannot grant them. See
[Merchant Portal Operations](/docs/en/support/portal-operations-faq) for the
menu-level table.
On top of the menus, merchants on a **gateway-only** arrangement — where the
signed product is Direct Payment and nothing else — also lose settlement-related
**columns**:
| Where | What is hidden |
| - | - |
| PayIn (payment-method configuration) | `Fees` and `Settlement Cycle` columns |
| Refunds list | `Settlement Status` column |
| Navigation | Settlements, Balance, Withdrawal and the related submenus |
This is expected rather than a defect: with no settlement product there is no
settlement cycle, no fee schedule to display, and no settlement status to report.
Nothing about it is switchable from your side. Order and refund data, exports and
webhooks are unaffected.
If you expected to see settlement data and do not, the question to ask support is
"which products is my account signed for", not "please grant me the menu".
## A payment method works in the sandbox but fails in production
That is possible by design, and it is the one sandbox difference that surprises
people the most. **The sandbox deliberately carries payment methods and
configurations that are not live in production**, so that integration and
end-to-end testing can start before a method is switched on for real traffic.
So:
* **Sandbox availability is not evidence of production availability.**
* Confirm production coverage against your contract — the Portal's PayIn page,
or `/api/v1/paymethodconfig/inquiry` against production — not against sandbox
behaviour.
* A method that returns `A0018` in production while working in the sandbox is
usually this, not an integration bug.
Other sandbox/production differences are in
[Sandbox Testing](/docs/en/support/sandbox-testing-faq).
## How does a user authorize a recurring WeChat Pay charge?
Through one of two flows, chosen by where the user is:
| User's context | Signing flow |
| - | - |
| Desktop web | The user scans a code to authorize |
| Mobile browser | The user authorizes in an H5 page |
Once the authorization exists, Waffo charges the recurring amounts against it
without further user action, the user or you can terminate the authorization, and
charged orders can be refunded in full or in part. Subscription mechanics
themselves — plan changes, failed deductions, retries, notifications — are in
the [Subscription FAQ](/docs/en/support/subscription-faq).
Only the two flows above exist. If you are building against a design that also
lists in-app, mini-program or in-client authorization, those are not available.
## Can a refund on the first payment stop a whole recurring series?
Yes, on card recurring series, and this is the failure mode most likely to
surprise you months later.
| What happens to the first payment of the series | Effect on the series |
| - | - |
| **Fully refunded** | The series **terminates** — no further recurring charges can be made against it |
| Referenced by a fraud or dispute transaction | The series **terminates** |
| **Partially refunded** | No effect — the series continues |
So a customer-service full refund of a first charge silently ends the customer's
subscription at the payment level, even though your own records still show it as
active. Two safeguards:
1. Prefer a partial refund where the goodwill amount allows it.
2. When you do fully refund a first charge, treat the series as ended and
re-establish the authorization if the customer stays.
## Do I need to tell the checkout what device the user is on?
No. The Waffo checkout detects the user's device and chooses the presentation
itself:
| User's device | What the checkout does |
| - | - |
| Mobile browser | Redirects into the payment app or page |
| Desktop browser | Renders a QR code for the user to scan |
You do not pass any device, terminal or authentication-mode parameter for this —
there is nothing to get wrong, and nothing to keep in sync when a payment method
changes its behaviour. Some app-based wallet methods take it one step further on
mobile: the checkout either deep-links straight into the specific app the user
picked, or hands off to the operating system's app chooser.
What you *do* control is which payment methods appear at all — see
[Checkout customization](/docs/en/developer-docs/integration/checkout/customization)
and [Checkout Display Rules](/docs/en/support/cashier-display-rules-faq).
## I charged a card without tokenizing it. Can I charge it again later?
No. A card payment made without a `tokenId` is a **one-off customer-initiated
transaction**. Nothing is retained that would let you charge that card again, so
the payment has no merchant-initiated follow-up capability.
To charge a card again later you must tokenize first: complete the tokenization
flow, hold a valid `tokenId`, and reference that token on the subsequent charges.
There is no way to convert a completed plain-card payment into a token after the
fact — the user has to go through binding.
The full token lifecycle, the CIT/MIT distinction and the
`AUTHORIZATION_REQUIRED` case are in
[Tokenization & Card Payments](/docs/en/support/tokenization-faq).
## Where do I look next?
| Question | Page |
| - | - |
| `A0001`, `A0002`, `A0003`, `A0018`, `E`xxxxx, webhook retries | [Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide) |
| Which rate a cross-currency order uses and for how long | [Exchange Rates in Payments & Settlement](/docs/en/support/exchange-rates-faq) |
| Per-method refund rules behind `A0017` and `A0014` | [Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq) |
| Portal menus, roles and why they differ between merchants | [Merchant Portal Operations](/docs/en/support/portal-operations-faq) |
| Sandbox credentials, simulators and test cards | [Sandbox Testing](/docs/en/support/sandbox-testing-faq) |
| Tokens, CIT and MIT | [Tokenization & Card Payments](/docs/en/support/tokenization-faq) |
# Common Errors Troubleshooting
Source: https://waffo.com/docs/en/support/errors-troubleshooting-guide
How to diagnose and fix the API errors merchants hit most often — invalid API key (A0001), signature failures (A0002), parameter validation (A0003), payment-method/contract mismatches (A0018), unknown-status errors (Exxxxx), and webhook notifications that keep retrying.
Every failed API call returns `code` and `msg` in the response body. The first letter of the code tells you who has to act:
| Prefix | Category | Who acts |
| - | - | - |
| `A`xxxxx | Merchant-related error | Fix your request or configuration, then resend |
| `B`xxxxx | User-related error | Guide the user (balance, timeout, authorization) |
| `C`xxxxx | System-related error | Transient on the Waffo side — retry later or contact support |
| `D`xxxxx | Risk-related rejection | Blocked by risk control; contact support if unexpected |
| `E`xxxxx | Unknown error | Keep querying until a final Success/Failed status |
The complete list is in the [error code quick reference](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes). This page covers the codes that generate the most support tickets.
## I get `A0001` "Invalid Api secret key" — where does the API key come from?
The `X-API-KEY` request header was missing or does not match a key issued to your account. The API key is provided by Waffo during merchant onboarding and is available in the Merchant Portal dashboard.
Common causes:
1. **Environment mismatch** — sandbox and production use **different API keys and RSA keys**. A sandbox key sent to `https://api.waffo.com` (or a production key sent to `https://api-sandbox.waffo.com`) fails with `A0001`.
2. The `X-API-KEY` header is missing from the request entirely.
Fix: copy the key for the target environment from the Merchant Portal and confirm the base URL matches that environment. See [Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox).
## How do I debug `A0002` "Invalid signature"?
Waffo verifies the `X-SIGNATURE` header against the **merchant public key you exchanged during onboarding**, using **SHA256WithRSA**. Verification failing means the signature you produced does not match the body Waffo received. Work through this checklist:
1. Sign the **raw request body string exactly as transmitted** — do not JSON-parse and re-stringify it, and do not sign a prettified or re-ordered copy.
2. Confirm you are signing with the **private key whose public key is registered with Waffo** for this environment (sandbox and production RSA keys are separate).
3. Confirm the algorithm is `SHA256WithRSA`.
4. If you generate a new key pair, the new public key must be registered with Waffo before you switch.
The reverse direction also matters: Waffo signs every response and webhook with its private key, and you verify with the Waffo public key (Merchant Portal → **Integration** menu). **If your verification of a Waffo response fails, stop sending new transactions and contact Waffo** — your original request may already have been processed even though you could not trust the response. Code examples: [webhook signature verification](/docs/en/developer-docs/webhook/signature-verification).
## What usually triggers `A0003` "Parameter validation failed"?
`A0003` is a request-format rejection. The `msg` field carries detail; the highest-frequency causes seen in production support are:
1. **Field length exceeded.** Every string field has a declared maximum — the ones merchants overflow most often on `/api/v1/order/create`:
| Field | Max length |
| - | - |
| `paymentRequestId` | String(32) |
| `merchantOrderId` | String(64) |
| `orderDescription` | String(128) |
| `userEmail` | String(64) |
| `userPhone` | String(16) |
| `goodsName` | String(64) |
| `goodsUrl` | String(128) |
| `appName` | String(32) |
| `notifyUrl` | String(256) |
2. **Amount format.** Amounts are strings with at most 2 decimal places — and **zero-decimal currencies such as `JPY` must be whole numbers**. A real production example: `"orderCurrency":"JPY","orderAmount":"200.88"` is rejected with `A0003`; `"201"` is accepted. See [currency handling](/docs/en/developer-docs/core-concepts/currency).
3. **Format conventions** — time is ISO 8601 with milliseconds (`2023-04-01T03:00:00.000Z`), currency is ISO 4217, country is ISO 3166-1 alpha-3 (`IDN`, `PHL`), and `userPhone` follows `+CountryCode-Number`, for example `+62-08990013157`.
Validate these locally before sending; the exact per-field contract lives in the [Acquiring API Specification](/docs/api-reference/introduction).
## What does `A0018` mean, and how do I fix it?
In the acquiring API, `A0018` is **"Payment method in merchant order does not match with current merchant contract"** — the `payMethod`/country/product combination in your order is not one you have signed for.
How to fix:
1. Call `/api/v1/paymethodconfig/inquiry`. The response lists every contracted pay method with its `productName` (`ONE_TIME_PAYMENT` / `DIRECT_PAYMENT`), `payMethodName`, `country`, and `currentStatus` (`1` available, `0` not available), plus any `fixedMaintenanceRules` windows.
2. Send only combinations that appear in that list with `currentStatus` `1`. Which methods you can sign depends on your contracted configuration — check the Merchant Portal or contact integration support to add one.
3. Two neighbouring codes look similar but ask for more precision, not a contract change: `A0022` (specify `productName` — multiple products found) and `A0033` (specify the pay method country — multiple pay methods found).
Note: the same code is reused in the **subscription** API, where it signals that no payment method is available for that user to continue the charge (for example, the user has no usable bound method left). The error text is the same; only the situation differs, so read it together with the API you called.
## My contract covers the payment method — why do I still get `A0018` when I pass `userCurrency`?
`A0018` also fires on the **currency-pair dimension**. The `orderCurrency` → `userCurrency` combination (the presentment pair) must be enabled in your contract, independently of the payment method itself. For example, an order with `orderCurrency=JPY` and `userCurrency=USD` is rejected with `A0018` unless the JPY→USD pair is contracted — even when card payments are.
Two ways out:
1. **Omit `userCurrency`.** The Waffo cashier then converts the order amount into the user's local currency automatically at the current exchange rate.
2. **Enable the pair.** Contact your Waffo integration support to add the currency pair to your contract if you need to fix the user-facing currency yourself.
## My order was rejected because of the amount. Where do I find the limits?
Each payment method has its own minimum and maximum order amount per currency, and some combinations carry precision rules on top of the usual currency rules (zero-decimal currencies must be whole numbers, and a few methods accept fewer decimal places than the currency normally allows). An amount outside the range or precision is rejected at order creation with an explicit error.
You can read the limits programmatically: `/api/v1/paymethodconfig/inquiry`
returns `orderAmountMin` and `orderAmountMax` for each contracted pay method,
alongside its `currency`. Note that the response has one entry per contract
rather than per pay method, so the same method can appear several times with
different currencies and different limits — match on the currency you are
charging in. The Merchant Portal shows the same configuration.
The limits are **contract-dependent fixed values**: they are set when the payment method is signed for your account and do not move with exchange rates — including for cross-currency orders. To find the values that apply to you, check the payment method configuration in the Merchant Portal, or ask your Waffo integration contact.
## I received `E0001` "Unknown status". Is the payment failed?
No — unknown is not final. `E`xxxxx errors mean the outcome is not yet determined inside Waffo or a third-party system. Treat the transaction as **pending** and keep querying (`/api/v1/order/inquiry`) until you receive a final Success/Failed status. Never show the user a failure or re-charge based on an `E` code alone.
When retrying the original request, reuse the same `paymentRequestId` — it is the idempotency key. Reusing it with **different** parameters fails with `A0011` "Idempotent param mismatch error". See [idempotency](/docs/en/developer-docs/core-concepts/idempotency).
## Waffo keeps sending me the same notification. Why?
Your endpoint is not acknowledging it. A recurring support case: a merchant's webhook endpoint replied with `Content-Type: application/octet-stream` and `{"message":"fail"}` — so Waffo kept redelivering the notification. To acknowledge:
1. Respond with `Content-Type: application/json`.
2. Respond with body `{"message":"success"}` after you have processed the event. Any `failed` / `unknown` reply, non-200 status, timeout, or unparseable body triggers the retry schedule — see [webhook retries](/docs/en/developer-docs/webhook/retry).
3. Make your handler idempotent: retries mean the same event can arrive more than once.
# Exchange Rates in Payments & Settlement
Source: https://waffo.com/docs/en/support/exchange-rates-faq
Which rate a cross-currency order uses, when it is quoted and re-quoted, why the local amount changes at midday UTC+8, where the applied rate appears in the API and the Portal, and how settlement and refunds each pick their rate.
[Amounts and currencies](/docs/en/developer-docs/core-concepts/currency) says Waffo converts
cross-currency orders automatically. This page is the mechanics behind that sentence: which rate,
quoted when, visible where, and what happens at settlement and on a refund.
## Which exchange rate does a cross-currency order use?
An order is cross-currency when your `orderCurrency` differs from the currency the customer actually
pays in. Waffo quotes a rate for that currency pair — order currency as the base, payment currency
as the quote — for the payment method being priced, then applies the markup agreed in your contract:
```
payable amount = orderAmount × base rate × (1 + contracted markup)
```
The result is then rounded **up** to what the payment currency and the payment method accept — some
methods take only whole multiples or a fixed number of decimals — and the rounding difference is
carried as a separate adjustment component rather than folded into the rate.
## Why is the local amount different in the morning and the afternoon?
Because the standard daily rate is not a calendar-day rate. Each rate window runs for a day but
rolls over at a **fixed time of day (UTC+8) that is not midnight**, so an order priced before the
switch and an order priced after it use different rates for the same currency pair even though they
fall on the same calendar date.
Two qualifiers: every quoted rate carries its own validity and expiry, so the daily rollover is the
normal refresh point rather than a guarantee that a rate cannot be superseded inside the window; and
your contracted markup is effective-dated in its own right, independently of the rate window.
## Is the rate locked, and for how long?
There is no rate-lock product to buy or enable. What exists is a snapshot:
* The rate is quoted **when the payable amount is priced**, and stored on the order together with a
rate reference and its expiry.
* If the payable amount is **recalculated at the cashier** — for example a promotion code changes the
order amount, or the shopper switches payment method — the FX is re-quoted at that moment. The
amount displayed when the shopper submits is the amount charged.
* A later **capture** on an authorised order reuses the order's stored rate. It is not re-quoted.
## Does Waffo have an exchange-rate query endpoint?
No. There is no rate-quote endpoint, no rate-lock endpoint, and no separate FX-locking service to
request access to. Do not build against one. Rates reach you as part of the order:
| You want | Use |
| - | - |
| The rate applied to one order | `orderAction.actionData.exchangeRate` on the create response, or `POST /api/v1/order/inquiry` |
| The amount the customer will pay | `paymentAmount` / `paymentCurrency` in the same `actionData`, or `finalDealAmount` on inquiry |
| Rates across many transactions | Portal settlement reports and exports, not an API call |
## There are two `exchangeRate` fields. Which is which?
| Field | Whose rate | Read it as |
| - | - | - |
| `orderAction.actionData.exchangeRate` | Waffo's applied rate for this order | Documented as `paymentAmount ÷ orderAmount` when order currency and payment currency differ |
| `payMethodInfo.exchangeRate` | The payment method's own rate during the customer's payment | Informational — what the wallet or bank used on its side, alongside `userPaymentAmount` / `userPaymentCurrency` |
Where a customer-paid service fee applies, it is returned separately as `userFeeAmount` /
`userFeeCurrency`. Account for it before treating any ratio of two amounts as a pure FX rate.
## Where do I see the rate in the Portal?
| Portal location | Field | Covers |
| - | - | - |
| Transaction detail | `Payment Amount` with its currency | The customer-facing amount; no rate field — the rate is implied against the order amount |
| Settlements → Estimation | `Forex Currency Pair`, `Est. Forex Rate` | The **estimated** conversion for orders not yet settled |
| Settlements → Settlement Batch | `Exchange rate`, shown as *original amount = target amount* | The conversion that actually executed, per order currency |
| Settlement reports | `Forex Currency Pair`, `Forex Rate` | Per transaction, in batch detail and customised exports |
Estimation is a menu-permission-driven page: if it appears in your Portal navigation, it is available
to your account. Its figures carry an `Est.` prefix for a reason — computed before settlement runs,
they can still move.
## Is my settlement converted at the order's rate?
No. Settlement conversion is a **separate event** with its own rate, applied when the settlement is
processed — not the rate stored on the order. That is why `Settlement Original Amount` (netted in the
order currency) and `Settlement Target Amount` (the same money after conversion) sit in different
currencies on the batch, and why the target amount is empty until conversion completes.
`Forex Currency Pair` also appears on settlement rows where your order currency already matches your
settlement currency, because fees and taxes can be billed in a third currency. The pair is resolved by
a fixed priority order, and `Forex Rate` is empty whenever the pair is empty — both are set out in
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
## Which rate does a refund use?
Always the **original order's stored rate**, never a fresh quote. A rate move between the payment and
the refund does not change the local amount the customer gets back.
| Refund | How the customer-facing amount is derived |
| - | - |
| Full refund | From the original payable amount, with the customer-paid fee and tax components removed |
| Partial refund | The refunded order amount converted proportionally at the order's FX-only rate |
Refund amounts are rounded **down** to the payment currency's precision — the opposite direction from
order pricing — so a refund can never exceed the original payable amount, and one whose converted
amount rounds to zero is rejected rather than sent. The rounding rules across several partial refunds
are in the [Refund FAQ](/docs/en/support/refund-faq).
***
Reconciling a specific figure? [Contact support](mailto:support@waffo.com) with the order id — the
applied rate, the payable amount and the rounding adjustment are all stored against it.
# Exports & Report Downloads
Source: https://waffo.com/docs/en/support/export-center-faq
Portal list exports are background tasks, not instant downloads: where the finished file appears, what the four task statuses mean, why Failed Count does not mean missing rows, when a download link expires, and the date-span and row limits that block an export before it starts.
Most Portal list exports do **not** stream a file to your browser. Pressing **Export** queues a
background task; the finished file is collected later from a **Reports** list in the same module.
Column definitions for the exported files live in
[Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports).
## I clicked Export and nothing downloaded. Where is my file?
The confirmation dialog says the report *is being exported* and to come back to the Reports tab.
That tab is where the task and its file live:
| You exported from | Collect the file at |
| - | - |
| Transactions → **Payments** | Transactions → **Reports** (file type `Payments`) |
| Transactions → **Refunds** | Transactions → **Reports** (file type `Refunds`) |
| Subscriptions list | Subscriptions → **Reports** (file type `Subscriptions`) |
| Payout → History → **Single Payouts** | Payout → History → **Reports** (`Single Payouts`) |
| Payout → History → **Batch Payouts** | Payout → History → **Reports** (`Batch Payouts`) |
Reports lists default to the **last month of export dates** (UTC+8), so an older task needs the date
filter widened. Each row shows Export Date, Status, File Type, Create Time, Success Time and the file
name; Transactions rows additionally show **Failed Count**. Where a Reports tab is visible in your
Portal, exporting from that module is available to you — the tab and the Export button ship together.
## What do the four export statuses mean?
| Status | Meaning | Terminal? | What you do |
| - | - | - | - |
| **Pending Export** | Task accepted, file not built yet | No | Wait |
| **Retry Needed** | An attempt failed; the task is queued to run again | No | Nothing — see below |
| **Exported** | File built and ready | Yes | Click the file name |
| **Export Failed** | Automatic retries did not succeed; this task will not produce a file | Yes | Narrow the filters and export again |
Only **Exported** rows have a downloadable file. On the Payout Reports tab, non-`Exported` rows show
`-` in place of the file name.
## The status says Retry Needed. Do I have to do anything?
No. **Retry Needed** is an automatic state, not an instruction. The task stored the filters you
submitted, and a background job picks the row up again after a short cooldown and re-runs it with
exactly those filters, until it reaches **Exported** or **Export Failed**.
Pressing **Export** again for the same module while a row sits in **Retry Needed** *resumes that same
task* rather than creating a second one, so you will not end up with duplicate files.
## Failed Count is not 0. Is my exported data incomplete?
No. **Failed Count** counts *failed build attempts for the task*, not skipped or dropped rows. A row
with status **Exported** and `Failed Count = 2` means the file was produced on a later attempt and is
complete for the filters you submitted. A run that cannot finish leaves the row in **Retry Needed** or
**Export Failed** instead — partial data is never published under **Exported**.
## Does the download link expire?
Yes, and that is by design: no link is stored in the row. Clicking the file name mints a fresh,
short-lived link at that moment and opens it. If a link you
copied earlier stops working, go back to the Reports row and click the file name again — the task row
stays in the list, so the file can be collected again later.
## Why was my export rejected before a task was even created?
Four checks run at submit time, each with its own message:
| Message | Cause | Fix |
| - | - | - |
| *The current query data is empty* | Zero matching records — no task is created (Subscriptions) | Adjust the filters |
| *The previous export is still in progress* | A **Pending Export** row already exists for that report type | Wait for it to finish |
| *Too many records to export* | The result set exceeds the single-export record cap | Narrow the date range or filters |
| *Time frame should not exceed one month* | Settlement Estimation export span | Split into shorter ranges |
The in-progress check is **per report type**, not per account: a Payments export in flight does not
block a Refunds export, and vice versa.
For Payments and Refunds an empty result still produces a header-only file, so "no matching
transactions" stays distinguishable from a failed export. The Subscriptions export is the one that
refuses upfront instead.
## What are the export span and size limits?
* **Date span: 31 days.** Enforced both by the date picker and again on the server for Payments,
Refunds and Payout history exports, so a hand-built wider range is still rejected. Settlement
Estimation exports cap at one month.
* **Rows per file: 60,000.** Within the cap you get one `.csv`. Above it, the task splits into
numbered files (`..._001.csv`, `..._002.csv`, …) delivered as a single `.zip` — one Reports row,
one download, several files inside.
* **File naming:** `{report type}_{MID}_{from}_{to}.csv`, both dates `YYYYMMDD` in **UTC+8** —
for example `payments_{MID}_20260801_20260827.csv`. Payout files use the report label
(`Single Payouts`, `Batch Payouts`) as the prefix.
## Which exports are asynchronous, and which download immediately?
| Export | Model |
| - | - |
| Payments, Refunds, Subscriptions, Payout single & batch history | Background task → module **Reports** tab |
| Settlement batch details, Customized Export, Estimation | Background task → Settlements → Reports → export history |
| Batch Refunds list, settlement calendar | Direct download in the browser |
| Payout → **Report** | Not on demand — periodic files listed for download |
Settlement exports are listed with a **three**-state history (`Pending` / `Success` / `Failed`)
rather than the four states above; there is no Retry Needed or Failed Count there.
## Does the Reports list refresh on its own?
The Subscriptions Reports tab polls while any row is still **Pending Export** or **Retry Needed**,
and stops once every row is terminal. The Transactions and Payout Reports tabs do not poll — re-apply
the date filter, or revisit the tab, to pull fresh statuses.
***
Still missing a file? [Contact support](mailto:support@waffo.com) with the module, the export date and
the file name shown on the Reports row.
# Billing for Failed Card Transactions
Source: https://waffo.com/docs/en/support/failed-transaction-billing-faq
Why a card payment that never succeeded can still appear on your settlement bill: which failure stages are billable, how the Event Type column works, why failed rows carry no order amount, and how to find and export these transactions in the Merchant Portal.
Most merchants meet this topic through a support ticket that starts *"there is a
fee on my bill for a transaction that failed"*. That is expected behaviour under
some contracts, and this page explains when and how.
For how each amount on a settlement row is computed, see
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
For the reports themselves, see
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
## Can I be charged for a card transaction that failed?
Yes — if your contract has a fee configured for the failure in question.
Billing for failed transactions is a **per-contract, per-payment-method setting**,
not a platform-wide policy. Waffo looks up a fee for the specific failure stage
against your agreement at settlement time:
* **A fee is configured** → the failed attempt enters settlement and is billed.
* **No fee is configured** → nothing is created and nothing is billed. The
transaction never appears as a settlement row.
So two merchants can have identical failed transactions and only one of them sees
a charge. If you are unsure which applies to you, check whether your settlement
detail contains any rows with an `Event Type` (see below) — if it does not, you
are not being billed for failures.
Two further scope limits apply regardless of your fee configuration:
| Limit | Detail |
| - | - |
| Transaction type | Only `PAYMENT` transactions. Refunds and chargebacks have no failure-stage billing. |
| Failure location | Only failures that happened in the **interaction with the payment network**. A request rejected by Waffo's own validation before it ever reached the network is not billable and carries no failure stage. |
## Which failure stages are billable, and what is the difference?
There are exactly two:
| Stage | What failed | Typically |
| - | - | - |
| `AUTHENTICATION_FAILURE` | The cardholder authentication step | 3-D Secure did not complete or did not match |
| `AUTHORIZATION_FAILURE` | The authorization step, after authentication had passed | The card was refused, expired, or the attempt errored |
They are separate fee items in a contract. You can be billed for one and not the
other, at different amounts. The distinction matters commercially, because the two
stages carry different costs upstream: an authentication failure is stopped
earlier than an authorization failure.
Note the ordering implication — an attempt that fails authorization has already
been through authentication successfully, so it is billed as
`AUTHORIZATION_FAILURE` only. A single attempt never produces two failure fees.
## How do I tell which of my transactions were billed as failures?
By the **`Event Type`** column. It sits immediately after `Transaction Type` and
appears in your settlement detail export, your settlement estimation export, and
the Portal list.
| `Event Type` value | Meaning |
| - | - |
| `PAY_SUCCESS` | A normal successful payment |
| `AUTHENTICATION_FAILURE` | Billed failure at the authentication stage |
| `AUTHORIZATION_FAILURE` | Billed failure at the authorization stage |
Filter or sort on this column and you have your complete list of failure-billed
transactions for the period. There is no separate report to request.
## Why do failed rows have no order amount?
Because there was no order. Nothing was collected, so there is no amount to
report — reporting one would imply money that never moved.
On a row whose `Event Type` is `AUTHENTICATION_FAILURE` or
`AUTHORIZATION_FAILURE`, these three columns are **deliberately blank**:
* `Order Amount`
* `Order Currency`
* `Order Amount (Settlement Currency)`
What the row *does* carry is the fee side:
| Report | Columns populated on a failed row |
| - | - |
| Settlement detail | `Fee Amount`, `Fee Currency`, `Fee Amount (Settlement Currency)`, `Settlement Amount` |
| Settlement estimation | `Est Fee Amount`, `Est. Tax Amount`, `Tax Currency`, `Est Settlement Amount` |
The `Settlement Amount` on such a row is therefore **negative** — it is a fee with
no offsetting order amount. This is the correct reading, not a data error, and it
is one reason a settlement period can net out lower than your successful-payment
total suggests.
If you reconcile by summing `Order Amount` and comparing against
`Settlement Amount`, failure rows will look like an unexplained gap. Reconcile by
summing the fee columns as well.
## Is the failure fee a percentage of anything?
No. A failure fee is a **fixed amount** — there is no order amount for a
percentage to apply to.
One constraint follows from this and occasionally surprises people: the currency
of a failure fee **must be the same as the currency of your payment fee** on that
payment method. A failure fee cannot be billed in a different currency from the
successful-payment fee it sits alongside.
## My customer retried and the payment eventually succeeded. Was I billed for the failures too?
Each **attempt** is recorded and evaluated separately. A successful payment
preceded by failed attempts produces a successful-payment row for the attempt that
worked, and — if your contract prices that failure stage — a failure row for each
attempt that did not.
So the answer depends on your contract, but the structure is unambiguous: the
eventual success does not retroactively cancel the earlier failures. They are
separate events on the same order, each carrying its own sequence number, and they
are deduplicated per attempt rather than per order.
This is the single biggest driver of unexpected failure-fee totals. A checkout
flow that encourages rapid re-tries — or a card-on-file retry schedule that fires
several attempts in short succession — multiplies the failure count for one
eventual sale. If failure billing applies to you, the cost of a retry is not zero,
and retry strategy becomes a commercial decision rather than purely a conversion
one.
## Can I see failure fees before the settlement date?
Yes, in your **settlement estimation** data, which carries the same `Event Type`
column. Failed attempts appear there as soon as they have been picked up, with
their estimated fee, so you can accrue for them rather than discovering them on the
settlement date.
The same amount-suppression rule applies: an estimation row for a failed attempt
has no order amount, and shows only `Est Fee Amount`, `Est. Tax Amount`,
`Tax Currency` and `Est Settlement Amount`. See
[Fee & Tax Treatment](/docs/en/support/fee-and-tax-treatment-faq) for the estimation
export's limits and how its figures are estimated.
## What can I do to reduce failure fees?
Failure fees are driven by attempt volume, so the levers are all upstream of
Waffo:
| Lever | Why it helps |
| - | - |
| Validate the card before submitting | Card number, expiry and CVV format errors caught client-side never reach the network, so they are never billable |
| Cap re-tries per order | Each attempt is separately billable, so an unbounded "try again" button converts a single lost sale into several fees |
| Space out retries on stored cards | Immediate re-tries against a card that has just refused rarely change outcome, and each one counts |
| Watch your authentication failure rate specifically | A high `AUTHENTICATION_FAILURE` share often points at a fixable integration or user-experience problem rather than genuine declines |
Use the `Event Type` breakdown described above to see which of the two stages
dominates. The two have different causes and different remedies, and the split is
usually lopsided.
## Where in the Merchant Portal do I find these transactions?
Under **Settlements → Settleable Transactions**. It lists settleable transactions
at the individual-transaction level, including the failure-billed ones, and it is
the practical starting point for any "what am I being charged for" question.
Filters available:
| Filter | Notes |
| - | - |
| Order Id / Merchant Order Id / Payment Request Id / Merchant User Id | Combined search — pick the identifier type, then enter the value |
| Sub Merchant Id | Only shown if you have sub-merchants |
| Order Currency | |
| Transaction Type | `PAYMENT` / `REFUND` / `CHARGEBACK` / `CHARGEBACK_ALERT` |
| Settlement Status | `UNSETTLED` / `SETTLED` |
| **Event Type** | `PAY_SUCCESS` / `AUTHENTICATION_FAILURE` / `AUTHORIZATION_FAILURE` |
| **Payment Date** | Date range, **capped at 31 days** — the picker will not let you select a wider span |
`Sub Merchant Id` is also the first column of the result table.
The list supports **export**. The exported file follows the same field layout as
the list, and finished files appear in the download list on the same page rather
than downloading immediately — see
[Exports & Report Downloads](/docs/en/support/export-center-faq) for how export tasks
behave.
## Can I get the failure stage back on the payment API?
Not as a dedicated field. The order APIs return `orderFailedReason` for a failed
order, which tells you *why* it failed; they do not return a separate
authentication-versus-authorization stage marker.
Where you see the stage is **downstream, in settlement data**: as the `Event Type`
column in the reports and Portal list described above. If your reconciliation
needs to attribute a failure fee to a specific attempt, join on the order
identifier from the settlement detail rather than expecting the stage on the
payment response.
## I can see the failure in my transaction list but nothing in my settlement. Is something missing?
No — that is the expected result whenever the failure is not billable to you. The
transaction list and your settlement data answer different questions:
| Where you look | What it shows |
| - | - |
| Transaction list | Every attempt, successful or not, because it is a record of activity |
| Settlement data | Only what affects money — successful transactions, and failures **your contract prices** |
So a failure visible in your transaction list and absent from settlement means one
of three things, all of them normal:
1. **No failure fee is configured** for that stage and payment method on your
contract. Nothing is billed, so nothing is created.
2. **The failure was not a network-interaction failure** — it was rejected by
validation before reaching the network, and carries no failure stage.
3. **The transaction has not been picked up for settlement yet.** Check the
settlement estimation data before concluding it is absent.
The reverse — a settlement row you cannot find in your transaction list — would be
a genuine discrepancy worth raising. A failure without a settlement row is not.
## Does a failed transaction affect anything other than the fee?
Only the fee line. A failure-billed attempt:
* does not create an order amount, so it does not enter your `Order Amount`
totals;
* does not create a payment fee, VAT or WHT on the order amount — the failure fee
is the whole charge;
* does contribute to your settlement amount, negatively, as described above;
* settles on the normal settlement date for the transaction date, like any other
settleable transaction.
# Fee & Tax Treatment
Source: https://waffo.com/docs/en/support/fee-and-tax-treatment-faq
How Waffo decides which fee terms apply to a card transaction, what the three tax-bearer modes mean and who pays in each, how disputes and chargebacks differ in their effect on your money, and the limits on chargeback and settlement-estimation exports.
This page covers the parts of your fee and tax treatment that are **decided by the
transaction, not by your rate card** — which of your contracted terms a given card
payment falls under, who bears a transaction tax, and how disputes and chargebacks
land in your settlement.
Commercial terms themselves — your rates, minimums and caps — are in your
contract; your Waffo account manager is the right contact for those. For how a
rate becomes an amount, see
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
## Why do two identical card payments carry different fees?
Because card payments are classified as **domestic** or **cross-border**, and your
contract can price the two differently.
The classification is derived from the **card BIN** — the leading digits of the
card number, which identify the issuing bank and therefore the issuing country:
| Condition | Classification |
| - | - |
| The card's issuing country matches the country of the Waffo entity processing that card arrangement | `DOMESTIC` |
| The issuing country is a different country | `CROSSBORDER` |
| The BIN cannot be resolved, or the issuing country cannot be determined | `CROSSBORDER` |
| The BIN resolves ambiguously to more than one issuing country | `CROSSBORDER` |
So `CROSSBORDER` is the fallback in every uncertain case. This is deliberate and
conservative, and it has a practical consequence: **an unrecognised card is priced
as cross-border**, which is normally the more expensive of the two. New or
recently issued BINs are the usual reason a payment you expected to be domestic
was not.
The classification is decided at payment time, recorded on the transaction, and
then used at settlement to pick the matching fee terms. If your contract happens
to have no terms for the classification a transaction was given, the cross-border
terms are applied.
Two things this classification is **not**:
* It is not the cardholder's country of residence, or the billing address country
— only the issuing institution's country.
* It is not your own country. The comparison is against the Waffo entity handling
that card arrangement, which is why the same card can be domestic under one card
arrangement and cross-border under another.
## Who pays a transaction tax — me or my customer?
There are exactly three possibilities, set per contract and per payment method:
| Mode | Who bears the tax | What the customer is charged | Effect on you |
| - | - | - | - |
| **User-borne** | Your customer | The order amount **plus** the tax, computed at the contracted rate | None — the tax is not deducted from your settlement |
| **Merchant-borne** | You | The order amount, unchanged. The tax is **not shown to the payer at checkout** | The tax is deducted from your settlement amount |
| **Not applicable** | Nobody | The order amount, unchanged | None — no tax is computed |
The mode is recorded on each order when it is created, so it is fixed for that
transaction and does not change retroactively if your contract changes.
Two consequences worth planning around:
* **Under user-borne, the amount collected exceeds your order amount.** The
customer sees and pays the higher figure. Your order amount, and therefore your
settlement base, is unaffected.
* **Under merchant-borne, the customer sees nothing.** The tax is deliberately
suppressed from the checkout display, because showing the payer a tax they are
not paying is misleading. It surfaces only in your settlement data.
The applicable rate always comes from configuration — your contract terms for
settlement-time taxes, or the arrangement-level configuration for taxes collected
at checkout. **No tax rate is hardcoded**, which is why the correct answer to
"what rate applies to me" is always your contracted rate rather than a number in
documentation.
## Is a merchant-borne tax broken out as its own column in my settlement file?
No. Settlement files carry **generic tax columns**, whoever bears the tax:
| Column | Meaning |
| - | - |
| `Tax Amount` | The tax on that transaction, in the tax currency |
| `Tax Currency` | Its currency, as a 3-letter ISO 4217 code |
| `Tax Amount (Settlement Currency)` | The same tax converted into your settlement currency |
There is no tax-type-specific column — no separate column naming a particular
country's transaction tax — and no column that splits merchant-borne from
user-borne tax. If you need a specific tax presented as its own column, or need
the pre-tax order amount alongside the collected amount, that is a **custom
report change**: raise it with your Waffo account manager rather than expecting it
in the standard files.
Your settlement statement also carries a tax total. Note that where a transaction
tax is not invoiceable in its jurisdiction, it will appear in your settlement data
but **not** on the invoice.
## What is the difference between a dispute and a chargeback, for my money?
The two are distinct record types, and the difference that matters for
reconciliation is whether funds move:
| | Dispute | Chargeback |
| - | - | - |
| What it is | A business record of a customer's complaint, with its context | A reversal of the payment |
| Moves money | **No.** It books no amount and no fee against you | **Yes.** An amount and a fee are booked |
| May lead to | A refund, if you resolve it that way | A settlement debit |
| Appears in settlement | No | Yes, as a `CHARGEBACK` transaction |
So a dispute appearing in your Portal is not a debit and should not be accrued as
one; a chargeback is. For case handling, deadlines, evidence and status flow, see
[Chargeback & Dispute FAQ](/docs/en/support/chargeback-faq).
## The dispute amount in my Portal does not match the amount the bank reversed. Why?
Because you are shown the amount **in your own order currency**, converted for
you, rather than the raw figure from the reversing side.
A dispute is raised against the payment as it was processed, which for a
cross-currency payment is in a different currency from your order. Rather than
show you a figure in a currency you never transacted in, the Portal shows
**`Dispute Amount`, populated with the merchant dispute amount** — the same
dispute expressed in your order currency.
The conversion is deliberately **rounded down** when apportioning against a
partially refunded order, so that the amount attributed to you never exceeds what
you could actually still be liable for. Two checks are applied when a case is
created: the amount claimed cannot exceed what remains disputable on the order,
and the amount attributed to you cannot exceed your own remaining refundable
amount.
The practical implication: on an order you have partially refunded, the dispute
amount attributed to you is **less than** the full disputed amount, pro-rated
against what you had not yet refunded. Reconciling a chargeback against the
original order amount will not tie out on a partially refunded order — reconcile
against the merchant dispute amount.
The related fee field is labelled `Dispute Fee Amount` with its currency stated in
the label, so it is unambiguous which currency the fee is in.
## What currency is my chargeback fee in, and does it need an exchange rate?
Your chargeback fee currency is a **contract term**, configured per merchant
account alongside the fee itself. It is not derived from the order and it is not
fixed to any particular currency by the platform.
Where your chargeback fee currency **differs from your settlement currency**, the
fee has to be converted, so settlement needs a `SettlementCurrency/FeeCurrency`
rate for the period. This is worth knowing because it explains a `Forex Currency
Pair` appearing on a chargeback row whose order currency already matched your
settlement currency — see
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained)
for how the pair is chosen.
Two related rules:
* A chargeback fee is taken **as reported for that case**, not recalculated from a
percentage. It will never reconcile to a rate × amount formula.
* All chargeback fees within one settlement batch must share a single fee
currency; the batch is validated for this. A mismatch is a configuration error,
not something you need to reconcile around.
## Which exchange rate is used when a chargeback settles?
The **original payment's settlement rate**, not the rate of the day the chargeback
settles.
This is a rate-protection rule: reversing a payment at a different rate from the
one it settled at would expose you to FX movement on a transaction you have
already reconciled. By reusing the original rate, the chargeback reverses the
same settlement-currency amount that the payment produced.
One consequence follows directly: **a chargeback cannot settle before its original
payment has settled**, because the rate it needs does not exist yet. A chargeback
on a very recent payment waits for that payment's settlement date.
For the chargeback *fee* leg, where a rate for the fee currency pair already
exists in that settlement batch, that same rate is reused — so the fee and the
amount on one chargeback are converted consistently.
## How do I export my chargebacks?
From the **Chargebacks** tab in the Portal, which lists chargeback cases with
their identifiers, type, original order id, dispute amount, reason code, dispute
status, settlement status, and created and expiry timestamps.
| Constraint | Detail |
| - | - |
| Date span | **31 days maximum.** A wider range is refused before the export starts |
| Scope | Chargebacks only — the export covers this tab, not other dispute types |
| Timestamps | Date parameters are `YYYYMMDD` in UTC+8 |
## What are the limits on the settlement estimation export?
Settlement estimation lets you see fee, tax and settlement figures for
transactions that have **not settled yet**, so you can accrue before the
settlement date.
| Constraint | Detail |
| - | - |
| Date span | **31 days maximum**, on payment date. A wider range is refused with a message rather than being truncated |
| Searchable by | Payment date range, order id, merchant order id |
| Large results | Split across multiple files at **60,000 rows per file** and delivered as a single archive |
| Delivery | An export task, not an instant download — see [Exports & Report Downloads](/docs/en/support/export-center-faq) |
Estimated figures are estimates, and the reason is the exchange rate: an unsettled
transaction has no settlement rate yet, because that rate is not fixed until
settlement. Estimation therefore uses:
1. The rate from your **most recent successfully settled** data for the same
merchant account, country and currency pair; failing that,
2. A market reference rate.
So your estimate is as good as the recency of your last settlement in that
currency pair. Expect estimated and actual figures to differ by the FX movement
between the two — that difference is not an error, and the actual figures replace
the estimates once the settlement runs.
## Which order and settlement currencies can I be set up for?
There is **no fixed list**, and in particular your settlement currency options are
not limited to your local currency plus one major currency.
The currencies you can transact and settle in are determined by your contract and
returned dynamically, so the options you see reflect what is actually available
for your region and arrangements rather than a static rule. Where a foreign
currency is enabled for ordering, it carries its own minimum and maximum order
amounts.
If a currency you need is not available to you, it is a contract question rather
than a platform limitation — ask your Waffo account manager.
# Self-Serve Sign-up, Sandbox & Account Verification
Source: https://waffo.com/docs/en/support/getting-started-self-serve-faq
The path a self-registered merchant actually walks: create an account, verify your email, answer the company questions, shape a sandbox through the six-step setup wizard, collect your sandbox credentials, then complete the seven-step account verification — including what to do when an application is returned.
[Quickstart](/docs/en/developer-docs/getting-started/quickstart) begins where you already hold credentials.
This page covers everything before that: what the Portal asks a self-registered merchant for, in what
order. Self-serve sign-up and the sandbox setup wizard are enabled per account — **if you can see the
screen in the Portal, it is available to you**; where an entry point is not shown, ask
[support@waffo.com](mailto:support@waffo.com) to open it.
## What is the whole path from sign-up to live payments?
| Stage | Where | What it produces |
| - | - | - |
| Sign up | `/auth/sign-up` | An account, email not yet verified |
| Verify email | `/auth/sign-up/verify-email` | A usable login |
| Company basics | `/auth/get-started` | A first draft record; sandbox provisioning starts |
| Sandbox Setup wizard | `/sandbox-setup/step/1` … `/step/6` | A configured sandbox and API credentials |
| Account verification | `/verify-account` | A submitted verification application |
| Go live | after verification is approved | Ability to accept real payments |
The Home page carries a progress panel mirroring this list, so you can leave and resume anywhere.
## How do I create the account and verify my email?
Sign-up takes an email address and a password; the strength indicator checks for upper case, lower case,
a digit and a minimum length. Google sign-in is offered as an alternative. Verification then arrives in
one of two shapes, depending on how the account was created: **a 6-digit code** you type into the
verification screen (paste works — the six boxes fill themselves), or **a verification link** you click
in the email. A countdown runs before **Resend** becomes available again; if the code never arrives,
resend once the countdown clears rather than signing up again — a second sign-up starts a new account.
The **Get started** screen that follows asks only for your **company legal name** and **incorporation
country/region** — enough to create your first record and begin preparing a sandbox. It reappears
pre-filled later in account verification.
## What do the six Sandbox Setup steps ask for?
| Step | Screen | What you supply |
| - | - | - |
| 1 | Tell us about your business | Legal company name, official website (optional), primary business type, incorporation country |
| 2 | Select your Waffo products | One-time payment, subscription payment, or both |
| 3 | Choose pricing strategy | Global USD pricing, or local currency pricing |
| 4 | Select your target markets | Countries/regions grouped by region, plus a **Global** option; drag-select and per-region select-all are supported |
| 5 | Manage your payment methods | Enable/disable each generated row and pick its order currencies |
| 6 | Sandbox Ready | Nothing — the wizard waits for provisioning, then hands you your next steps |
Steps are gated by progress: the wizard redirects you back to the furthest step you actually completed,
so you cannot deep-link past unanswered questions. **Business type** is a single-select list of
categories (AI/SaaS, gaming, e-commerce, streaming, education, travel, fintech, marketplace, social and
chat apps, digital content and more). **Incorporation country** on step 1 is a short card list — if yours
is not on it, use the *Contact us* link on that step rather than picking the nearest match.
## How do product, pricing strategy and markets change what I can test?
They are inputs to the payment method list, not cosmetic. When you leave step 4 the Portal previews
payment methods for the exact combination you selected, and step 5 renders only what comes back.
| Field | Values |
| - | - |
| `productSolution` | `ONE_TIME_PAYMENT`, `SUBSCRIPTION`, `BOTH` (selecting both products on step 2 sends `BOTH`) |
| `pricingStrategy` | `GLOBAL_USD`, `LOCAL_CURRENCY` |
Two consequences worth knowing before you pick: **subscription-only + Global USD pricing** leaves no
local markets selectable, only the Global option; **subscription-only + local currency pricing** narrows
step 4 to the small set of markets where local-currency subscriptions are offered, with the remaining
market chips greyed out rather than hidden. Changing your product selection or pricing strategy after the
fact **clears your downstream choices** on purpose — the previous list is no longer valid.
Step 5 lists Country/Region, Product, Payment method type, Payment method, Order currency and Status,
filterable by country, method type and enabled/disabled. Popular methods come pre-enabled; every row you
leave enabled must have at least one order currency selected, or the step will not advance.
## What is the difference between the Global market and a country?
**Global** sits in the market list next to the countries, but it is not one. It stands for the
global card schemes, which do not belong to any single country, while every other entry in the list
is a country or region whose local payment methods you want to test.
The two are **not alternatives**, and this is the most common misreading of step 4:
| What you select | What step 5 gives you |
| - | - |
| Global only | Global card payment methods, and nothing local |
| Countries only | Local payment methods for those countries, and no global cards |
| Global **and** countries | Both — the usual choice |
Selecting Global does not clear your countries, and selecting a country does not clear Global. If
you intend to take card payments anywhere in the world *and* local methods in two markets, select
all three. On step 5 the global rows are grouped under a **Global** country label and sorted to the
top, so you can tell them apart from the country rows at a glance.
## Why is a payment method I expected missing from step 5?
Step 5 does not query anything. The list is produced when you leave **step 4**, from exactly three
inputs — your product selection, your pricing strategy, and your markets — and step 5 renders what
came back. So work through the inputs in that order:
| Check | Why it bites |
| - | - |
| Is the method's country selected on step 4? | Nothing outside your selected markets is offered. Global cards need the **Global** entry, not a country |
| Are the step-5 filters clear? | Country, payment-method type and enabled/disabled are **display filters** over the list you already have. A filter left set hides rows without saying so |
| Does your product selection cover it? | The list is produced per product. A method offered for one-time payments only will not appear in a subscription-only preview |
| Does your pricing strategy allow the currency? | Under Global USD pricing the order-currency cell is **read-only**. You can only ever enable a row in the currencies the preview returned for it |
If the table comes back empty altogether, it renders as an empty table rather than an explanation —
go back to step 4 and widen the markets rather than assuming the step is broken.
Two things this step is *not*: it is not your production contract (see below), and it is not the
complete Waffo catalogue. What your live account can accept is
[Service → Payin](/docs/en/support/payin-agreements-and-limits-faq), and from code
`/api/v1/paymethodconfig/inquiry` — see
[Payment Method Configuration & Limits](/docs/en/support/payment-method-config-and-limits-faq).
## If I turn a payment method off in the sandbox, does production change?
No. The wizard and **Settings → Sandbox Config** write your *sandbox* configuration only. What you
can accept in production is your payin agreement, which is set up on Waffo's side and read on
**Service → Payin** — the wizard never writes to it.
The implication runs both ways, and it is worth internalising before go-live:
| Situation | What it means |
| - | - |
| Enabled in the sandbox, absent from Service → Payin | Not contracted in production. Enabling it for testing did not enable it for real |
| Disabled in the sandbox, `Active` on Service → Payin | Live payments are unaffected. You have only narrowed what you test against |
| Present in both | The one you can integrate end to end today |
A method that works in the sandbox and fails in production is the visible symptom of the first row;
[Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement) covers what
that failure looks like, and
[Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq) covers how the two
environments are isolated from each other.
## The page says "Default configuration". Can I test without configuring anything?
Yes. **Settings → Sandbox Config** carries one of two badges:
| Badge | What it means |
| - | - |
| `Default configuration` | You have not completed the setup wizard. A starting selection of products, pricing strategy, markets and popular payment methods is in place so you can start testing immediately |
| `Configured` | Your own submitted configuration is in force |
Read the actual default set off that page rather than from any document — it is what the page shows
you, and it is a starting point, not a commitment. **Customize** re-enters the wizard at step 1
seeded with the current values, and none of these controls touch your API credentials. What
**Reset** and **Clear all** do to an edit in progress, and how re-submitting replaces rather than
patches your selection, are in
[Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq).
## My submission came back with an error. What do I do?
The submission is all-or-nothing and the failure arrives as a single message rather than field-level
errors, so there is no partial state to clean up. Your answers stay in the wizard: fix what the
message names and submit again.
Before submitting, one rule is enforced locally — **every payment method you leave enabled must have
at least one order currency selected**, or the step refuses to advance and tells you so. That is the
most common reason a submission cannot be attempted at all.
If the final step reports that no sandbox configuration exists, it returns you to step 5. Nothing
was lost: the submission never landed, so re-submit from there.
## Where do I get my sandbox credentials?
Step 6 polls for credential readiness every couple of seconds and shows a "setting up your sandbox"
state until they exist. **The poll has no timeout**, so a page that spins for a long time is not
counting down to anything — it is still waiting for readiness, and it is worth raising rather than
waiting out. Once
ready, the sandbox MID, API key and key material live under **Settings → Integration → API Keys**, and
the test card library (success and decline scenarios only — there is no 3DS-challenge test card) under
**Settings → Integration → Test cards**. If step 6 reports that no sandbox configuration exists, the wizard sends you back to step 5
to submit — the submission never landed; credentials were not lost.
Afterwards, **Settings → Sandbox Config** exposes the same four decisions as an editable review flow.
Re-submitting replaces the whole selected set rather than patching one row, and your API credentials are
unaffected by a configuration change.
## What does account verification ask for?
`/verify-account` is a seven-step wizard (eight when extra documents are requested of you). Each step
must be complete before you continue:
| Step | Required to continue |
| - | - |
| Business type | Country of incorporation, business type, legal structure |
| Business details | Company legal name, registration number, official website, registered address (address, city, country) |
| Products & channels | Product category, product description, at least one sales channel with both a channel type and a URL |
| Key people | Signing director (first/last name, ID type, issuing country, ID number); plus a main contact unless you tick "same as signing director" |
| Ownership & control | Ownership type; for individual or mixed ownership, at least one UBO with full personal, ID and residential-address details, percentages totalling no more than 100% |
| Documents | Derived from your earlier answers — see below |
| Additional Requirements | Appears only when extra documents are requested; every requested file must be uploaded |
| Review & Submit | All previous steps complete, plus your final confirmation |
**Documents** is not a fixed checklist. It always asks for a Certificate of Incorporation and the signing
director's ID document, then adds a shareholding structure chart for corporate or mixed ownership, the
main contact's ID document when that is a different person, and per-UBO ID document and proof of address.
Anyone already listed as signing director or main contact is not asked for the same ID twice. Accepted
formats are PDF, JPG, JPEG and PNG, each encrypted before storage. The size limit is shown on the Documents
panel itself — read it there rather than from this page, because it is enforced per uploader. Upload
colour images with clear, complete edges — every field has a *Guidelines* panel with a reference example.
Your answers are saved as a draft each time you continue, and reopening `/verify-account` restores them,
uploaded files and reached step included. Submitting is the point of no return: the application enters
review and **cannot be edited unless it is returned to you**.
## My application came back as Returned. What do I fix?
| Status shown | What the wizard lets you do |
| - | - |
| Draft | Edit everything |
| In review | Read-only |
| Returned | Edit only the flagged items |
| Approved / verified | Read-only |
| Rejected | Read-only |
In returned mode the wizard is precise about scope: reviewer notes appear at the top, the step indicator
marks which steps contain returned items, individual fields and documents are flagged in place with the
note for that item, and everything else stays locked. A step stops being flagged once you address its
items, so the marks double as your to-do list. Fix them and re-submit — a re-submitted application counts
as in review again.
## The Portal shows an email address and my integration has a support group. Which do I use?
They answer different halves of onboarding, and using the wrong one is the usual reason a question
sits unanswered:
| Ask | Where it goes |
| - | - |
| A wizard step, a Portal screen, a missing entry point, a verification application | The onboarding address shown on the screen you are on |
| Integration behaviour, error codes, sandbox test results, and **anything to do with acceptance** | Your Waffo technical support group |
Acceptance in particular is group-only: the completed
[acceptance template](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria) is
submitted to the technical support group and confirmed there — see also
[Quickstart](/docs/en/developer-docs/getting-started/quickstart), which ends at the same place.
***
Stuck at a step, or an entry point missing from your Portal? Email
[support@waffo.com](mailto:support@waffo.com) with the screen you are on.
# Account Verification: Form, Statuses & What You Can Change
Source: https://waffo.com/docs/en/support/kyb-application-lifecycle-faq
How the account verification application behaves: which countries and entity types the self-serve form accepts, which fields are mandatory, whether the draft is saved and shared, who can submit, what each application status means, when you can edit, and what happens after approval.
This page is about the **application itself** — the form, its statuses, and what you can still
change. Which documents you need and what makes each one acceptable is a separate page:
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq). What has to happen after
approval before you can take live payments is
[Payment Enrollment](/docs/en/support/payment-enrollment-faq).
## Can I register and look around before I submit any company documents?
Yes. Registration asks for an email address and a password, then one short step for your company
name and country. Nothing else — no documents, no ownership details — is required to get into the
Portal, and a sandbox is provisioned for you so you can start integrating immediately.
Until verification completes, the account is in a look-around state and carries a **Demo Mode**
badge; see
[Merchant Switcher, Demo Mode & Environments](/docs/en/support/portal-switcher-and-environments-faq).
## The merchant ID I see does not look like the one we discussed. Why?
Before your own account is provisioned, your user is attached to a **shared demonstration merchant
account**, so the merchant ID in the header is that one, not yours. Your own merchant ID appears —
and becomes what the switcher shows — once provisioning completes. Nothing is wrong, and no data is
mixed: the demonstration account holds sample data only.
## Which countries of incorporation does the self-serve form accept?
The verification form's `Country of incorporation` offers **Hong Kong, Singapore and the United
States** only. This is enforced rather than advisory: if an application is loaded with a country
outside that set, the field is cleared and has to be re-selected from the supported list.
The very first step after sign-up, where you type your company name, does let you pick any country —
that is for setting up your sandbox, not the verification decision. So an unsupported country will
only become visible when you reach the verification form.
If your entity is registered elsewhere, do not work around the field. Ask your Waffo contact whether
your jurisdiction can be onboarded — that is a commercial and compliance question, not a form
limitation you can resolve yourself.
## We are not a limited company. Which entity types can I choose?
| `Business type` | |
| - | - |
| `COMPANY` | |
| `PARTNERSHIP` | |
| `SOLE_PROPRIETOR` | |
| `TRUST` | |
| `NON_PROFIT` | |
Where you pick `COMPANY`, a second field asks for the legal structure: `PRIVATE_LIMITED`,
`PUBLIC_LIMITED`, `LLC` or `JOINT_STOCK`.
The entity type you choose changes what you have to upload — a sole proprietorship and a company do
not evidence ownership the same way. The mapping from entity and ownership type to documents is in
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq), which also covers the
ownership types `INDIVIDUAL`, `CORPORATE` and `MIXED`.
## What are the steps, and which fields are mandatory?
Seven steps: **Business type**, **Business details**, **Products & channels**, **Ownership &
control**, **Key people**, **Documents**, **Review & submit**. An eighth,
**Additional requirements**, appears only when a reviewer has asked for something extra — see
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq).
Mandatory fields that people most often try to skip:
| Field | Step | Note |
| - | - | - |
| Legal business name | Business details | Must match your registration document |
| Company registration number | Business details | The label changes with your country, because the document differs |
| Company official website | Business details | Mandatory |
| Product category | Products & channels | From a fixed list, below |
| Brief product description | Products & channels | Free text, up to 200 characters |
| Sales channels | Products & channels | At least one row: a channel type plus its address |
`Trade name` is optional — fill it in when you trade under a name different from the registered one.
## My business is not in your product category list. What do I pick?
The list has seven values, and the last one is the catch-all:
`AI` · `SAAS` · `ONLINE_GAMING` · `LIVE_STREAMING` · `DIGITAL_ENTERTAINMENT` ·
`CREATOR_AND_FANS` · `OTHER_DIGITAL_CONTENT`
Pick the closest category and use the description field — that is what the free-text description is
for. Do not force an inaccurate category to get past the step; the description is read.
## My product is only in an app. Do I still have to give a website?
The sales-channel rows accept more than websites: `WEBSITE`, `MOBILE_APP`, `SOCIAL_PROFILE`,
`MARKETPLACE` and `OTHER`. You can add as many rows as you have channels, and only rows typed as
`WEBSITE` are validated as a URL — an app or marketplace row takes the store listing or profile
address in whatever form it has.
The separate `Company official website` field on the entity step is, however, mandatory. If your
entity genuinely has no site at all, raise it with your Waffo contact rather than entering something
that is not yours.
## Can I fill the form in over several sessions?
Yes. The application is saved as a draft as you go, and there is a **Save & continue** action, so
closing the browser does not lose what you typed. Reopening the verification screen loads your draft
back, including files you already uploaded.
## Can two of us fill it in together?
No — and this is the part that surprises teams. The draft belongs to the **user who is filling it
in**, not to the merchant account: it is loaded for the signed-in user. A colleague signing in with
their own account does not see your draft and cannot continue it, and there is no co-editing or
handover control in the Portal.
Practical consequence: decide who owns the application before you start, have that person collect the
documents from the others, and have them submit. If the wrong person started it, the application has
to be filled in again by the intended owner.
## Who is allowed to submit it?
The person who filled in the draft submits it — submission acts on that user's own draft. Because a
draft is not visible across users, "who can submit" is in practice "whoever started it".
That makes the person you choose worth choosing deliberately: they will also be the one who sees
what comes back if the application is returned.
## Can I still edit after I submit?
Only in specific states. The application is editable while it is a draft, and again if it is
**returned** to you — otherwise the form renders read-only, and both the interface and the backend
refuse changes:
| State | Editable? | Submittable? |
| - | - | - |
| Draft (including the initial state right after sign-up) | Yes | Yes |
| Submitted and under review | No | No |
| Returned | Yes | Yes — resubmit |
| Rejected | No | No |
| Approved | No | No |
So if you spot a wrong field after submitting, you cannot correct it yourself while review is in
progress. Wait for the outcome — if the field matters, it comes back to you as a return, and the
return tells you exactly which fields to fix.
## What do statuses like `MERCHANT_GENERATED`, `KYB_GENERATED` and `NOTIFIED` mean?
They all mean the same thing from your side: **submitted, under review**. Submission puts the
application into a pending state, and it then moves through internal provisioning steps that are
part of the same "under review" family:
| Status | Reading |
| - | - |
| `INIT` | The short first step after sign-up is saved; the full form is not submitted |
| `DRAFT` | Saved and editable, not submitted |
| `PENDING` | Submitted; a transient state right after you press Submit |
| `MERCHANT_GENERATED` | Submitted, under review |
| `KYB_GENERATED` | Submitted, under review |
| `NOTIFIED` | Submitted, under review |
| `RETURNED` | Sent back to you with specific fields to fix |
| `REJECTED` | Declined |
| `SUCCESS` | Approved |
Two things follow. First, none of the middle three is an action for you — you do not need to do
anything differently for one rather than another. Second, an application sitting in one of them is
**not** stuck between systems; it is waiting on review.
## What is the difference between Returned and Rejected?
| | Returned | Rejected |
| - | - | - |
| What it says | Specific things need fixing | The application is declined |
| Can you edit? | Yes | No |
| Can you resubmit the same application? | Yes | No |
| What you see | The fields flagged, with the reviewer's note and any example files | The outcome |
Returned is a request, not a refusal — the form reopens with only the flagged items in play, and a
resubmission puts it back into review. What a return notice can contain, and where to read it, is in
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq).
## I pressed Submit twice. Have I sent two applications?
No. Submit asks for confirmation first, and the action is only accepted from an editable state — a
submitted application can no longer be submitted, so a second press is refused rather than creating
a duplicate. There is one application per account being reviewed, not one per press.
## Can I copy documents from an entity we already had approved?
Not from the Portal. Each entity's application is filled in on its own, and no screen copies
documents or details from another application. Where the same individual appears in two roles inside
**one** application — a signing director who is also a beneficial owner — the document is reused
automatically, which is the only sharing the form does; see
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq).
Whether previously supplied material can be accepted again for a new entity is a compliance
question. Ask before assembling a second full pack.
## Where do I watch my progress, and can I hide the panel?
Home carries a **Getting Started** panel with your remaining setup steps, and each step links
straight to the part of the form it belongs to. You can collapse it by clicking its header, and your
choice is remembered in that browser; clicking the collapsed widget opens it again. The first-run
guided tour can be replayed from the panel, so skipping it is not permanent.
Note the collapse state lives in your browser, so a different browser or device starts expanded
again.
## My documents were approved. Why can I still not take payments?
Approval of the entity is one gate; the payment methods you want are a second one. That stage, the
three cards on Home, and what `Active`, `Processing`, `Suspended` and `Rejected` mean per method are
in [Payment Enrollment](/docs/en/support/payment-enrollment-faq). You can keep integrating in the sandbox
throughout — see [Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq).
# KYB Document Requirements: Getting Verification Right First Time
Source: https://waffo.com/docs/en/support/kyb-document-requirements-faq
What each account-verification document must look like to be accepted: which registration certificate applies to your country, how a shareholding structure chart is judged, passport and proof-of-address rules, what a returned item can be, how extra document requests work, and why a Guidelines download link stops working.
[Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq)
walks the verification wizard step by step and tells you *which* documents you will be asked
for. This page answers the next question: **what does each one have to look like to be
accepted.** Every document field in the Portal has a **Guidelines** panel with a reference
image; this page is that content in text, plus the reasons items most often come back.
Read it before your first submission. A returned application costs you a full review cycle, and
almost every return traces back to one of the items below.
## Which registration document do I upload?
It depends on your country of incorporation — and for the United States, on your legal
structure. The Portal picks the field for you and labels it accordingly:
| Country of incorporation | Legal structure | Document asked for |
| - | - | - |
| Hong Kong | — | Business Registration Certificate |
| Singapore | — | Certificate of Incorporation |
| United States | LLC | Articles of Organization |
| United States | Joint stock | Articles of Incorporation |
If your country is not offered on the first step of the wizard, use the *Contact us* link on
that step rather than picking the nearest match — a mismatched country produces a document
request you cannot satisfy.
## Why is the "registration number" field labelled differently for me than for someone else?
Same reason: the label follows your country, because the number itself is a different national
identifier.
| Country | Field label | Where to read it from |
| - | - | - |
| Hong Kong | Business registration number | As printed on your HK Business Registration Certificate |
| Singapore | Unique entity number (UEN) | Issued when your company was incorporated |
| United States | Filing ID | From your state registration documents |
Enter it exactly as printed on the document you are uploading, including any leading zeros. A
number that does not match the certificate is one of the cheapest returns to avoid.
## What makes a registration document acceptable?
| Requirement | Detail |
| - | - |
| Format | Colour image or colour scan. Not black and white, not a photocopy of a photocopy. |
| Validity | The submission date must fall inside the document's validity period. For a Hong Kong Business Registration Certificate this means also filling in the expiry date you can read on it. |
| Clarity | Clear and readable, with **all four edges** fully visible — no cropping, no obstruction, no glare. |
For US filings the requirement is stated as a single line and it is the same substance: clearly
legible, all four edges fully visible, free from obstructions or glare.
## What does an acceptable shareholding structure chart look like?
This is the item that comes back most often, because "a chart of our shareholders" is not the
same thing as what is being asked for. Four requirements, all of them checked:
| Requirement | What it means concretely |
| - | - |
| Clear direction | A **top-down tree**, from the ultimate natural-person shareholders down to the contracting company. Not a flat list, not a spreadsheet of names. |
| Full penetration | Include **every intermediate entity**, layer by layer, until all underlying natural persons are shown. A chart that stops at a holding company has not penetrated. |
| Ratio labelling | Label each **direct** shareholding ratio on the line that carries it, for example `70%`. |
| Highlight UBOs | Clearly mark every natural person who holds **25% or more**, directly or indirectly. |
The Portal offers a **Download Shareholding Structure Template** link inside the chart's
Guidelines panel. Use it — a chart produced from the template satisfies the layout requirements
by construction, and you only have to get the facts right.
The field hint states the two remaining conditions the chart itself must carry: it must
**penetrate to ultimate beneficial owners holding 25% or more**, and it must be **stamped with
the company seal and signed by directors**. An unsigned, unstamped chart is returnable however
accurate its content.
## How do I work out indirect shareholding for the 25% test?
Multiply the ratios along the path, layer by layer. A natural person holding 50% of a
mid-tier company that in turn holds 60% of your company holds 30% indirectly, and is therefore
a UBO who must be marked and declared. Label the **direct** ratio on each line of the chart —
the multiplication is what the reader does with those labels, so labelling every layer is what
makes the chart checkable.
Where a person reaches 25% through more than one path, add the paths together.
## My ownership type changes what I have to upload. How?
Yes, and this is worth getting right on the earlier step, because it decides your Documents
step entirely:
| Ownership type | UBOs to declare | Shareholding structure chart |
| - | - | - |
| Individual | Yes — every UBO at 25% or more, with full personal, ID and residential-address details | Not required |
| Corporate | No individual UBO section | **Required** |
| Mixed | Yes | **Required** |
Declared ownership percentages must total no more than 100%. If a chart is required and you
have not selected Corporate or Mixed, the chart field will not appear — check your ownership
type before concluding the form is wrong.
## What makes an ID document acceptable?
The Portal asks for the ID type each person holds — passport, national ID card or driver
licence — and labels the upload accordingly. The acceptance standard is written for the
passport case and is the strictest of them:
| Requirement | Detail |
| - | - |
| Complete display | The **full data page** *and* the **MRZ code** along the bottom. A crop that loses the two machine-readable lines is returnable. |
| Format | Colour scan or photo **of the original document**. |
| Clarity | Name, date of birth, document number, validity period and photo all clear and **without reflection**. All four edges and corners visible. |
| Validity | The document must be inside its validity period at submission. |
Practical reading of "without reflection": a flash photo of a laminated page usually fails on
the photo or the MRZ. Scan it, or shoot it in even indirect light.
## What counts as proof of address for a UBO?
The requirement the reviewer applies is exact matching, not document type:
> Name and address must exactly match the UBO information registered with Waffo. The name must
> be the full legal name, and the address must be the current residential address.
So the failure mode is almost never "wrong kind of document" — it is a shortened first name, a
former address, or an address written differently from the one keyed into the form. Check the
form against the document before uploading, not after.
The categories of evidence normally used are a recent bank or credit-card statement, a recent
utility or telecoms bill, or a tenancy agreement inside its term. Whichever you choose, the
name on it must be the person's full legal name and the address must be the one you entered as
their residential address.
## One of my UBOs holds a mainland China national ID card. Do they still need proof of address?
No. Where a person's ID type is a **national ID card issued in China**, the proof-of-address
upload is not requested for that person — the card carries the address. You will simply not see
the field for them, and its absence is not something to work around.
## The same person is a director and a UBO. Do I upload their ID twice?
No. People are matched as the same individual on **first name, last name, ID type and ID
number** together, and their document cards are merged: one ID upload, tagged with every role
they hold. On the merged card you will see their roles side by side, for example a *Signing
Director* tag next to a *UBO | 40% ownership* tag.
Two consequences:
* **Merging depends on those four values matching exactly.** The same person entered with a
differently-spelled first name, or with a passport number in one place and a national ID
number in another, will be treated as two people and asked for two documents.
* **A merged UBO still owes proof of address** (unless the China national ID exemption above
applies) — it appears on the merged card, under the ID upload, rather than on a separate UBO
card.
## What file formats can I upload, and is there a size limit?
The uploader accepts **PDF, JPG/JPEG and PNG**. Every document is encrypted before storage.
There is a per-file size limit, and the figure is stated on the Documents step's own banner —
read it there rather than from memory, and keep your files comfortably below it. If an upload
is rejected on size, the message tells you the limit being applied.
A practical note that saves a round trip: for a multi-page document, one PDF is safer than
several images, because "all four edges visible" and "complete display" are judged per file.
## What can be sent back to me when an application is returned?
Returned items are drawn from a fixed set — a reviewer flags specific fields and documents, not
free-form prose. Knowing the set tells you what a return can possibly be about.
At company level:
| Returnable item | Where you fix it |
| - | - |
| Registration document | Documents step |
| Shareholding structure chart | Documents step (Corporate / Mixed only) |
| Company legal name | Business details step |
| Trade name | Business details step |
| Company registration number | Business details step |
| Company official website | Business details step |
| Registered address | Business details step |
| Website URL / App URL (your sales channels) | Products & channels step |
| Product category / product description | Products & channels step |
| Ownership type | Ownership & control step |
At person level, the returnable items differ by the person's role. Everyone shares a core set;
a UBO carries a larger one, because a UBO is the only role for which Waffo holds full personal
details:
| Returnable item | Signing director | Main contact | UBO |
| - | - | - | - |
| Full name | ● | ● | ● |
| ID type | ● | ● | ● |
| ID number | ● | ● | ● |
| ID document | ● | ● | ● |
| Issuing country of ID | ● | ● | ● |
| Nationality | ● | ● | ● |
| Proof of address | | | ● |
| Date of birth | | | ● |
| Gender | | | ● |
| ID expiry date | | | ● |
| Ownership percentage | | | ● |
| Residential address | | | ● |
A returned residential address covers its parts together — street, city, country and postal
code are one item, not four.
## Where do I read what the reviewer actually wants?
Three places, and they are all in the wizard rather than in an email:
1. **Reviewer notes at the top** of the application — the overall message.
2. **A note on each flagged item, in place.** Each flagged field or document carries the
specific comment for that item, next to the input you have to change.
3. **A Guidelines attachment on the flagged item**, where the reviewer added one — a file that
shows what is being asked for. Open it from the flagged item.
A step stops being marked once its items are addressed, so the step indicator works as a
progress bar for your fix-up round.
## What are "Additional Requirements"?
An extra step that appears **only** when the reviewer asked for something beyond the standard
document set. Each request is a card carrying:
* the **name** of what is being requested,
* a **notice** — the reviewer's explanation of what they want and why,
* optionally **guideline attachments** to open, and
* the input to satisfy it.
Requests come in two shapes, and the card gives you whichever applies:
| Request type | What you do |
| - | - |
| Document | Upload a file, same formats and limits as any other document |
| Text | Type an explanation into a text box — no file involved |
The step will not let you continue until every request on it has been answered. Requests are
grouped into named categories (legal documents, director ID documents, authorisation documents,
UBO reports, other materials, and a general "other"), and a reviewer can also raise a
**custom** item — a custom item always arrives with explanatory text, so a request you have
never seen before still tells you what it wants.
## Can I see what was asked for in earlier rounds?
Yes. Below the current requests, **History additional requirements** lists previous rounds
**grouped by date**, newest first. Each historical entry shows the item name, the reviewer's
notice, and what you supplied — the text you wrote, or a link to the file you uploaded. Rounds
where nothing was supplied are shown as such.
Use it before answering a repeat request: if the same item is being asked again, the history
tells you what was rejected last time.
## The download link for a document or a Guidelines file stopped working. Why?
Because those links are **deliberately short-lived**. A document link is generated at the
moment you click, is valid for a limited period, and then stops working — including a link you
copied, bookmarked, or pasted into a chat for a colleague.
This is not a fault, and there is nothing to fix:
* **Re-open the item in the Portal** to get a fresh link. That always works.
* **Do not share the link itself.** Share the Portal location, or download the file and send
the file.
* **A link that fails immediately** usually means the page has been open a long time — reload
the page and click again.
## Can I edit my company legal name if I got it wrong?
Not from the verification wizard once it is set. Your company legal name is captured at the
very start of the journey and carried into the verification form as a read-only value, precisely
so that the name on your documents and the name on your account cannot drift apart. If it is
wrong, contact [support@waffo.com](mailto:support@waffo.com) — do not work around it by
entering the correct name elsewhere on the form, which produces a mismatch return.
## My documents were approved. What happens next?
Approval of your documents is not the same as being able to charge. One more stage follows —
payment methods being enabled one by one — and it has its own indicator on the Portal Home
page. See
[Payment Enrollment: Why Approved Verification Is Not Yet Live Payments](/docs/en/support/payment-enrollment-faq).
***
Unsure whether a specific file will pass? Open the **Guidelines** panel on that field first —
it carries a reference image of an acceptable example. If it is still unclear, email
[support@waffo.com](mailto:support@waffo.com) before submitting rather than after.
# Building My Own Checkout Page Instead of Using Waffo's
Source: https://waffo.com/docs/en/support/merchant-built-checkout-faq
What a merchant-built checkout can and cannot get from Waffo: when orderAction.actionData is populated and when it is empty, which payment instruments come back as raw data, who validates the required customer fields, how to query payment-method availability and amount ranges yourself, whether hosted and self-built flows can be mixed order by order, and the parts of the flow that only the Waffo-hosted checkout performs.
[Choosing an integration approach](/docs/en/developer-docs/use-cases/checkout-solution)
compares the two modes and tells you which request parameters each one needs.
This page answers the questions that come up **after** you have decided to build
your own page: what data you actually receive, what you have to do yourself, and
which parts of the flow have no merchant-side equivalent.
The field-by-field reference for `orderAction.actionData` lives in
[API reference tables](/docs/en/support/api-reference-tables).
This page does not repeat it.
## What do I have to receive and handle if I build my own payment page?
Four things, in this order:
| Step | What you do | Where the data comes from |
| - | - | - |
| 1. Decide the payment method | Your page collects the customer's choice | Your own UI, built from a list you query (see below) |
| 2. Create the order | `POST /api/v1/order/create`, passing `payMethodType` and — for a specific wallet — `payMethodName` | [Checkout customization](/docs/en/developer-docs/integration/checkout/customization) |
| 3. Present the payment instrument | Read `orderAction`. Either redirect the customer (`actionType` = `WEB` / `DEEPLINK`) or render the raw instrument from `actionData` | [Redirects and `actionType`](/docs/en/support/cashier-redirect-and-action-type-faq) |
| 4. Confirm the result | Your payment notification (webhook), or `POST /api/v1/order/inquiry` | [Payment results](/docs/en/support/payment-results-faq) |
Nothing about step 4 changes when you build your own page. A customer arriving
back on your page is not proof of payment, and a rendered QR code is not proof
of payment. The notification and the inquiry remain the only authoritative
sources.
## When is `orderAction.actionData` actually populated?
**Only when the order already resolved to one specific payment method at
create time.** This is the single most important rule for a merchant-built
checkout, and it is easy to miss:
| What you sent on `/api/v1/order/create` | What you get back |
| - | - |
| A specific payment method (`payMethodType`, plus `payMethodName` where the type has several sub-methods) | `orderAction` with the redirect fields **and** `actionData` where the method has an instrument to hand over |
| No payment method — you left the choice to the customer | `orderAction` pointing at the Waffo-hosted checkout. `actionData` is **empty** |
The reason is mechanical: when the customer has not chosen yet, no payment has
been submitted to the payment method, so there is no account number, no QR
payload and no expiry to return. They come into existence only after a method
is picked. If you leave the choice to the customer, the choice happens on the
Waffo-hosted page — and at that point the data has nowhere to go except that
page.
So if `actionData` is empty and you expected content, check the payment-method
parameters on your create request first.
## Which payment instruments come back as raw data, and which force a redirect?
`actionData` carries whatever the routed payment method produces. Broadly:
| Kind of payment method | What you can expect |
| - | - |
| QR-code methods | A QR payload in `paymentQr` — a **string**, not an image |
| Over-the-counter and code-based methods | `paymentCode` and/or `paymentBarCode` |
| Bank transfer and virtual account | `bankAccount`, `bankName`, `bankBeneficiary`, `referenceNumber` |
| Wallets that authorize on their own surface | No instrument — a redirect URL instead, or an app push with no URL at all |
| Cards | No instrument. Card data collection cannot be handed to `actionData` |
Two consequences worth planning for:
* **You render the QR yourself.** `paymentQr` is the raw payload string. Encode
it into a scannable code client-side, at a size that survives a phone camera.
* **A method can populate several of these, or none.** Which fields arrive is a
property of the payment method the transaction routed to, not of the country
or of your account. Build your page to render whichever fields are present
and to fall back to the redirect when none are.
## Can I get a local bank account number — a CLABE, an IBAN, a virtual account?
Yes, through the same field: `bankAccount`. It carries the account the customer
must transfer to, in whatever form the local system uses — a CLABE in Mexico, an
IBAN elsewhere, a routing plus account number, or a virtual account generated
for that one order. `bankName`, `bankBeneficiary` and `referenceNumber` come
alongside it.
Two caveats:
* It is populated for **bank-transfer and virtual-account style methods that
return one**. It is not a field you can request for an arbitrary payment
method, and asking for "the CLABE" in the abstract has no answer — the
question is always whether the specific method you are integrating returns a
`bankAccount`. Verify per method in sandbox before you build the screen.
* The rule above still applies: you get it only if you pinned the payment
method on the order.
When you ask Waffo support whether a given method returns bank-transfer data,
include the method you intend to use and the `userInfo.userTerminal` you will
send — terminal type affects which methods are eligible at all, so the answer
can differ between a PC and a mobile session.
For how these fields behave once the customer transfers — expiry, wrong amount,
reconciliation on `referenceNumber` — see
[Bank transfers and virtual accounts](/docs/en/support/bank-transfer-and-virtual-account-faq).
## `apiMethod` says `POST`. Do I have to build and submit a form myself?
On a merchant-built checkout, yes. `orderAction` describes the redirect
completely, and a `POST` redirect needs all three parts:
| Field | What to do with it |
| - | - |
| `actionType` | `WEB` → use `webUrl`; `DEEPLINK` → use `deeplinkUrl` |
| `apiMethod` | `GET` → navigate to the URL. `POST` → submit a form to it |
| `formBody` | The form fields, in `a=1&b=2` form. Split it and emit one hidden input per pair |
Do not drop or re-encode parameters while doing this — several payment methods
carry required values in the URL or the body, and a rewritten URL fails at the
payment method rather than at Waffo.
If you use the Waffo-hosted checkout instead, this is handled for you: the page
builds and submits the form itself.
## Do I have to query payment-method availability and amount limits myself?
Yes. This is the main ongoing cost of a merchant-built checkout, and it is the
one most often underestimated.
| What you must maintain | How to get it |
| - | - |
| Which payment methods your account can offer, per country and currency | `POST /api/v1/paymethodconfig/inquiry` — see [Payment method configuration and limits](/docs/en/support/payment-method-config-and-limits-faq) |
| The amount range each method accepts | The same response, before you create the order |
| Whether a method is temporarily unavailable | The status fields in the same response |
| Newly signed payment methods | Nothing appears on your page until you add it |
On the Waffo-hosted checkout all four are automatic: unavailable methods are
grayed out, out-of-range methods are not offered, and a newly signed method
shows up without a code change. On your own page, an out-of-date list turns
into a failed order the customer already committed to.
## Who validates the customer fields a payment method requires?
You do, for the fields you send. Several payment methods have fields that are
documented as optional on `/api/v1/order/create` but are **mandatory for that
method** — a national ID, a phone number, a postcode, an email address. If you
collect them on your own page, you own the format validation, and Waffo's only
feedback is a rejected order-create call.
The list of which fields become mandatory for which method type is in
[Conditionally required fields](/docs/en/support/conditional-required-fields-faq),
and the field paths that can appear in a missing-field error are in the same
place.
On the Waffo-hosted checkout these fields are collected on the page, with the
payment method's own format rules applied inline, before the payment is
submitted.
## Can I mix the two — self-built for some payment methods, hosted for others?
Yes, and it is decided per order rather than per account. The same
`/api/v1/order/create` endpoint gives you either behaviour:
* Pass a specific payment method → the customer never sees a Waffo selection
page, and you receive the instrument or the redirect to drive your own screen.
* Pass no payment method → the customer lands on the Waffo-hosted checkout and
chooses there.
A common shape is to run your own page for the two or three methods that
dominate your volume, and fall back to the hosted checkout for the long tail —
which also means new payment methods work with no change on your side.
What you cannot do is switch mid-order. Once a checkout session has resolved to
a payment method, that choice is fixed for the session.
## Can I build the over-the-counter store selection page myself?
Not as a self-serve integration. For over-the-counter methods where the
customer picks a specific store or outlet, the store list is presented by the
Waffo-hosted checkout during the payment step; `actionData` returns the payment
code the customer takes to the counter, not the list of places that accept it.
So a merchant-built page can present the code and the instructions, but the
selection step it comes from has no merchant-facing equivalent. If your product
needs that screen inside your own UI, raise it with your Waffo contact as a
product request rather than looking for a parameter.
## Can I build my own page for card payments?
Only if you are prepared to be in PCI DSS scope. That is the trade-off in the
[integration comparison](/docs/en/developer-docs/use-cases/checkout-solution#comparison):
with the Waffo-hosted checkout, card data never touches your systems.
Two intermediate options exist that keep you out of scope while still letting
you control the page:
* **Card tokenization**, where the card fields are rendered by the Waffo
front-end SDK inside your page — see
[Tokenization](/docs/en/support/tokenization-faq).
* **The embedded checkout**, where the Waffo checkout is rendered inside your
page rather than replacing it — see
[Checkout integration steps](/docs/en/developer-docs/integration/checkout/steps#iframe-embedding-considerations).
## Is Apple Pay available to a merchant-built checkout?
Yes — a direct Apple Pay integration against your own Apple merchant identity
is one of the supported options, alongside redirecting to the Waffo checkout,
loading it in an App WebView, and loading it in an iframe with the Waffo
front-end SDK. The four options and what each one requires are set out in
[Apple Pay integration](/docs/en/developer-docs/integration/apple-pay/overview).
What is not available is a plain iframe of the checkout URL for Apple Pay: that
path requires the front-end SDK plus domain verification arranged in advance.
## Does a self-built checkout change my reporting, receipts or refunds?
No. Everything after the payment is identical, because the order is the same
order:
| Area | Behaviour |
| - | - |
| Payment notification | Same events, same payload |
| Order inquiry | Same endpoint, same statuses |
| Refunds | Same `/api/v1/order/refund` and the same per-method refund rules |
| Portal transaction lists and exports | The transaction appears the same way |
| Receipts | Governed by your receipt configuration, not by which page the customer paid on |
The parts that differ are all on the presentation side: payment-method
selection, required-field collection, instrument rendering, status polling and
the result screen.
## What does the hosted checkout do that I would have to rebuild?
Worth reading as a checklist before committing:
* Payment-method selection, grouping and per-country ranking, plus hiding or
graying out methods that are unavailable right now
* Collecting and validating each method's required customer fields
* Rendering QR codes and barcodes, and offering copy and save actions
* Polling for the payment result and advancing the page when it lands, so the
customer is not left on a dead screen
* The retryable-error path, where a transient failure is retried in the
background and the page moves on by itself
* Letting the customer pick a different payment method after a failure, without
a new order
* The amount breakdown — price, discount, subtotal with the FX rate, customer
fee, tax, total
* The display language, including selecting one when you did not send
`cashierLanguage`
* The expired-link and already-paid screens for a link a customer reopens later
***
Still deciding? [Choosing an integration approach](/docs/en/developer-docs/use-cases/checkout-solution)
has the side-by-side comparison. If you have picked the merchant-built route and
need to confirm what one specific payment method returns,
[contact support](mailto:support@waffo.com) with the payment method, the country
and the `userInfo.userTerminal` you will send.
# Mini-Program Payments
Source: https://waffo.com/docs/en/support/mini-program-payments-faq
How to take payments inside a wallet mini program: which front-end JS APIs and which Waffo endpoints you must integrate, what to send on order creation, how refunds work, how to identify the user with userid/inquiry, and why you only get the user's public ID unless extra scopes are approved.
Some wallets host third-party **mini programs** — small apps that run inside the
wallet's own app. If you publish one, you can collect payment inside it without
sending the user out to a browser: your mini program asks the wallet to pay, and
your backend talks to Waffo exactly as it does for any other order.
This page covers the whole integration: the payment path, the refund path, and
the separate user-identity path. Signing, keys and headers are unchanged from
every other Waffo API — see
[Authentication & Signature](/docs/en/support/auth-signature-faq). Order
fields not specific to mini programs are in the
[Order Creation Field Guide](/docs/en/support/order-create-field-guide).
> [!NOTE]
>
> Publishing a mini program requires a developer account on the wallet's own
> mini-program platform, and Waffo assigns you the app identifier you send on API
> calls. Ask your Waffo integration contact to start that process before you write
> any code — the identifiers are not self-service.
## What do I actually have to build?
Two independent tracks. Payment is mandatory; identity is mandatory only in its
first step.
**Payment track**
| Piece | Where | Required |
| - | - | - |
| `my.tradePay` (wallet JS API) | Your mini program's front end | Yes |
| `POST /api/v1/order/create` | Your backend | Yes |
| `POST /api/v1/order/inquiry` | Your backend | Yes |
| Payment result notification (webhook) | Your backend | Yes |
| `POST /api/v1/order/refund` | Your backend | Only if you refund |
| `POST /api/v1/refund/inquiry` | Your backend | One of these two |
| Refund result notification (webhook) | Your backend | One of these two |
**Identity track**
| Piece | Where | Required |
| - | - | - |
| `my.getSystemInfo` (wallet JS API) | Front end | Yes |
| `my.getAuthCode` (wallet JS API) | Front end | Yes |
| `POST /api/v1/userid/inquiry` | Your backend | Yes |
| `POST /api/v1/userinfo/inquiry` | Your backend | Only if you need profile data |
Both identity endpoints sit in the **payin** scope of an API key, so the same
key you use for order creation can call them.
## How does the payment flow run end to end?
1. The user taps *Pay* in your mini program. Your front end calls **your own**
backend, not Waffo directly — your API key and your signing private key must
never reach the front end.
2. Your backend calls `/api/v1/order/create` with the mini-program fields below,
and receives a payment URL.
3. Your front end passes that URL to `my.tradePay`.
4. The wallet runs its own payment UI and returns success or failure to your
front end.
5. Waffo sends the authoritative result to your `notifyUrl`. Your backend
fulfils the order on that, not on step 4.
That last point is the one that causes real incidents. The value
`my.tradePay` hands back is a **UI outcome**: it tells you what happened in the
user's app, not what happened to the money. A user who backgrounds the wallet,
loses signal, or force-closes the app can leave you with no front-end result at
all on a payment that succeeded. Treat the webhook — or an
`/api/v1/order/inquiry` poll — as the only source of truth, exactly as described
in [Confirming Payment Results](/docs/en/support/payment-results-faq).
## What do I send on order creation?
Everything a normal order needs, plus three mini-program specifics:
| Field | Value |
| - | - |
| `paymentInfo.productName` | `MINI_PROGRAM_PAYMENT` |
| `paymentInfo.payMethodName` | The wallet's payment-method name, as it appears in your payment-method configuration |
| `paymentInfo.payMethodProperties.appId` | The mini-program app identifier Waffo assigned you |
| `userInfo.userTerminal` | `IN_MINI_PROGRAM` |
`userTerminal` matters more than it looks: it is part of how Waffo decides
whether a payment method can serve this particular request. A mini-program order
submitted as `WEB` can be rejected as having no usable payment method, because
not every method supports every terminal. The other accepted values are `WEB`,
`APP` and `IN_WALLET_APP` (the user is inside the wallet app but not inside a
mini program).
If you are unsure which payment-method name and which currency your account is
configured for, read it back from `/api/v1/paymethodconfig/inquiry` — see
[Payment Method Configuration & Order Limits](/docs/en/support/payment-method-config-and-limits-faq).
## How do I refund a mini-program payment?
Through the ordinary refund API — `/api/v1/order/refund`, with
`/api/v1/refund/inquiry` or the refund webhook to follow the outcome. There is
no mini-program-specific refund endpoint, and no mini-program-specific rule
about how many refunds an order may take.
What does vary is the **payment method's** refund capability: whether partial
refunds are allowed at all, and whether they can be repeated. Read
`refundAllowed` from the payment-method configuration rather than assuming, and
see [Payment Method Refund Limits](/docs/en/support/payment-method-refund-limits-faq)
for what each capability means in practice.
## How do I identify the user inside my mini program?
Your mini program cannot see who the user is by itself; the wallet mediates it.
1. Front end calls `my.getAuthCode` with the scopes you need. It returns a
short-lived `authCode`.
2. Front end sends that `authCode` to your backend.
3. Your backend calls `/api/v1/userid/inquiry` with `merchantId`, `appId`, the
`authCode`, and `appPlatform` (the wallet-platform code Waffo assigned you).
4. You get back:
| Field | Notes |
| - | - |
| `userId` | The user's **public identifier** on that wallet — stable for that user in your mini program |
| `accessToken` | Needed only if you go on to request profile data |
| `accessTokenExpiryTime` | ISO 8601. The token is not indefinite; do not cache it past this |
Use `userId` as the key for your own account linking. Do **not** treat it as a
phone number, an email address, or a cross-wallet identity — it is an opaque
identifier scoped to your mini program.
## Can I get the user's email address so I can deliver the goods?
Not by default, and this is the single most common surprise in this integration.
`my.getAuthCode` **must** include the scopes `MINI_PROGRAM` and `PUBLIC_ID`.
Those two get you the public user identifier and nothing else. Everything else
is an **optional scope that has to be approved per merchant** by the wallet
platform:
| Optional scope | What it unlocks |
| - | - |
| `USER_CONTACTINFO_EMAIL` | Email address |
| `USER_CONTACTINFO` | Contact details |
| `USER_ADDRESS` | Address |
| `USER_BIRTHDAY` | Date of birth |
| `USER_GENDER` | Gender |
| `USER_LOGIN_ID` | The user's login identifier on the wallet |
| `USER_NAME` | Name |
By default **no personal information beyond the public ID is granted**. To get
any of the above, you have to request the specific scope and provide a written
justification for why your mini program cannot function without it. Approval is
the wallet platform's decision, not Waffo's, and it is granted scope by scope.
Design accordingly: build the flow so that a user with only a public ID can
still complete a purchase and receive what they bought. In practice that means
collecting a delivery email in your own mini-program UI, and treating any
profile field the wallet grants you as a convenience that pre-fills a form —
never as a required input.
## What does the profile lookup return?
`POST /api/v1/userinfo/inquiry` takes `merchantId`, `appId`, `appPlatform` and
the `accessToken` from the identity step, and returns whatever your approved
scopes cover:
| Field | Notes |
| - | - |
| `userName` | Full name |
| `nickName` | Display name |
| `gender` | A single-letter code (`M` / `F`) |
| `birthday` | A formatted date string — parse defensively, do not assume a separator |
| `contactEmail` | Email address |
| `contactMobileNumber` | Mobile number, including country code |
| `addressInfo[]` | `address`, `city`, `region`, `postcode`, `addressCountryCode` |
| `userLoginId` | The user's login identifier on the wallet |
| `kyc` | The user's verification tier, as a two-digit code: no verification, partial, or full |
Every one of these is **conditional**. A field you were not granted simply does
not arrive, and a field you were granted can still be absent because the user
never filled it in. Write your mapping so that a missing field is a normal case,
not an error.
## What do the identity error codes mean?
| Code | Meaning | What to do |
| - | - | - |
| `A0001` | Invalid API secret key | Wrong key or wrong environment. |
| `A0002` | Invalid signature | See the signature FAQ. |
| `A0003` | Parameter validation failed | The detail names the field. A mismatch between the body's merchant ID and your API key's merchant ID lands here too. |
| `A0006` | Merchant status abnormal | Account-level problem; contact support. |
| `A0010` | Operation not allowed in merchant contract | Your account is not configured for this. |
| `B0008` | Auth code is invalid | Get a fresh code from `my.getAuthCode`. |
| `B0009` | Auth code is already expired | Codes are short-lived — exchange them immediately, never store them. |
| `B0010` | Auth code has been used | Each code is single-use. Do not retry the exchange with the same code. |
| `B0011` | Access token is invalid | Re-run the identity step from `my.getAuthCode`. |
| `B0012` | Access token is expired | Re-run the identity step; honour `accessTokenExpiryTime`. |
| `C0001` | System error | Retry. |
The three `B0008`–`B0010` codes are all "your auth code is no longer usable".
The correct handling for every one of them is the same: go back to the front end
for a new code, rather than retrying the backend call.
Payment errors are the ordinary ones — see
[Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide).
## Can I run subscriptions inside a mini program?
Yes, as a distinct product: `MINI_PROGRAM_SUBSCRIPTION` rather than
`MINI_PROGRAM_PAYMENT`. It carries one extra requirement — a **user access
token** obtained through the identity flow above must be supplied when the
subscription is created, because the wallet needs to know which of its users is
authorising the recurring charge.
Everything else about subscriptions is unchanged, including amount rules,
notifications and cancellation. Start from
[Subscription FAQ](/docs/en/support/subscription-faq), and check what your
payment method supports before promising features to your users.
## What is different from a normal hosted-checkout integration?
| | Hosted checkout | Mini program |
| - | - | - |
| Where the user pays | The Waffo checkout page in a browser | Inside the wallet app, driven by `my.tradePay` |
| Who renders the payment UI | Waffo | The wallet |
| How you learn the result | Redirect (cosmetic) + webhook (authoritative) | JS callback (cosmetic) + webhook (authoritative) |
| Who the user is | Whatever you collect | The wallet's public user ID, plus approved scopes |
| Checkout customisation | Portal settings apply | Not applicable — there is no Waffo page |
The important consequence: **none of the checkout-page settings apply**. Display
language, method ordering, branding and the result page all live in the wallet's
UI, so your mini program owns the experience. Everything in
[Checkout display rules](/docs/en/support/cashier-display-rules-faq) and the checkout
customisation pages is irrelevant to this path.
## Do I still get the same reporting?
Yes. Mini-program orders appear in the Portal transaction list, in exports and in
settlement like any other order, and are distinguishable by their product. Order
identifiers behave identically, so your reconciliation does not need a special
case — see [Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
## Is there a Waffo SDK or JS package for the mini program?
No. There is nothing for you to install on the mini-program side. The two halves
of the integration are:
| Side | What you use |
| - | - |
| Your mini program's front end | The **wallet's own** JS APIs — `my.getSystemInfo`, `my.getAuthCode`, `my.tradePay`. They come from the wallet runtime, not from Waffo. |
| Your backend | Waffo's ordinary REST endpoints, signed the ordinary way |
Waffo's published SDKs are **server-side** helpers for calling the REST API; they
are not mini-program packages and there is no front-end bundle to embed. So
"which Waffo library do I import into the mini program" has no answer — the
correct answer is none, and your front end never talks to Waffo directly.
## What is `appPlatform` for, and what happens if I leave it out?
`appPlatform` tells Waffo **which wallet platform the auth code came from**. It is
part of the documented request contract for both identity endpoints, and the
accepted values are assigned to you by Waffo along with your `appId`.
Always send it. It is the field that disambiguates the exchange when your account
is set up on more than one wallet platform, and omitting it is not something to
rely on — there is no defined fallback behaviour for you to depend on, and the
value costs you nothing to store next to the `appId` you already hold.
## What does `B0013` mean, and how is it different from `B0011` and `B0012`?
| Code | Meaning |
| - | - |
| `B0011` | Access token is invalid — the value is not a token Waffo can resolve |
| `B0012` | Access token is expired — it was valid and its lifetime has passed |
| `B0013` | Access token is not exist — no token is on record for this request |
For your integration all three collapse to the same recovery: **go back to the
front end, call `my.getAuthCode` for a new code, exchange it for a new token.**
Retrying the backend call with the same token cannot succeed for any of them.
Do not build separate branches for the three. Build one "re-run the identity
flow" path and route `B0011`, `B0012` and `B0013` into it, exactly as you do for
`B0008`–`B0010` on the auth code.
## The token has not reached `accessTokenExpiryTime`, but creating a subscription says the token expired
Because the **wallet**, not Waffo, is the authority on whether the token is still
good at the moment of the charge. `accessTokenExpiryTime` is the latest the token
could be valid — it is not a guarantee it still is. A token can stop working
early when the customer revokes the authorisation, signs out, or the wallet
invalidates the session on its own.
When that happens the wallet's verdict reaches you as `B0009` (auth code already
expired) or `B0008` (auth code invalid) on the create call, rather than from the
identity endpoint. Both are classified as **not retryable**: the same call with
the same credential will keep failing.
The design rule that follows: **fetch a fresh token as part of the flow that
creates the order or the subscription, rather than reusing a stored one.** Caching
a token and treating `accessTokenExpiryTime` as a validity promise is the single
most common cause of this error.
## Can I run subscriptions in the mini program, and what exactly do I send?
Yes, as the `MINI_PROGRAM_SUBSCRIPTION` product. Alongside the normal
subscription fields you send the customer's access token:
| Field | Value |
| - | - |
| `paymentInfo.productName` | `MINI_PROGRAM_SUBSCRIPTION` |
| `paymentInfo.payMethodUserAccessToken` | The `accessToken` from `/api/v1/userid/inquiry` |
The token identifies which of the wallet's users is authorising the recurring
mandate, so the wallet can bind the mandate to the right account. The same field
exists on `/api/v1/subscription/change`, so a plan change in a mini program needs
a freshly obtained token too.
Everything else about subscriptions is unchanged — cycles, trials, amounts,
statuses, retries, notifications and cancellation all behave exactly as on
[Subscription creation & validation](/docs/en/support/subscription-creation-validation-faq)
and the pages it links to.
## Do the later renewals need a new token each time?
No. The token is supplied **once, at signing time**, to establish the mandate.
The stored mandate is what later periods are charged against, so you do not
re-run the identity flow on every renewal — renewals are background charges with
no customer present and no front end involved.
You need a fresh token when the customer is in front of you authorising
something new: creating a subscription, or a plan change. Not for period 2
onwards.
If a renewal fails because the customer revoked the mandate in their wallet, that
arrives as a cancellation on the payment-method side rather than as a token
error — see the wallet-revocation behaviour in
[Subscription FAQ](/docs/en/support/subscription-faq).
## Can I send the customer back to a specific page in my mini program after paying?
That is your front end's job, not Waffo's. Because there is no Waffo-hosted page
anywhere in this flow, the redirect URLs on order creation have nothing to act on
here — navigation stays inside the wallet, and control returns to the page that
called `my.tradePay` when the wallet's payment UI closes.
So: resume on the page you left, and decide what to show from **your backend's**
view of the order, which is driven by the webhook. Do not render a success screen
purely on the JS callback, for the reasons in
[Confirming Payment Results](/docs/en/support/payment-results-faq).
## Where in the Portal can I see which mini program a payment came from?
On the **individual payment's detail page**, which carries a mini-program block
with the **App ID** and **App Name** for orders that have one. Open the payment
from **Transactions → Payments** and the block is there alongside the rest of the
order.
What is **not** available: there is no mini-program filter or App ID column on
the payments **list**, so you cannot slice the list by mini program. If you run
more than one mini program and need per-app reporting, key it off your own order
records — you know which `appId` you sent on each `/api/v1/order/create` — rather
than expecting the Portal to group it for you.
Mini-program products are named in transaction exports, so the traffic is
identifiable as mini-program traffic; it is the *which* mini program that is
detail-page only.
## Is the customer the same person as on my web checkout?
Not automatically, and you should not try to make Waffo answer this. The `userId`
you get back is the wallet's **public identifier scoped to your mini program** —
it is not an email address, not a phone number, and not a cross-channel identity.
That means:
* You cannot match a mini-program shopper to a web-checkout shopper by comparing
identifiers. There is nothing shared to compare.
* If you publish two mini programs, do not assume the identifier for the same
human is the same in both. Design as though it is not.
* The workable pattern is your own account system: the customer signs in to your
account inside the mini program, you store the wallet `userId` against that
account once, and from then on your account ID is the identity that spans
channels.
Where you have been granted a contact scope, an email address can help you
*offer* a match to the customer — but treat it as a hint to confirm with them,
never as proof that two records are the same person.
## Where do I look next?
* [Confirming Payment Results](/docs/en/support/payment-results-faq) — why the
webhook, not the JS callback, is your source of truth.
* [Order Creation Field Guide](/docs/en/support/order-create-field-guide) — the rest
of the order-create contract.
* [Payment Method Configuration & Order Limits](/docs/en/support/payment-method-config-and-limits-faq)
— reading back which methods, currencies and amount ranges you have.
Stuck on the app identifier or a scope approval? [Contact
support](mailto:support@waffo.com) — both are provisioning steps, not code
problems.
# Running Multiple Brands Under One Account
Source: https://waffo.com/docs/en/support/multi-brand-and-sub-merchant-faq
How to run several brands or business lines under one Waffo account using SubMIDs: per-brand checkout name and logo, which level wins, what happens before you configure anything, what becomes fixed, extra information some payments collect, and how the USD amounts in your reports are converted.
This page is for accounts that sell under more than one brand, storefront or
business line. Page-level styling controls are covered by the Customizing the
Checkout Page docs; this page is about the account structure that makes those
controls differ per brand.
## I have several brands. Do I need a separate Waffo account for each?
No, and you do not need to change your merchant type either. The supported
structure is to keep your existing MID and attach a **SubMID per brand or
business line**. Each SubMID can carry its own customer-facing identity while
your account, contracts and reporting stay in one place.
What a SubMID gives you:
| Per SubMID | Effect |
| - | - |
| Cashier display name | The name your buyers see on the checkout page and receipt |
| Merchant logo | Its own logo list, uploaded separately from the MID's |
| Checkout styling | Its own colors and sizes, or inheritance from the MID |
| Payment-method ranking | Its own ordering, per country |
| Order creation | Orders can be created against the MID or against a SubMID |
## Which name and logo does the checkout show for a SubMID order?
Resolved highest-first:
| Priority | Source |
| - | - |
| 1 | What you sent on the order — `brandInfo.cashierDisplayName` (max 128 chars) and `brandInfo.cashierLogoUrl` (max 512 chars) |
| 2 | The **SubMID's** configured value |
| 3 | The **MID's** configured value |
So the MID acts as the fallback identity: a SubMID with nothing configured
presents the MID's name and logo rather than presenting nothing. If neither
level has a display name configured, the merchant-name field is simply hidden —
your KYB-registered legal name is not substituted in.
Both order-level fields are per session and change nothing stored. They are the
right tool when brand is a property of the individual transaction rather than of
a business line.
## Does the MID always act as the fallback identity?
For a standard merchant account, yes: an order on a SubMID with no display name
of its own shows the MID's. There is one exception — accounts set up as
**institutional merchants** resolve the display name from the **SubMID only**.
If that SubMID has no display name configured, the merchant-name field is
hidden rather than falling back to the account level.
If you are unsure which applies to your account, check whether a SubMID with no
configuration shows your account-level name on a test checkout page.
## My brand varies per transaction, not per business line. Do I still need SubMIDs?
Not necessarily. If the deciding factor is the individual order rather than a
lasting business line, send the identity on the order instead:
| Field | Limit | Note |
| - | - | - |
| `brandInfo.cashierDisplayName` | 128 characters | Overrides the configured display name for that session |
| `brandInfo.cashierLogoUrl` | 512 characters | Accepts either an image URL (`http://`/`https://`) or a pre-created logo ID (`logo_…`) |
An invalid value in either field is **dropped silently** and the order is still
created — it renders with your configured identity, with no error to explain
the difference. Validate what you send.
The logo ID form, and how IDs are allocated per MID and per SubMID, are covered
in the Customizing the Checkout Page docs.
Use SubMIDs when the difference is durable (a brand, a storefront, a business
line with its own look and its own reporting cut); use the order fields when it
is per transaction.
## I am about to attach my first SubMID. Will anything change for my existing traffic?
No. Until you configure something at the SubMID level, everything continues to
resolve to your existing MID configuration — same checkout name, same logo,
same styling, same ranking. Attaching a SubMID is not itself a customer-visible
change; configuring it is.
In the Portal each SubMID is listed with its state, so you can see at a glance
what has diverged:
| Tag | Meaning |
| - | - |
| **Using MID config** | Nothing configured at this level yet |
| **Customized** | This SubMID has its own logos or styling |
**Reset** on a customized SubMID returns it to Using MID config.
## What becomes fixed once I start using a SubMID?
The structure. Once a SubMID has completed its initial onboarding steps, or has
processed live transactions, the arrangement is **locked and cannot be
reversed** — you cannot unwind that SubMID back into the MID afterwards.
Consequence for planning: decide your brand-to-SubMID mapping before you go
live rather than after. Adding further SubMIDs later is fine; collapsing
existing ones is not.
## Can each brand have a different set of payment methods and a different order?
Ordering, yes — payment-method ranking is configured per country and can be set
per SubMID, independently of the MID. Which methods exist at all is governed by
your agreements, not by the SubMID structure, so a SubMID cannot make a method
available that your account does not have.
See the Checkout Payment Method Display Rules page for how ranking, grouping
and availability interact.
## Some of my transactions ask the buyer for extra details. Why only some?
Certain payment methods require information Waffo cannot supply from the order
— most commonly a verified email address — before they will accept the payment.
When a payment routes to such a method, the checkout collects what that method
requires, and where the method requires verification, it also asks the buyer to
confirm the address with a one-time code sent to it.
Two consequences for your integration:
* It varies **per transaction**, because it follows the routed payment method,
not your account or your brand. Two orders of the same amount from the same
buyer can differ.
* Sending a usable `userInfo.userEmail` at order create reduces typing: it can
prefill the field. For some methods the address is fixed for that payment and
cannot be replaced by the buyer.
The mechanics of that step — code lifetime, the verified window, length limits
— are in the Checkout Forms, Prefill & Error Handling page.
## Your report shows a USD amount that does not match what I calculated. Why?
Because report USD figures are not converted at the rate of the moment of the
transaction, and not at a rate we quote. The basis is:
```
TPV (USD) = Σ [ order amount / market closing rate on the day the
transaction succeeded, in Waffo's reporting time zone ]
```
Three things in that formula explain almost every discrepancy:
| Element | Detail |
| - | - |
| **Rate** | The **market closing rate** for the day — not an intraday rate, and not the rate applied to the payment itself |
| **Day** | The day the transaction **succeeded**, in Waffo's reporting time zone — not your local calendar day, and not the day the order was created |
| **Per transaction** | Each transaction is converted on its own day, then summed. A period total is not the period's amount at one single rate |
Fallback: when the relevant day is not a trading day, or the rate cannot be
retrieved, the most recent available closing rate is used instead.
So a month's report total will normally differ from "total local currency × the
rate I looked up today", and both numbers can be right. Reconcile
transaction-by-transaction, on the success date, if the figures must agree.
## Which transactions are counted in the report volume?
Only transactions that reached a **successful** state, converted on the day
that success occurred. A transaction that was created in one period and
succeeded in the next is counted in the period it succeeded — which is a second
common reason a month-boundary total does not match an order-creation-based
count of your own.
## Is the report USD amount the same as what my buyer paid or what I get settled?
No — treat them as three different numbers with three different purposes:
| Number | What it is |
| - | - |
| Report USD amount | A reporting conversion of the order amount, on the basis above. For comparing volumes across currencies |
| What the buyer paid | The amount in the buyer's payment currency, at the rate applied to that payment. Shown on the checkout page and the receipt |
| What you are settled | Determined by your settlement currency and cycle. See the settlement documentation |
Only the second one is money that moved at that rate. The first is a
measurement.
## Where do I see per-brand numbers?
Transaction and report data carry the SubMID, so you can separate business
lines in the Portal and in exports without splitting accounts. Report subjects,
delivery and subscription options are covered by the reporting documentation.
***
Planning a multi-brand setup, or seeing report figures you cannot reconcile?
[Contact support](mailto:support@waffo.com) — for reconciliation, include the
order IDs and the success dates you are comparing against.
# Which Amount and Currency Fields Do I Check?
Source: https://waffo.com/docs/en/support/order-amount-and-currency-fields-faq
An order carries several amounts — orderAmount, originalAmount, finalDealAmount, the amount the user actually paid, and a user fee — in up to three currencies. This page says which field to compare when you verify an order, which one to ship goods against, why finalDealAmount legitimately differs from orderAmount, and which currency to create the order in.
Most amount questions turn out to be the same question: *there are several amounts on the
order and I do not know which one is mine.* This page is the field-by-field answer.
Two neighbouring pages cover the adjacent ground and are the faster read for their topic:
| Page | Use it for |
| - | - |
| [Amounts and currencies](/docs/en/developer-docs/core-concepts/currency) | Decimal-place rules per currency, and the same-currency vs cross-currency definition |
| [Order Amounts, Limits & Declines](/docs/en/support/order-amount-precision-and-decline-faq) | Amount **rejected** at order create — precision, rounding, limits |
| [Exchange Rates in Payments & Settlement](/docs/en/support/exchange-rates-faq) | Which rate is used, when it is fixed, and where to see it |
| **This page** | Which amount field means what, and which one to compare |
## Which amount fields does an order carry?
| Field | Where it appears | What it is |
| - | - | - |
| `orderAmount` | You send it on create; echoed on inquiry and in the payment notification | **The amount you asked for**, in `orderCurrency` |
| `orderCurrency` | Same | The currency you priced in |
| `originalAmount` | Inquiry / notification | The order amount **before** a discount was applied. Absent when no discount applied |
| `finalDealAmount` | Inquiry / notification | The final amount the user paid — see below for why it can differ from `orderAmount` |
| `userCurrency` | Optional on create; echoed back | The user's payment currency, when it is not `orderCurrency` |
| `payMethodResponse.userPaymentAmount` | Inquiry / notification, inside `paymentInfo.payMethodResponse` | The amount the user actually paid **on the payment method's side** |
| `payMethodResponse.userPaymentCurrency` | Same | The currency of that payment |
| `orderAction.actionData.paymentAmount` / `paymentCurrency` | Order-create response only | What the user is being asked to pay, for merchants rendering their own checkout |
| `orderAction.actionData.userFeeAmount` / `userFeeCurrency` | Order-create response only | A fee charged to the **payer** on top of your amount, itemised |
| `remainingRefundAmount` | Refund response / refund inquiry | How much of the order you can still refund |
All amounts are **strings**, not numbers — the published reason is float precision: `0.1 + 0.2`
is not `0.3`, which is unacceptable for money. Parse them as decimals.
## How should I verify that an order is the one I created?
Compare **`orderAmount` + `orderCurrency`** against the values you sent, keyed by your own
`paymentRequestId` or `merchantOrderId`. Those two fields are your own input echoed back, so
they are the only pair that is guaranteed to equal what you asked for.
Do **not** verify by comparing `finalDealAmount` to your expected amount. It is a different
quantity by design, and two entirely normal situations make it differ — which is the next
question.
> [!WARNING]
>
> Verify against `orderAmount`, but do not treat a redirect back to your site as the result.
> The authoritative result is the webhook or [Order Inquiry](/docs/en/support/payment-results-faq),
> whatever the amounts say.
## Why is `finalDealAmount` different from `orderAmount`?
The API contract names two reasons, and both are normal:
1. **Your order currency is not the user's payment currency.** A cross-currency order is
priced by you in one currency and paid by the user in another, so the two numbers are in
different units and will not match.
2. **The user paid an additional service fee.** Where the payment method charges the payer a
fee, that fee is included in `finalDealAmount`, so it is larger than your amount.
So `finalDealAmount` answers *"what did this cost the user"*, and `orderAmount` answers
*"what did I charge"*. Ship goods and run business validation against `orderAmount`; use
`finalDealAmount` only when you want to show the user what they paid.
## Can `orderCurrency` and `userCurrency` really be different?
Yes — that is exactly what a cross-currency order is: you price in your own currency and the
user pays in theirs. It is not an error state and it is not limited to any one payment-method
category. The published definition and the parameters that go with it are in
[Amounts and currencies](/docs/en/developer-docs/core-concepts/currency); the practical rule for
order creation is that a cross-currency order needs `payMethodCountry` when you have not
pinned `payMethodType` or `payMethodName`, while `userCurrency` itself stays optional.
If your reconciliation assumes one currency per order, build for three fields from the start:
`orderCurrency` (yours), `userCurrency` / `userPaymentCurrency` (the user's), and your
settlement currency (see [Settlement calculation explained](/docs/en/support/settlement-calculation-explained)).
They are three separate things and only the first is under your control at order time.
## What exactly did the user pay, in their own currency?
`paymentInfo.payMethodResponse.userPaymentAmount` with
`paymentInfo.payMethodResponse.userPaymentCurrency`. That block is the payment method's own
view of the transaction and also carries:
| Key in `payMethodResponse` | What it is |
| - | - |
| `payMethodRefId` | The reference the **user** sees in their own payment history — quote this one when a user disputes whether they paid |
| `exchangeRate` | The rate the payment method used during the user's payment |
| `maskCardData` | For a successful card payment, the first six and last four digits |
| `userOpenId` | The user's unique id at that payment method |
| `userPaymentAmount` / `userPaymentCurrency` | The amount and currency actually paid |
It is a JSON string, and which keys are present depends on the payment method — read it
defensively rather than assuming a fixed shape.
## Can I compute the user's net payment as `finalDealAmount` minus the user fee?
No — not from the inquiry response. `userFeeAmount` and `userFeeCurrency` are returned in
**`orderAction.actionData` on the order-create response**, not as top-level fields on Order
Inquiry or in the payment notification. So at the moment you are reconciling, you generally
do not have the fee as a separate number to subtract, and a subtraction across two currencies
would be wrong anyway.
Use instead:
* **Did I get charged the right amount?** → `orderAmount` + `orderCurrency`.
* **What did the user pay in total?** → `finalDealAmount`, or `userPaymentAmount` for the
payment-method-side figure.
* **What was the fee?** → capture `actionData.userFeeAmount` at create time and store it, if
you need it later.
## Is a user fee only charged on over-the-counter payments?
The API does not scope it that way. `userFeeAmount` / `userFeeCurrency` live in
`orderAction.actionData`, which is the block for merchants building their own checkout, and
nothing in the contract restricts them to one `payMethodType`. Treat a payer fee as *possible*
on any method and read the field, rather than deciding by method category — the same advice as
for `finalDealAmount`: read it, do not infer it.
If you use the Waffo checkout page, the user sees the fee on the checkout itself; you do not
have to render it.
## What is `originalAmount`, and which amount do I validate a discount against?
`originalAmount` is the order amount **before** a discount was applied. When a promotion code
or coupon has been used, `orderAmount` is the discounted, payable amount and `originalAmount`
is the pre-discount figure you priced.
So for business checks — entitlement, fulfilment, "did they pay enough for this SKU" — use
`orderAmount`, because that is what was actually charged. Use `originalAmount` only for
display or for measuring discount impact. Coupon and promotion-code mechanics, including what
happens to a discount on refund, are in
[Coupons & Promotion Codes](/docs/en/support/coupons-and-promotion-codes-faq).
## Which amount should I reconcile my finances against?
Neither `orderAmount` nor `finalDealAmount` on its own — those are order-level fields, before
fees and before settlement conversion. For money reconciliation use the settlement reports,
which carry the amount, the fees and the settlement currency together:
[Settlement reports](/docs/en/support/settlement-reports-faq) and
[Settlement calculation explained](/docs/en/support/settlement-calculation-explained).
The order-level fields are for *order* reconciliation — proving that the order you created is
the order that was paid. Keep the two exercises separate; that is the single most common cause
of "your numbers do not match mine".
## Do I have to create the order in the local currency?
No. `orderCurrency` is **your pricing currency**, and a cross-currency order — you price in
one currency, the user pays in another — is a supported, first-class flow. Whether a
particular payment method can serve a particular order currency depends on the contracts on
your account, which is a lookup, not a guess:
| Where to look | What it tells you |
| - | - |
| `/api/v1/paymethodconfig/inquiry` | One entry per payment-method contract in force on your account, each with its `currency` and its `orderAmountMin` / `orderAmountMax` |
| Portal → Payin | The same contracts, as a screen you can read and filter — see [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq) |
| [Payment methods](/docs/en/essentials/payment-methods) | The public list, filterable by country, type and payment currency |
Because that response is keyed on **contracts**, one payment method can appear several times —
once per currency. `payMethodName` is not a unique key. Details in
[Payment Method Config & Limits](/docs/en/support/payment-method-config-and-limits-faq).
## A payment method is listed in one currency. Can I still price in another?
Read the two currencies separately:
* **Payment currency** — what the user is charged in. For a local payment method this is
effectively fixed by the method itself.
* **Order currency** — what you priced in, and what you are billed and settled on.
A method whose payment currency is a single local currency can still serve an order you priced
in a different currency; that is the cross-currency case, and the conversion is what the
exchange rate is for. What you cannot do is assume it: check the contract entry for the
combination you intend to send, and handle the rejection codes for the case where the
combination is not configured — it comes back as `A0018`, the contract-mismatch code, not as
a currency-specific error; see
[Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement).
## Where do I see the exchange rate for a cross-currency order?
Two different rates, two different fields:
| Field | Meaning |
| - | - |
| `orderAction.actionData.exchangeRate` | Defined in the contract as `paymentAmount / orderAmount`, present when `orderCurrency` differs from `paymentCurrency` |
| `payMethodResponse.exchangeRate` | The rate the payment method itself used when the user paid |
They answer different questions and can differ. When the rate is fixed, for how long, and
which rate a refund uses are all in
[Exchange Rates in Payments & Settlement](/docs/en/support/exchange-rates-faq).
## How many decimals may I send, and does it depend on the currency?
Yes, it depends on the currency, and the rule differs between same-currency and cross-currency
orders. The published rule: most currencies take two decimals per ISO 4217, a set of
currencies must carry **no** decimals at all in a same-currency order, and in a
cross-currency order every currency must follow its ISO 4217 precision with no exceptions.
The full list and the rejection behaviour are in
[Amounts and currencies](/docs/en/developer-docs/core-concepts/currency) and
[Order Amounts, Limits & Declines](/docs/en/support/order-amount-precision-and-decline-faq).
Per-payment-method amount ranges are a separate check from precision: they come from
`orderAmountMin` / `orderAmountMax` on the contract, so validate against those before you
create the order rather than discovering the limit from an error code.
## Which amount fields does a refund carry?
| Field | Meaning |
| - | - |
| `refundAmount` | What you asked to refund, in the order currency |
| `finalDealAmount` | The amount the **user receives**. It may exclude a service or tax fee the user originally paid, so it can be smaller than the corresponding order amount |
| `remainingRefundAmount` | What is still refundable on that order after this refund |
| `userCurrency` | Present when the order currency is not the user's payment currency |
`remainingRefundAmount` is the field to drive your own "can I still refund" logic from, rather
than recomputing it from your records — it already accounts for every refund on the order,
including ones you did not initiate yourself. Refund rules themselves are in
[Refund FAQ](/docs/en/support/refund-faq) and
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq).
***
Still not sure which field to compare? [Contact support](mailto:support@waffo.com) with your
`paymentRequestId` and the two numbers that disagree.
# Order Amounts, Limits & Declines
Source: https://waffo.com/docs/en/support/order-amount-precision-and-decline-faq
Why an amount that looks valid is rejected, how same-currency and cross-currency orders are checked differently, what processingAmountLimits min/max/multiple mean, which limit produced which error code, how payment method type decides whether your customer can pay at all, how to read a card decline, and how long an unpaid order stays open.
Everything about why an order was refused before the customer ever saw a
payment page, plus how to read a decline once they did. For what the order
fields mean see the
[Order creation field guide](/docs/en/support/order-create-field-guide); for
confirming outcomes see
[Confirming payment results](/docs/en/support/payment-results-faq); for the fixed
per-currency decimal table see
[Currency](/docs/en/developer-docs/core-concepts/currency).
## Why was my amount rejected for having too many decimals?
Because there are **two** precision rules, not one, and they apply to different
amounts:
| Layer | Applied to | Effect |
| - | - | - |
| **Order currency, ISO decimals** | Your `orderAmount`, always | Rejected at creation with `A0003` if the amount has more decimals than the order currency's ISO 4217 definition, or breaks that currency's multiple rule. |
| **Processing precision and multiple** | The amount that is actually charged | If the charged amount cannot be expressed in the processing precision, or breaks the payment method's multiple rule, that payment method is removed. If none survives you get `A0018`. |
The second layer is where same-currency and cross-currency diverge, and it is
the part worth internalising.
## Same-currency and cross-currency are checked differently. How?
The second layer is applied to **whichever amount the customer is charged**:
* **Same-currency order** — the charged amount *is* your `orderAmount`, so the
strict processing precision and multiple are applied directly to what you
submitted. Some currencies have a processing precision **narrower than their
ISO decimals**: those currencies accept two decimals by the ISO rule but are
processed to whole units. An amount with a fractional part in such a currency
passes the first layer and is then rejected by the second.
* **Cross-currency order** — the charged amount is the **converted** amount, so
the strict check lands there and not on your `orderAmount`. Waffo converts,
then adjusts the result to the processing currency's precision and the
payment method's multiple, so an `orderAmount` carrying decimals the
processing side could not accept is no longer your problem.
**You have to know which of the two scenarios you are in.** The same
`orderAmount` in the same currency can be accepted in one and rejected in the
other, and the difference is entirely which currency the customer pays in. If
you price in one currency and charge in another, do not carry over the
same-currency validation you built for your domestic flow.
## Which direction does Waffo round the converted amount?
**Up.** After conversion, the amount is adjusted upward to fit the processing
currency's precision and then, where the payment method has one, upward to the
next valid multiple. Rounding only ever appears in **cross-currency** orders —
a same-currency order is never re-rounded, it is validated and passed through.
The residual is reported to you: the inquiry response for a cross-currency
order returns the converted amount, the rate applied, and the adjustment made,
so you can reconcile the charged amount against your priced amount without
recomputing it. See
[Exchange rates](/docs/en/support/exchange-rates-faq) for when the rate is fixed.
## Why was 10.50 rejected when 10 was accepted?
Because the limit on an amount is not just a range — there is also an **integer
multiple**. A payment method can require that the amount be a whole multiple of
some unit, in which case any value that is not a multiple of it is refused
regardless of how comfortably it sits inside the min/max.
The multiple is dimensioned by **payment method plus the currency actually being
processed**, so:
* The same payment method can carry a multiple in one currency and none in
another.
* Two payment methods in the same currency can carry different multiples.
* A method with a multiple almost always has **no decimal part at all** — if a
method demands multiples of a whole unit, `10.50` cannot ever satisfy it.
Amounts of this shape are the single most common cause of "my amount is inside
the limits and still rejected".
## How do I find the limits before I create the order?
Read them from the payment method query response. Each available method returns
`processingAmountLimits`, keyed by the currency being processed, with three
fields:
| Field | Meaning |
| - | - |
| `min` | Lowest acceptable amount in that currency. |
| `max` | Highest acceptable amount. |
| `multiple` | The amount must be a whole multiple of this. Zero or absent means no multiple constraint. |
Both local and converted currencies appear in that map, so a cross-currency
method reports the limits in the currency the customer will actually be
charged. Validate against `multiple` as well as `min`/`max` in your own
pre-flight check — that is the field most integrations skip.
## Which error means which limit was hit?
| Code | What it means here | What to do |
| - | - | - |
| `A0003` | The order amount's decimals or multiple do not fit the **order currency**. | Fix the amount you submit. |
| `A0008` | The order amount is outside the **contract's** amount range for every candidate agreement. | The amount itself is the problem, not its shape. |
| `A0018` | No payment method survived filtering — including because the **charged** amount fails the processing precision or the method's multiple. | Re-read `processingAmountLimits` and adjust; also check the method and terminal you passed. |
| `A0029` | The card credential you referenced is not usable — unknown, wrong status, or not this user's. | See the token section below. |
Note that `A0018` is a catch-all for "nothing was available", so it is also
what you get for a terminal mismatch or an over-long subscription. Read it as
"filtering left nothing", not as "wrong payment method name". See
[Order creation field guide](/docs/en/support/order-create-field-guide) for the
other `A0018` causes.
## Why is a payment method unavailable to my customer?
Because a payment method's **type** determines how the customer can finish, and
some types can only be completed one way. A method can be restricted to
specific customer terminals, and Waffo enforces that at order creation:
* Waffo holds an allowed-terminal list per payment method. If the
`userTerminal` you sent is not on that method's list, **the method is removed
from the candidates**. If nothing is left, creation fails.
* App-launch-only methods are the classic case: some e-wallet, BNPL and digital
banking methods complete by handing off to a mobile app, so they simply do
not exist as an option on a desktop browser.
The consequence: **`userTerminal` is not a analytics hint, it is a routing
input.** Sending a desktop value for a mobile session, or defaulting it to one
value for all traffic, will make perfectly available methods disappear and
produce a creation failure your logs will attribute to the wrong thing. Detect
it per session. See
[Order creation field guide](/docs/en/support/order-create-field-guide) for the
accepted values.
## Bank transfer and over-the-counter: why is nothing happening after I create the order?
Because for those types, **creating the order and paying are separate events**.
The customer leaves with an account number, a reference or a slip, and pays
later through their bank or at a counter. Nothing else happens at Waffo until
they do.
* Expect a long gap between creation and result, and expect two notifications:
one when the order is ready to be paid and one when the money arrives.
* The window in which the customer can still pay is set **per payment method**,
not by a single platform value, so two bank-transfer methods in the same
country can close at different times.
* Do not cancel or re-create the order because it has been quiet for hours.
See
[Confirming payment results](/docs/en/support/payment-results-faq) for what counts
as final.
## Can I set the customer's default card when I create the order?
No — order creation is not where the default card is set. Use the dedicated
saved-payment-method endpoint; see
[Tokenization & card payments](/docs/en/support/tokenization-faq).
What order creation *does* do with a saved credential is validate it strictly.
Passing `paymentInfo.userPaymentAccessToken`:
| Situation | Result |
| - | - |
| The token does not exist | Creation fails (`A0029`). |
| The token is not in a usable state | Creation fails. |
| The token belongs to a different `userId` | Creation fails. Tokens are scoped to the customer who created them. |
| You also passed raw card data | Creation fails — the two are mutually exclusive. |
So a saved-card charge either goes ahead on that exact credential or it does
not go ahead at all. There is no silent fallback to another stored card, which
is deliberate: the customer must not be charged on a card you did not name.
## Does `tokenizationNotifyUrl` replace `notifyUrl`?
No. They are separate channels and you need both:
* `notifyUrl` receives the **payment** result, as always.
* The tokenization notification URL receives the **card-binding** result only.
Configuring one has no effect on the other, and a card bound during a payment
produces a notification on each. See
[Tokenization & card payments](/docs/en/support/tokenization-faq) for where the
tokenization URL is configured.
## A card was declined. Should I retry?
Only after you know which of four kinds of decline you got. Waffo maps the card
scheme's response into a Waffo failure code and a retry class:
| Class | What it means | What to do |
| - | - | - |
| **Do not retry** | The scheme has told you this attempt will never succeed — the account is closed, invalid, reported lost or stolen, or the transaction is not permitted for this cardholder. | **Ask the customer for a different card.** Do not retry, including on a schedule. Repeat attempts on these can attract scheme fees. |
| **Retry after the details are updated** | The credential itself is stale or the authentication failed — an expired card, or authentication data that did not verify. | Have the customer re-enter or re-bind the card, then charge again. Retrying the same stored credential unchanged will fail the same way. |
| **Retry later** | A temporary condition on the issuer's side — funds not available right now, a frequency or amount limit reached, the issuer unreachable. | Retry on a delay. For subscriptions this is what the retry strategy already does for you. |
| **Other** | Everything else, including card products unsuitable for this transaction. | Do not assume retryable. Where the guidance is to obtain another form of payment, treat it as the first class. |
Two rules that matter more than the taxonomy:
* **A permanent decline is a card-change prompt, not a retry candidate.** The
most expensive integration mistake here is looping on a decline that can never
clear.
* Waffo does not publish a per-code retry ceiling, and neither should your
runbook assume one. Retry caps for recurring charges follow card scheme rules
— see
[Subscription retries & payment methods](/docs/en/support/subscription-retry-and-payment-methods-faq).
## The customer told their bank to stop the recurring payment. What must I do?
**Stop all recurring deductions for that customer immediately.** The schemes
have specific response codes for a cardholder revoking authorization — for a
single merchant or for all merchants — and for a cardholder asking that a
recurring or instalment payment be stopped. These are non-retryable by
definition, and continuing to attempt them is a compliance problem, not just a
failed payment.
Concretely:
* Cancel the subscription rather than letting the retry schedule run out.
* Do not create a new subscription on the same credential.
* If the customer later wants to resume, they must authorize again from
scratch, with a fresh consent.
## How long does an unpaid order stay open?
There are two Waffo-side defaults, split by whether the method is a card, and
neither is a contract term you should build against:
| Order | Default window |
| - | - |
| Card-type payment methods | A **long** platform default, measured in hours. Cards are given a long window deliberately: results arrive synchronously, and a short window risks closing an order that then succeeds. |
| All other payment methods | A **short** platform default — but in practice superseded by the payment method's own configured timeout, which is the value that governs bank transfer, virtual account and over-the-counter flows. |
The platform defaults are environment configuration and can change; if the
window matters to your business, send `orderExpiredAt` yourself on the methods
that accept a merchant-specified expiry (the `Order Expiry Customization` column
on **Service → Payin** tells you which). It is ignored on methods that do not.
See
[Confirming payment results](/docs/en/support/payment-results-faq) for what
`orderExpiredAt` does and does not control, and for why a payment can still
land after Waffo closed the order.
## Stablecoin: can one payment be flagged with two exception types?
No. An exception carries **exactly one** type — the field is single-valued, so a
payment that is both late and short is recorded under one classification, not
two. The types are:
| Type | Meaning |
| - | - |
| `OVERPAID` | More received than the order amount. |
| `UNDERPAID` | Less received than the order amount. |
| `LATE_PAID` | Funds arrived after the order was already final. |
| `WRONG_TOKEN` | The token received is not the token the order expected, or is not a recognised token at all. |
| `WRONG_CHAIN` | Funds arrived on a chain the order did not expect. |
| `NO_MATCH_ORDER` | No order could be matched to the transfer. |
| `UNSUPPORTED_WALLET_ADDRESS` | The address involved cannot be handled automatically. |
| `BLOCKED` | Funds refused on risk grounds. |
Two ordering rules you can rely on:
* **Token identity is resolved before amount.** An unrecognised or mismatched
token is classified as a token problem and the amount is never compared — so
do not expect an `UNDERPAID` on a transfer that was in the wrong token.
* **The return address is checked last, and it wins.** If an exception would
otherwise have been resolved automatically but the address it would return to
is not a plain wallet address, the exception is **re-classified as an address
problem and routed to manual handling**. That is why an overpayment can
surface as an address exception.
For what happens to the money in each case, see
[Stablecoin exceptions & refunds](/docs/en/support/stablecoin-exceptions-faq).
# Order Creation Field Guide
Source: https://waffo.com/docs/en/support/order-create-field-guide
The complete /api/v1/order/create request field table with every length limit, which fields are mandatory, what happens when a value is too long, how the paymentRequestId idempotent check actually behaves when you resend, and the field rules behind the common validation errors.
This page explains the order-creation fields merchants ask about most, the
complete field table with its length limits, and the validation rules behind
the most common errors.
## The three IDs, and which one to use where
| Field | Who generates it | Purpose |
| - | - | - |
| `paymentRequestId` | **You** (String 32) | Identifies this create **request**, and is used for the idempotent check. Reuse the same value to safely retry the same order; a different order must use a new value. |
| `merchantOrderId` | **You** (String 64) | Your business order number, carried through for your own reference and reconciliation. |
| `acquiringOrderId` | **Waffo** (String 32) | Waffo's order ID, returned in the create response. Use it in follow-up calls such as inquiry and refund, and to match webhook notifications. |
**Idempotency in practice:** retrying with the same `paymentRequestId` and
identical parameters returns the same order instead of creating a duplicate.
Retrying with the same `paymentRequestId` but **different** parameters is
rejected with `A0011 Idempotent param mismatch error`.
## Is there one table showing which fields are mandatory and how long they can be?
Yes — this one. "Mandatory" here means *mandatory for every order*. A second,
larger group of fields is optional in the contract but becomes mandatory for
particular payment methods or scenarios; those are listed separately in
[Conditionally Required Fields by Payment Method](/docs/en/support/conditional-required-fields-faq),
and the lookup table is in [API Reference Tables](/docs/en/support/api-reference-tables).
**Top level**
| Field | Mandatory | Max length |
| - | - | - |
| `paymentRequestId` | Yes | 32 |
| `merchantOrderId` | Yes | 64 |
| `orderCurrency` | Yes | 12 |
| `orderAmount` | Yes | 24 |
| `orderDescription` | Yes | 128 |
| `orderRequestedAt` | Yes | — (timestamp) |
| `notifyUrl` | Yes | 1024 |
| `orderExpiredAt` | No | — (timestamp) |
| `userCurrency` | No | 12 |
| `successRedirectUrl` | No | 1024 |
| `failedRedirectUrl` | No | 1024 |
| `cancelRedirectUrl` | No | 512 |
| `extendInfo` | No | 128 |
| `paymentTokenData` | No | 8192 |
| `promotionInfo.promotionCode` / `.couponId` | No | 64 each |
**`merchantInfo`** (the object itself is mandatory)
| Field | Mandatory | Max length |
| - | - | - |
| `merchantId` | Yes | 64 |
| `subMerchantId` | No | 64 |
**`userInfo`** (the object itself is mandatory)
| Field | Mandatory | Max length |
| - | - | - |
| `userId` | Yes | 64 |
| `userEmail` | Yes | 64 |
| `userTerminal` | Yes | 32 |
| `userPhone` | No | 30 |
| `userCountryCode` | No | 3 |
| `userFirstName` / `userLastName` | No | 64 each |
| `userCreatedAt` | No | — (timestamp) |
| `userBrowserIp` | No | 128 |
| `userAgent` | No | 256 |
**`paymentInfo`** (the object itself is mandatory)
| Field | Mandatory | Max length |
| - | - | - |
| `productName` | Yes | 32 |
| `payMethodType` | No | 256 |
| `payMethodName` | No | 256 |
| `payMethodProperties` | No | 1024 |
| `payMethodCountry` | No | 3 |
| `payOption` | No | — |
| `userPaymentAccessToken` | No | 256 |
| `payMethodUserAccountNo` | No | 64 |
| `payMethodUserAccountType` | No | 24 |
| `cashierLanguage` | No | 24 |
| `cashierAppearance` | No | — (JSON string) |
| `receiptTemplateId` | No | 32 |
| `captureMode` | No | 24 |
| `merchantInitiatedMode` | No | 24 |
**`goodsInfo`** — the object is optional. The contract documents `goodsName`
and one of `goodsUrl` / `appName` as expected content (for compliance and risk
review), but the create request only enforces the length limits below; whether a
payment method insists on them is a per-method conditional rule
| Field | Max length |
| - | - |
| `goodsName` | 64 |
| `goodsUrl` | 128 |
| `appName` | 32 |
| `goodsId` | 32 |
| `skuName` | 32 |
| `goodsCategory` | 32 |
| `goodsUniquePrice` | 16 |
| `goodsQuantity` | — (integer) |
**`cardInfo`**, **`riskData`**, **`addressInfo`**, **`brandInfo`** — all optional objects
| Field | Max length |
| - | - |
| `cardInfo.cardNumber` | 24 |
| `cardInfo.cardCvv` | 4 |
| `cardInfo.cardHolderName` | 128 |
| `cardInfo.threeDsDecision` | 12 |
| `cardInfo.cardExpiryYear` / `cardExpiryMonth` | — (integer) |
| `riskData.userType` / `userCategory` | 24 each |
| `riskData.userLegalName` / `userDisplayName` | 128 each |
| `riskData.userRegistrationIp` / `userLastSeenIp` | 24 each |
| `riskData.userIsNew` / `userIsFirstPurchase` | 8 each |
| `addressInfo.address` | 256 |
| `addressInfo.city` / `region` | 32 each |
| `addressInfo.postcode` | 16 |
| `addressInfo.addressCountryCode` | 16 |
| `brandInfo.cashierLogoUrl` / `cashierProductImageUrl` | 512 each |
| `brandInfo.cashierDisplayName` | 128 |
> [!NOTE]
>
> Note the asymmetry that catches people out: `successRedirectUrl` and
> `failedRedirectUrl` allow **1024**, `cancelRedirectUrl` only **512**. A URL that
> passes on two of the three fields can fail on the third.
## If a value is too long, is it truncated or rejected?
**Rejected.** Length limits are enforced on the request, before any order is
created; nothing is silently truncated, and there is no field where an
over-length value is accepted in shortened form. The failure comes back as
`A0003 Parameter validation failed` with the offending field named in the
message, so read the message rather than diffing your payload by hand.
Practical consequence: validate lengths on your side before you call, especially
for fields you build by concatenating (`orderDescription`, `merchantOrderId`,
`goodsName`) and for anything a user typed.
## `extendInfo` is only 128 characters. Can I pass more, and do I get it back?
You get it back, and you cannot make it bigger.
* **You get it back**: `extendInfo` is stored verbatim and echoed on
`/api/v1/order/inquiry` **and** in the payment notification — both carry the
same order structure, so whatever you put in comes back out unchanged in both.
* **128 is the limit**, and it is a request-validation limit, so 129 characters
is an `A0003`, not a truncation.
So treat it as a **key, not a payload**: put your own order reference or a short
routing token in it and look the rest up in your own system. `merchantOrderId`
(64) is the field designed for your business order number; between the two you
have enough to key any lookup. If you need per-order structured data on the
Waffo side, the fields that carry semantics — `goodsInfo`, `riskData`,
`addressInfo` — are the intended homes for it, not `extendInfo`.
## What exactly happens if I resend the same `paymentRequestId`?
`paymentRequestId` is scoped to your merchant ID, and the check runs before any
new order can be created. Two comparisons are made — `orderAmount` and
`orderCurrency`:
| What you resend | What happens |
| - | - |
| Same id, same amount and currency | You get the **existing** order back, not a new one |
| Same id, **different** amount or currency | Rejected with `A0011 Idempotent param mismatch error` |
| Same id, at the same moment as the first call | One call wins; the other gets `A0040 Order cannot be concurrently created` |
When the existing order is returned, what you receive depends on where that
order got to: an order still in progress has its payment step retried and you
receive current payment details, and an order that already reached a final state
replays the stored result — including replaying the original failure, if it
failed. That is deliberate: a retry can never turn a settled order into a
different outcome.
Two consequences worth designing for:
* **Only amount and currency are compared.** Resending the same id with a
different payment method, a different user or a different description does
**not** raise a mismatch — it returns the first order. If you want a genuinely
different order, use a new `paymentRequestId`.
* **`A0040` is a "try again", not a failure.** It means your two calls collided.
Do not create a second order id in response; re-call with the same id, or run
Order Inquiry on it.
## I got `A0040 Order cannot be concurrently created`. Should I retry?
Yes — with the **same** `paymentRequestId`. It is raised when two creates for the
same id are in flight at once, so exactly one of them is doing real work and the
other has been turned away to protect you from a duplicate order. Retry after a
short pause, or go straight to Order Inquiry on that id; either way you converge
on the one order that exists.
What you must not do is treat it as "the order was not created" and reissue with
a fresh id. That is how a genuine double charge gets built.
## Can I send `orderAmount` as 0?
A zero amount is not rejected by the generic amount check — the request-level rule
is that the amount must not be **negative** — but whether a zero-amount order can
actually be created depends on the payment product and the payment method you
are using, and a zero amount is meaningless to most of them.
So do not build a flow on it. If your use case is "verify the card without
charging", the mechanism designed for that is
[tokenization](/docs/en/support/tokenization-faq), not a zero-amount order. If your
use case is a free order, do not create a Waffo order at all.
Negative amounts fail with `A0003`.
## How many decimals may I send, and can an amount be rejected for its shape?
Decimal places are checked against the currency's ISO 4217 precision, and a
value with too many decimals fails with `A0003` — the full rule, including the
currencies that must carry no decimals at all and the different treatment of
cross-currency orders, is in
[Amounts and currencies](/docs/en/developer-docs/core-concepts/currency) and
[Order Amounts, Limits & Declines](/docs/en/support/order-amount-precision-and-decline-faq).
There is a second, less obvious check. Some payment method and currency
combinations accept only amounts that are a **whole multiple of a step** — for
example only multiples of ten, or only whole units with no decimals at all —
and the rule is tied to the combination, not to the currency alone. This is why
an amount that is perfectly valid in a currency can still be rejected on one
particular method. If your prices are not round numbers, test each method you
intend to offer with a realistic amount rather than a tidy one, and treat the
rejection as a configuration fact rather than a bug.
## What format does `orderRequestedAt` need, and does my clock have to match yours?
The format is ISO 8601 to **millisecond** precision, ending in a literal `Z` —
`2023-04-03T03:00:00.000Z`. It is a UTC timestamp: a numeric offset such as
`+08:00` is not an accepted substitute, and neither is second-only precision.
A value that does not parse fails with `A0003`, and the error message states the
expected format.
Your clock does not have to agree with Waffo's — `orderRequestedAt` is recorded
as *your* request time and an order is not rejected for being skewed from server
time. The timestamp that **is** compared against the clock is `orderExpiredAt` —
and the comparison is forgiving: an expiry that is already in the past is
**discarded** at create time and the order is created with the default expiry for
the payment method, so a clock bug on your side does not surface as an error. It
surfaces as an order that stays open longer than you intended. `A0034 The order
expiry time provided by merchant has expired` exists in the contract, but you
will not see it from a past `orderExpiredAt` on create. Send both timestamps in
the same UTC format and the whole class of problem disappears.
## What is `riskData` for, and does leaving it out hurt me?
`riskData` carries what you already know about the user that Waffo cannot see:
whether they are new (`userIsNew`), whether this is their first purchase
(`userIsFirstPurchase`), an individual or a business (`userType`), the IP they
registered from (`userRegistrationIp`), the IP they were last seen on
(`userLastSeenIp`), and their legal and display names.
It is optional. None of it is validated against anything, and omitting it does
not fail the order. What it does is give risk screening more to work with on a
transaction it would otherwise judge on the payment alone — which is why the
fields exist. Two rules if you send it: send **true** values, and send them
consistently, because inconsistent history data is worse than no history data.
Do not fabricate a registration IP or flag every user as returning.
The same logic applies to `userInfo.userBrowserIp` and `userInfo.userAgent`:
optional in general, but expected when you collect the card yourself and post
card, Apple Pay or Google Pay data through the API rather than using the Waffo
checkout — in that flow they are the only signal of the real browser.
`addressInfo` follows the payment method: it is optional in the contract and
becomes mandatory where the method or the scenario requires a shipping address —
see [Conditionally Required Fields](/docs/en/support/conditional-required-fields-faq).
## Do I have to send `goodsInfo`, and is `goodsUrl` or `appName` enough?
The object itself is optional, and the create request does **not** enforce the
fields inside it beyond their length limits — a `goodsInfo` with only `goodsName`
is accepted. The contract documents `goodsName` plus **either** `goodsUrl` **or**
`appName` as the expected content, because that is what compliance and risk
review look at: a web merchant sends the product URL; an app-only merchant sends
the package name, for example `com.example.app123`. They are alternatives, not
both.
Where a field inside `goodsInfo` *is* rejected as missing, the rule comes from
the payment method, not from the object: some methods require merchandise
details and report the missing path the same way as any other conditionally
required field — see
[Conditionally Required Fields](/docs/en/support/conditional-required-fields-faq).
So the practical advice is: either omit the object or send it complete. Omitting
it does not by itself get an order blocked, but it removes the merchandise
context from risk screening, which for some business categories is exactly the
context that gets a borderline transaction approved.
## Can I restrict payment to specific card BINs?
Yes, through `paymentInfo.payMethodProperties`, which is a JSON string. Two keys
are involved:
| Key | Meaning |
| - | - |
| `cardPrefix` | The permitted card prefixes (BINs). **4 to 9 digits** each, comma-separated, up to **80** prefixes |
| `cardPrefixPurpose` | Why you are restricting: `restriction` or `promotion` — this selects the message the user is shown |
Digits only, and each prefix must be within the 4-to-9 digit range; anything else
is an `A0003`. Set `cardPrefixPurpose` deliberately: a shopper who is told "this
card is not eligible for this promotion" reacts very differently from one told
"this card is not accepted", and the two wordings come from that one value.
The same properties field is where a small number of other method-specific keys
live, including the payer tax identifier some markets require — see
[Conditionally Required Fields](/docs/en/support/conditional-required-fields-faq).
## Can I decide whether a payment goes through 3DS?
You can express a decision, on card-like payments, with
`cardInfo.threeDsDecision`. It takes exactly two values:
| Value | Meaning |
| - | - |
| `3DS_FORCE` | Authenticate with 3DS |
| `NO_3DS` | Do not authenticate with 3DS |
Anything else is rejected as invalid card data. Two limits to understand before
you rely on it:
* It applies only to **card-like** payments where the **customer is present**.
On a merchant-initiated transaction — a charge you make against a stored token
with no customer in session — the field is not applied.
* It is a decision you express, not a guarantee about the issuer. Whether an
authenticated payment then succeeds, and whether an unauthenticated one is
declined by the bank, is the issuer's call.
If you want to collect card numbers on your own page at all, that is a
compliance question before it is a parameter question: you need the appropriate
card-data qualification, and the alternative — letting Waffo's checkout or the
tokenization flow handle the card — is what the
[tokenization](/docs/en/support/tokenization-faq) and
[checkout](/docs/en/developer-docs/integration/checkout/steps) integrations exist for.
## How do I charge a stored card with no customer present?
Send `paymentInfo.merchantInitiatedMode`, which takes two values:
| Value | Use for |
| - | - |
| `scheduled` | A charge on a known schedule — a renewal or an instalment |
| `unscheduled` | A charge triggered by your business at an unpredictable moment — an account top-up, a usage overage |
`userPaymentAccessToken` is **required** whenever you send
`merchantInitiatedMode`; without it the request fails validation, because there
is no stored credential to charge. Sending the mode also marks the transaction as
merchant-initiated, which is what changes how it is authenticated — and is why
`threeDsDecision` stops applying.
For how the token is created in the first place, and the difference between the
customer-present first payment and the charges that follow, see
[Tokenization](/docs/en/support/tokenization-faq).
## Can `payMethodName` carry more than one value?
Yes. Both `payMethodName` and `payMethodType` accept a **comma-separated list**,
and values are matched case-insensitively. That is how you narrow a checkout to a
subset rather than to exactly one method — for example passing both the credit
and debit entries for one card scheme so a shopper can use either.
The published parameter guidance for the common cases is worth following exactly:
for open card acceptance, pass `payMethodType="CREDITCARD,DEBITCARD"` and leave
`payMethodName` empty so the card scheme is determined from the BIN the shopper
types; to pin a single method, pass both fields; to let the shopper choose
everything, pass neither. Exact values for your account come from
`/api/v1/paymethodconfig/inquiry` or the Portal's Payin page — do not hardcode a
list copied from a specification.
## What if `merchantInfo.merchantId` does not match my API key?
It fails immediately, with `A0003`, before any order work happens: the merchant
ID in the body must be the merchant the API key you signed with belongs to. The
same check has three neighbouring failures worth telling apart:
| Situation | Code |
| - | - |
| `merchantId` does not match the API key | `A0003` |
| `merchantId` does not exist at all | `A0003` |
| `subMerchantId` does not exist under that merchant | `A0003` |
| The sub-merchant exists but is not active | `A0006 Merchant status abnormal` |
The distinction matters when you debug: `A0003` on this axis means *you sent the
wrong identifier*, while `A0006` means *the identifier is right and the account
is not usable* — the second one is not fixable in code. If you run several
merchant accounts, note that an API key belongs to one of them; there is no
cross-account key. See
[Authentication & signatures](/docs/en/support/auth-signature-faq).
## What is `userId` for?
`userId` (String 64, required) is **your** identifier for the paying user,
along with `userEmail` and `userPhone` (format `+CountryCode-Number`, for
example `+62-08990013157`). These identify the user on your side — they are
not Waffo account credentials.
## What should I pass in `userTerminal`?
`userTerminal` tells Waffo where the user actually is, so the returned
payment link works in that environment:
| Value | Use when |
| - | - |
| `WEB` | The user is on a PC browser. |
| `APP` | The user is inside your mobile app — you receive redirect links optimised for the app environment. |
| `IN_WALLET_APP` | The user is already inside a payment method's wallet app. |
| `IN_MINI_PROGRAM` | The user is inside a payment method's mini program. |
Not every payment method supports every terminal type; the value also
affects which payment methods can be offered.
## What is `productName`, and when do I use `DIRECT_PAYMENT`?
`productName` selects the payment product: `ONE_TIME_PAYMENT`,
`MINI_PROGRAM_PAYMENT`, or `DIRECT_PAYMENT`. Most merchants use
`ONE_TIME_PAYMENT`. You can check which products are enabled for your
account in the Waffo Merchant Portal under **Payin Service** — passing a
product that is not in your contract fails validation.
## Why did I get `A0003 Parameter validation failed`?
The most common causes are field length and format limits. Ones that
frequently trip merchants up:
* `cancelRedirectUrl` — maximum length **512**, half of what the success and
failure redirect URLs allow
* `goodsInfo.goodsUrl` — maximum length **128**, must be a full URL
(expected for compliance and risk review)
* `userPhone` — must follow `+CountryCode-Number`
* `orderRequestedAt` — must be ISO 8601 with milliseconds and a `Z` suffix
The error response names the offending field; check it against
[the field table above](#is-there-one-table-showing-which-fields-are-mandatory-and-how-long-they-can-be).
One format failure that is **not** `A0003`: a `userEmail` that does not look
like an address comes back as `B0007 Invalid email format`. If you see `B0007`,
the fix is the email value, not a length or timestamp.
## `userEmail` is mandatory but I never collected one. What do I send?
Send a **unique** synthetic address in the published fallback form
`userId@examples.com`, substituting the user's actual id. Format is validated —
the value has to look like an address, otherwise the create fails with
`B0007 Invalid email format` — but uniqueness is not enforced by the API, which
is precisely why it is on you to keep it unique.
The two things not to do, because both damage you rather than failing loudly:
* **Do not send a literal placeholder** such as `test@…`.
* **Do not share one address across users.** One address on many payments is
exactly the pattern risk screening is built to notice, so the cost lands as
declines on good transactions, not as an error on the create call.
Once you do collect real addresses, send the real ones. If your historical
traffic needs the fallback applied retroactively, that is a support request
requiring your authorization, not a parameter change.
## When do I need to send `userCurrency`?
`userCurrency` is optional, and there is no "userCurrency is required" error. It
declares the currency the user will pay in, for the case where that is not your
`orderCurrency`.
What it actually affects is **which of your contracts can serve the order**. A
cross-currency order can only be routed through a contract configured for that
currency pair; if you leave the field out and no same-currency contract matches
either, nothing is left to route to and you receive
`A0018 Payment method in merchant order does not match with current merchant contract` —
which reads as a payment-method problem but is really a currency-pairing problem.
That is the single most misread failure on this endpoint.
The published rule for a cross-currency order is to send `payMethodCountry` when
you have not pinned `payMethodType` or `payMethodName`; `userCurrency` itself
stays optional. Which currencies your contracts cover, and how the three
currencies on an order relate, are in
[Which Amount and Currency Fields Do I Check?](/docs/en/support/order-amount-and-currency-fields-faq).
## Why did I get `A0018 Payment method does not match`?
`A0018` means the payment method in the order does not match your current
merchant contract. Which payment methods are available depends on what is
enabled for your account — check the Merchant Portal, or contact integration
support to adjust your contract.
## The payment failed — how does the user retry with a different payment method?
Create a **new order with a new `paymentRequestId`**. Reusing the previous
`paymentRequestId` with a changed `payMethod` is an idempotency mismatch and
fails with `A0011`.
One extra rule when switching away from cards: a saved card token
(`userPaymentAccessToken`) is only valid for card payments. If the failed
attempt was a card payment and the retry specifies a non-card method (a
bank transfer or wallet, for example), **do not carry the card token over**
— the combination cannot be routed and order creation fails with a
"no available channel" error.
## Which WeChat Pay method and order currency should I pass?
WeChat Pay is not a single configuration. `WECHATPAY` exists as three regional
configurations, each tied to one order currency, and `WECHATPAYHK` is a
separate method:
| Method | Order currency | Wallets that can pay |
| - | - | - |
| `WECHATPAY` — HKG | `HKD` | Wallets registered in Hong Kong **and** in Mainland China |
| `WECHATPAY` — CHN | `CNY` | Mainland China wallets only |
| `WECHATPAY` — USA | `USD` | Mainland China wallets only |
| `WECHATPAYHK` | `HKD` | Hong Kong wallets only |
Pick the combination that matches where your users' wallets are registered — if
the wallet is not covered by the configuration you charged in, the user cannot
complete the payment. Confirm which of these are enabled for your account with
`/api/v1/paymethodconfig/inquiry`; each entry carries the method's `country`
and `currency`.
## Error code families
| Prefix | Family |
| - | - |
| `Axxxxx` | Merchant-related (credentials, parameters, contract, limits) |
| `Bxxxxx` | User-related |
| `Cxxxxx` | System-related |
| `Dxxxxx` | Risk-related rejection |
| `Exxxxx` | Unknown — retry the transaction (or Order Inquiry) until a final Success / Failed status is returned |
# My Order Was Rejected — Is It Me, My Setup, or the Payment Method?
Source: https://waffo.com/docs/en/support/order-rejection-self-diagnosis-faq
A decision tree for the rejections merchants raise most: A0018 payment-method/contract mismatch and every dimension that produces it, A0001 after a key rotation, C0005 channel rejection and the failure reason returned with it, A0017 on refunds, and how to tell a not-yet-enabled payment method apart from a bad parameter.
Three rejections account for most go-live tickets: `A0018`, `A0001` and `C0005`. They look alike —
a code, a short message, no detail — but they need completely different responses. This page is the
decision tree. Per-code reference is the published
[Error code list](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes); the
mechanics of each code are in
[Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide) and
[Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement).
## Which of these am I looking at?
| Code | One-line reading | Whose problem |
| - | - | - |
| `A0001` | The API key was not accepted | Your credentials or your environment |
| `A0002` | The signature did not verify | Your signing |
| `A0003` | The request itself is malformed | Your payload |
| `A0018` | No agreement of yours matched this whole request | Your setup — or a parameter that narrowed too far |
| `A0008` / `A0020` / `A0021` | The amount or the daily total is outside a limit | Your request against your limits |
| `A0035` / `A0036` | Manual capture is not available, or not now | Your setup or the order's state |
| `C0005` | The payment method refused the payment | The payment method — read the failure reason |
| `C0007` | The payment method is unavailable right now | Temporary; retry or offer another method |
| `E0001` | The outcome is not yet known | Nobody's yet — keep querying |
The first letter is the fastest triage there is: `A` is yours to fix, `C` is on the Waffo or
payment-method side, `E` means undetermined. Never treat an `E` as a failure — the rule and the
retry pattern are in the published
[Error handling](/docs/en/developer-docs/core-concepts/error-handling).
## Is `A0018` "not enabled yet", or did I send something wrong?
Both are possible, and this is the crux of the question. `A0018` does not mean "the payment method
you named is not signed". It means **nothing you are contracted for survived the whole request**,
and the request narrows the candidates on many dimensions at once. Any one of them can empty the
set, and they all come back as the same code and the same message.
Work through them in this order — the first mismatch you find is your answer:
| # | Dimension | Check |
| - | - | - |
| 1 | **Merchant ID** | Does the merchant ID in the body belong to the API key you signed with? A mismatch here makes everything below moot |
| 2 | **Payment method and type** | Are `payMethodName` / `payMethodType` exactly as they appear on Service → Payin — or omitted entirely? |
| 3 | **Order currency** | Is there a row carrying this `orderCurrency` for that method? Matched exactly |
| 4 | **User currency** | If you pinned `userCurrency`, is that pair on a row? Omitting it removes this dimension |
| 5 | **Agreement status and validity window** | Is the row `Active`, and is today inside its validity dates? A row that has ended does not appear at all in the inquiry response |
| 6 | **Country** | If you pinned `payMethodCountry`, does it match the row's payment-method country? Globally available methods do not belong to a country — do not pin one for them |
| 7 | **Terminal** | Is the `userTerminal` you sent one the method supports? |
| 8 | **Conditionally required fields** | Some methods require extra fields; missing one empties the set rather than producing a field error — see [Conditional & Required Fields](/docs/en/support/conditional-required-fields-faq) |
| 9 | **Card or token data** | Sending raw card data or a saved token restricts candidates to card-class agreements, and a token must still resolve to a method you are contracted for |
| 10 | **Cross-currency pricing** | A cross-currency order that cannot be priced is rejected here too |
| 11 | **Real-time availability** | The method may be contracted but unavailable at that moment |
So: **not-enabled and sent-wrong are distinguished by elimination, not by the code.** The fastest
way to separate them:
1. Call `/api/v1/paymethodconfig/inquiry`. If the method, country and currency combination is not in
the response, it is a setup question — nothing you change in the payload will help.
2. If it *is* in the response with an availability of "available", the combination is contracted and
the rejection came from something the request itself added — one of rows 1, 6, 7, 8, 9 above.
Remove the optional narrowing parameters one at a time and retry.
Two codes sit next to `A0018` and mean the opposite of a missing contract — they mean you have
**more** than one match and must be specific: `A0022` (name the product) and `A0033` (name the
payment-method country).
## Why isn't an unsupported currency its own error code?
Because the currency is one of the dimensions above, not a separate check. An order currency or a
currency pair you are not contracted for comes back as `A0018`, with the payment-method wording. Do
not wait for a currency-specific code — see
[Do I have to enable a currency before I can use it?](/docs/en/support/currency-and-method-enablement-faq).
## The same code appeared on a subscription. Does it mean the same thing?
The code is shared but the reading differs. On the acquiring side it is the contract mismatch above.
On the **subscription** side it means there is no usable payment method left to charge that
subscriber with — for example the saved method has been removed. The published code list glosses it
the second way; the message text you receive is the contract wording. Read it together with the API
you called.
One consequence worth building for: on a renewal after the first period, this code is treated as
**not retryable** — the schedule will not keep trying it. Fix the payment method rather than waiting
for a retry. See
[Subscription retries & payment methods](/docs/en/support/subscription-retry-and-payment-methods-faq).
## I rotated my keys and now I get `A0001`. Is the new key broken?
Usually not. Three things to rule out, in order:
| Check | Detail |
| - | - |
| **Environment** | Sandbox and production have separate keys **and** separate domains. A sandbox key sent to the production domain is an `A0001`, and vice versa |
| **Activation state** | A submitted key is not usable until it is active. A submission still under review, or one that was rejected, behaves like a key that does not exist |
| **Propagation** | A key that has just become active is not accepted the same instant everywhere. Do a rotation as an overlap — add the new key, wait, keep the old one in place until the new one has verified real traffic — rather than as a cutover |
How long that window is, the exact order in which the key, its scope, the signature and your IP
allowlist are checked, and how `A0001` differs from `A0002` and from a scope refusal, are in
[API Credential Scope & Auth Error Triage](/docs/en/support/api-credential-scope-and-triage-faq). The
submission and rotation procedure, and where to see why a submission was rejected, are in
[Production API Keys & Public Keys](/docs/en/support/production-api-key-and-public-key-faq).
If the key is accepted but the call is refused anyway, the cause is usually what that key is
permitted to do — a different rejection, not `A0001`.
## What does `C0005` actually tell me, and where is the reason?
`C0005` — *Payment channel rejection* — means the payment method refused the payment. On its own it
tells you almost nothing, because it is the code a wide range of underlying refusals map to,
including every card decline.
The detail is in the **failure reason returned with the order**, which has two halves:
| Field | Use |
| - | - |
| `orderFailedCode` | A stable value to branch on in your own code |
| `orderFailedDescription` | Human-readable text for support conversations and logs |
They arrive together as `orderFailedReason` on
[Order inquiry](/docs/api-reference/introduction), and the Portal shows the same pair on the transaction
record as *Order Failure Code* and *Order Failure Description* — see
[Transaction Field Reference](/docs/en/support/transaction-field-reference-faq).
So the routine is: **never branch on `C0005`; branch on the failure code inside it.** Descriptions
such as an authentication step that could not be started, or a card refused by the issuing bank on
fraud or on legal grounds, are exactly the sort of thing that arrives this way, and they call for
very different handling.
## Which `C0005` failures are worth retrying?
Retryability belongs to the underlying reason, not to `C0005`. The classification and what to do
about each class — including which declines must never be retried and which need the customer to
re-enter their credential — is in
[A card was declined. Should I retry?](/docs/en/support/order-amount-precision-and-decline-faq).
Two rules on top of that:
* **Show the customer a usable message and offer another payment method.** Waffo checks exactly this
behaviour during integration acceptance for channel rejections and system-unavailable responses —
see the published
[Acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria).
* In the sandbox, several reserved amounts return `C0005` on purpose so you can build that path. If
you get `C0005` on an amount you did not expect to be special, check the reserved list before
concluding anything — see
[Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator).
## A refund came back `A0017`. Can that order ever be refunded by API?
`A0017` — *the order cannot be refunded because the payment channel does not support it* — comes
from the payment method itself, and it is **not retryable**. Sending it again unchanged produces the
same answer.
Before treating it as final, separate it from the other refund rejections:
| Situation | Where to look |
| - | - |
| The method permits no refunds at all, or only a shape you did not ask for | The refund policy on your agreement — `refundAllowed` on the inquiry response, `Refundable` on Service → Payin |
| The refund window has closed | The refund period on the same row |
| The order is not in a refundable state, or is already fully refunded | The order itself |
| The method cannot process a refund through the API | `A0017` |
All four, and what to do in each case, are in
[Payment Method Refunds & Limits](/docs/en/support/payment-method-refund-limits-faq) and
[Refund FAQ](/docs/en/support/refund-faq).
## Where do I look next?
| Question | Page |
| - | - |
| What are my limits, and which limit did I hit? | [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq) |
| What is contracted for me, from code? | [Payment Method Configuration & Limits](/docs/en/support/payment-method-config-and-limits-faq) |
| Is this a currency problem? | [Currency & Method Enablement](/docs/en/support/currency-and-method-enablement-faq) |
| Is this mine to fix at all? | [What can I change myself?](/docs/en/support/self-service-boundaries-faq) |
| Signature, idempotency and unknown-status handling | [Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide) |
# What Can I Do to an Order in Each Status?
Source: https://waffo.com/docs/en/support/order-status-actions-and-capture-faq
The order status list including the two statuses most merchants never see, which statuses accept cancel, capture and refund, why an order sits in AUTHORIZATION_REQUIRED, where the close reason is written, how cancel behaves when the payment method has already taken money, and the rules for capturing an authorized card payment.
The status list itself is published in
[Payment lifecycle and order statuses](/docs/en/developer-docs/core-concepts/payment-lifecycle)
and [Data reference](/docs/en/developer-docs/tools-and-references/references/data-reference).
This page is the *operational* half: which actions each status accepts, and what
the three write endpoints — cancel, capture, refund — do when the order is not in
the state you expected.
| Page | Use it for |
| - | - |
| [Confirming Payment Results](/docs/en/support/payment-results-faq) | What each status *means*, and how to confirm a result |
| [Timeouts & When a Result Is Final](/docs/en/support/payment-timeout-and-finality-faq) | Whether a result can still change, and timeout-safe integration |
| **This page** | Which action is allowed in which status, and the errors when it is not |
## Are there statuses that are not in the published list?
Two, and both are worth knowing about because they explain confusing
observations rather than being things you act on.
| Status | What it is |
| - | - |
| `CAPTURE_IN_PROGRESS` | A capture you requested is being carried out. It sits between the authorized state and `PAY_SUCCESS`, and it accepts a repeat capture call |
| *(a pre-status before anything reaches a payment method)* | An order that has been accepted but for which no payment attempt exists yet is **reported to you as `AUTHORIZATION_REQUIRED`**. There is no separate value for it on the API |
The second one is the answer to a question merchants ask often, so it is worth
stating plainly: `AUTHORIZATION_REQUIRED` covers both *"the payment method is
waiting for the user to authorize"* and *"nothing has happened on this order
yet"*. You cannot tell those apart from the status alone.
## My order is stuck in `AUTHORIZATION_REQUIRED`. Did the user close the WebView?
Maybe — but the status does not tell you that, and it is not the only
explanation. `AUTHORIZATION_REQUIRED` means *no result has come back*, which is
consistent with all of:
* The user never opened the payment page at all.
* The user opened it and abandoned it — closed the WebView, killed the app,
walked away.
* The user is still on it, mid-authentication.
* The payment attempt has not yet been made.
So it is not a diagnosis, and specifically it is **not** evidence of a failure
you should report to the user. The correct handling is the same in every case:
leave it open, let the order reach its expiry, and take the result from the
webhook or Order Inquiry. Do not close it yourself on a timer — see
[Timeouts & When a Result Is Final](/docs/en/support/payment-timeout-and-finality-faq).
If it matters to you operationally to know whether the page was opened, that is a
question for your own front-end instrumentation, not for the order status.
## Which statuses are final, and can I store one as my outcome?
Two statuses are final: **`PAY_SUCCESS`** and **`ORDER_CLOSE`**. Everything else
is transitional, and an order in a transitional status can still move.
`PAY_SUCCESS` and `ORDER_CLOSE` do not flip into each other. Once an order is
closed, a later success report from the payment method does **not** reopen it and
does **not** rewrite it to `PAY_SUCCESS`; once it succeeded, it stays succeeded.
So yes — you can persist a final status as your business outcome.
The one thing that is *not* safe to persist is the assumption that a final status
means nothing further can happen to the **money**. A successful order can still be
refunded or disputed afterwards, and neither of those changes `orderStatus` — see
the refund question below.
## What can I do to an order in each status?
| Status | Cancel | Capture | Refund |
| - | - | - | - |
| Pre-status / no payment attempt yet | Yes | No | No |
| `PAY_IN_PROGRESS` | Yes | No | No |
| `AUTHORIZATION_REQUIRED` | Yes | No | No |
| `AUTHED_WAITING_CAPTURE` | Yes | **Yes** | No |
| `CAPTURE_IN_PROGRESS` | No | Yes (idempotent repeat) | No |
| `PAY_SUCCESS` | No — `A0013` | No | **Yes** |
| `ORDER_CLOSE` | Yes, as a no-op | No | No |
Read the two edges carefully, because they are where integrations get it wrong:
* **Refund is only possible from `PAY_SUCCESS`.** There is no "refund a pending
order" — if the money has not landed, the action you want is cancel.
* **Cancel is the way to release an authorization you are not going to capture**,
and it is materially better than authorizing, capturing and then refunding.
## I called cancel and it was refused. What do the errors mean?
| Situation | Code |
| - | - |
| The payment already succeeded | `A0013` Order cannot be cancelled due to payment already success |
| The payment method does not support cancellation | `A0015` Order cannot be cancelled due to payment channel not supported |
| The payment method refused the cancellation | `A0016` Order cannot be cancelled due to payment channel rejection |
| The order is in a status that cannot be cancelled | Order status is invalid to proceed |
| The order does not exist, or the id does not match | Order not found / id mismatch |
| Two cancel calls at once | A concurrency error — retry |
How to act on each:
* **`A0013` is not an error to retry.** The user paid. If you do not want to
fulfil, the action is a refund, not a cancel.
* **`A0015` is a fact about the payment method**, not about your request. Some
methods have no cancel operation at all; for those, the way an unpaid order
ends is expiry. Design for "cancel may be unavailable" and let the expiry do
the work.
* **`A0016` came from the payment method's side.** A retry can be worth one
attempt, but if it repeats, stop and let the order expire.
* **Cancelling an already-closed order succeeds** and does nothing. That makes
cancel safe to call from a cleanup job without checking the status first.
You may send either `paymentRequestId` or `acquiringOrderId` to identify the
order, plus your `merchantId`; sending neither id is a validation failure.
## After I cancel, can the user still pay on the payment method's page?
Cancel puts the order into `ORDER_CLOSE` — a final status — and asks the payment
method to cancel its side too. From that moment the order cannot complete: a
success report arriving afterwards is not applied, so the order will not silently
turn into a paid order behind your back.
What cancel cannot guarantee is that a **third-party payment page already open in
front of the user** stops accepting input. Where the payment method does not
support cancellation at all (`A0015`), nothing was cancelled on their side by
definition. So the honest answer to "can the user still pay?" is: *they may be
able to hand over money, and it will not become a completed order.* Money that
moves in that window is a recovery case, not a fulfilment case — the handling is
described in
[Timeouts & When a Result Is Final](/docs/en/support/payment-timeout-and-finality-faq).
Practical rule: cancel when you have decided not to sell, and do not treat cancel
as a way to close a checkout the user is still looking at.
## Is "set a short expiry, then cancel when it passes" a good design?
It is a reasonable design, with one correction and one caveat.
* **The correction:** you do not need the cancel call. An order that reaches its
expiry without being paid is closed for you, and you are notified. Adding your
own cancel afterwards is harmless — it is a no-op on a closed order — but it is
not what makes the order end.
* **The caveat:** a short expiry does not shorten the payment method's own
window, and it does not stop a late payment from being taken on their side. So
a three-minute expiry buys you a three-minute *checkout*, not a three-minute
guarantee that no money will move afterwards.
If your business needs a genuinely short window, set the expiry **and** build the
late-payment recovery path. If you only need the checkout to stop looking live,
the expiry alone is enough.
## Will an order close exactly at the expiry I set?
Close to it, but not to the second. Closing is performed by a periodic sweep, so
an order can remain technically open for a short interval after its nominal
expiry, and under high volume a batch of due orders can spill to the next pass.
Before closing, the payment method is checked, and an order with a payment
already in flight is left alone rather than being closed underneath the payment.
What this means for you:
* **Do not use "the order is closed" as a clock.** If your fulfilment logic needs
a deadline, use your own timer against the expiry you sent; use the order status
to learn the *outcome*, not the *time*.
* **Do not treat a slightly-late close as an incident.** It is the design.
* **An expiry in the past is not rejected — it is discarded.** If `orderExpiredAt`
is already behind the clock when the order is created, the value is dropped and
the order gets the payment method's default expiry instead. So a clock bug on
your side does not fail loudly; it produces an order that stays open longer than
you intended. Check the timestamp before you send it.
If you send no `orderExpiredAt` at all, the order still gets closed eventually,
but the time is not a value you set or should depend on. Send an explicit expiry
whenever the window matters to your business.
## Do I get a notification when an order is closed on timeout?
Yes. A closed order produces the ordinary payment notification —
`PAYMENT_NOTIFICATION`, carrying the same order structure as Order Inquiry, with
`orderStatus` = `ORDER_CLOSE` and the reason in `orderFailedReason`. There is no
separate "order expired" event type to subscribe to, and there is nothing extra
to enable.
So an integration that handles `PAYMENT_NOTIFICATION` and branches on
`orderStatus` already handles timeout closes. If you are not receiving them,
that is a webhook delivery question — see
[Portal webhook log](/docs/en/support/portal-webhook-log-faq) and
[Webhook retry](/docs/en/developer-docs/webhook/retry).
## Can I look up `orderFailedReason.orderFailedCode` in the error code table?
**No — it is a different set of codes.** The `Axxxx` codes are what an API call
returns to you synchronously when your *request* is wrong or refused. The code
inside `orderFailedReason` describes why an *order* ended the way it did, and it
is a separate, reason-oriented namespace. Looking one up in the other's table is
why merchants conclude the code "does not exist".
| You are holding | It is | Look it up in |
| - | - | - |
| The `code` / `msg` on an API response | An API error code | [Error codes](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes), [API Reference Tables](/docs/en/support/api-reference-tables) |
| `orderFailedReason.orderFailedCode` on a closed or failed order | An order failure reason | The `orderFailedDescription` shipped alongside it, and Portal → Transactions → the order's Order Failure Description |
Can you automate on it? Partly, and carefully:
* **Do** branch on the broad distinction you can rely on — a timeout close is a
different business event from a payment refusal, and both differ from a
cancellation.
* **Do** always store `orderFailedDescription` next to the code, because it is
the part that stays readable when a code you have never seen arrives.
* **Do not** build an exhaustive switch that treats an unrecognised reason as a
failure of your own code. The set is larger than the handful you will see in
testing, and new reasons appear as payment methods change. Default to
"closed, not paid, reason recorded" and alert a human rather than guessing.
There are two distinct timeout reasons — one for an order that never reached a
payment method, one for an order that did — and
[Confirming Payment Results](/docs/en/support/payment-results-faq) explains the
practical difference.
## Can I capture less than I authorized?
Yes. A capture amount may be **anything greater than zero and up to the
authorized amount**; the remainder of the authorization is released rather than
held. Capturing more than was authorized is refused.
| What you send | What happens |
| - | - |
| Less than the authorized amount | Captured; the rest is released |
| Exactly the authorized amount | Captured |
| More than the authorized amount | Refused — capture is not supported for that amount |
| A second capture, same amount as the first | Idempotent: you get the original result back |
| A second capture, **different** amount | Refused as an idempotency mismatch (`A0011`) |
| A capture on an order that is not in an authorized state | Refused — the current status does not allow capture |
| A capture on an order that was never set to manual capture | Refused — capture is not supported on that order |
That last row is the one that surprises people. Capture only exists for orders
created **in manual-capture mode** — you opt in at order creation with
`paymentInfo.captureMode`, and the only value accepted there is `manualCapture`.
An ordinary order is captured automatically and there is nothing for you to call.
The published product behaviour matches the code on the point that matters most:
capture is effectively **single** — once you capture, even partially, the rest of
the authorization is released, so you cannot draw down an authorization in
instalments. For usage-based billing, the pattern is a stored token and periodic
charges, not repeated captures. See
[Auth-Capture](/docs/en/essentials/auth-capture) and
[Tokenization](/docs/en/support/tokenization-faq).
## How long do I have to capture?
The window is counted in whole days from **when the order was created**, not from
when you noticed the authorization. It is a single platform-wide setting, applied
to every merchant alike — it is not negotiated per account, and the wording of the
refusal message is not a reliable guide to the current figure. Confirm the window
with support before you design a fulfilment process around it.
After the window lapses two things happen, and you should build for both:
* **Your capture call starts failing.** It is refused as unsupported, not queued.
* **The authorization is released for you.** Expired authorizations are cancelled
proactively, so the customer's funds do not stay held because you forgot.
So an authorization is not a promise you can hold indefinitely, and a capture is
not something to leave in a retry queue with no deadline. If your fulfilment can
take longer than your capture window, capture on shipment rather than on
delivery, or use a stored token and charge when you are ready.
## Why is `refundExpiryAt` empty on some of my orders?
`refundExpiryAt` is the point after which the order can no longer be refunded. It
is derived from the **refund window in your agreement for that payment method**,
counted from the order's **creation date** — not from the moment the payment
succeeded — and it lands at the end of that day.
Two consequences:
* A window expressed in days is a **calendar** window. An order created late in
the day gets a slightly shorter usable window than one created early, because
the boundary is the end of a day either way.
* The clock does not restart when the payment succeeds. An order paid a long time
after it was created has already spent part of its refund window.
It is empty — `null` — in every case where the question does not apply:
| When it is null | Why |
| - | - |
| The order is not successful | There is nothing to refund |
| Refunds are not permitted for that agreement | There is no window to express |
| No refund window is configured for that agreement | Same |
So do not read a missing `refundExpiryAt` as "refundable forever". Read it as "no
deadline is being published for this order", check
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq)
for what applies, and use `remainingRefundAmount` on the refund APIs to decide
what is still refundable.
## Does a refund change the original order's status?
No — and this is the single most common false alarm on this endpoint. A refund
never rewrites `orderStatus`; a fully refunded order still reports
`PAY_SUCCESS`. Refund progress lives on the refund record and on the order's
separate refund-status field, not on the payment status.
If your bookkeeping keys refund state off `orderStatus`, it will look permanently
stuck. Key it off the refund record instead —
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq)
sets out where to look for each question.
***
Stuck on a status that will not move? [Contact support](mailto:support@waffo.com)
with the `acquiringOrderId` and the status you are seeing.
# Payin Agreements & Transaction Limits
Source: https://waffo.com/docs/en/support/payin-agreements-and-limits-faq
How to read Service → Payin: every column on your payin agreement list, the fee and settlement-cycle notation, agreement statuses, the amount limits — plus what the two limit-exceeded errors mean, how the daily limit is counted and released, and which name your customer sees at checkout.
**Service → Payin** is the Portal's record of what you are actually contracted to accept: which
payment methods, in which countries and currencies, on what fee basis, with which amount limits and
refund rules. This page explains how to read it, and what happens when a payment hits one of those
limits.
Order creation itself is covered in the
[Order Create field guide](/docs/en/support/order-create-field-guide); refund behaviour in
[Refund FAQ](/docs/en/support/refund-faq); what the checkout screen shows customers in
[Cashier display rules](/docs/en/support/cashier-display-rules-faq).
## Where do I see which payment methods I am contracted for?
**Service → Payin**. One row per contracted combination, with these columns:
| Column | What it holds |
| - | - |
| `Payment Method Country` | The country or region the payment method applies to |
| `Product` | The product the row belongs to (for example a one-time payment or a subscription product) |
| `Payment method type` | The category — card, wallet, bank transfer and so on |
| `Payment method` | The specific payment method, with its logo |
| `Payment option` | Sub-rows where a method prices differently per option |
| `Fees` | Your fee basis for the row — see below |
| `User Fee` | Only present where at least one row charges the payer a fee — see below |
| `Settlement cycle` | Notation such as `T+3` — see below |
| `Minimum amount` | Smallest order amount accepted for the row |
| `Maximum amount` | Largest order amount accepted for the row |
| `Order Currency` | The currency the order is created in |
| `User Currency` | The currency the payer is charged in |
| `Refundable` | Which refund shapes the row permits — see below |
| `Refund Period` | How long after payment a refund remains possible |
| `Additional Fields For Refunds` | Whether refunds on this row require extra fields |
| `Order Expiry Customization` | Shown only where the row supports a custom order expiry |
| `Status` | The agreement state for the row |
| `Mini Apps Info` | Shown only where mini-app details exist for the row |
`Fees` and `Settlement cycle` are not shown for every contract type — if they are absent from your
page, that is your contract shape, not a missing permission.
Anything the backend does not supply renders as `-` rather than being left blank, so a dash means
"nothing configured for this row", not "zero".
## How do I read the Fees column?
The Fees cell is composed, not a single number. It is assembled from up to four parts:
| Shape you may see | Reading |
| - | - |
| `% ` | Percentage of the amount only |
| ` ` | Flat amount per transaction only |
| `%+ ` | Both — a percentage plus a flat amount |
| `… (min )` | A floor: the fee never falls below this |
| `… (max )` | A ceiling: the fee never rises above this |
| `… (min , max )` | Both a floor and a ceiling |
Where the ceiling is expressed as a proportion rather than a fixed amount, the `max` part appears as
a percentage instead.
Two further details:
* The column header carries the tooltip **"Rounding up to 2 decimal places per transaction."** —
rounding is applied per transaction, not once per settlement batch.
* Where a payment method prices differently per payment option, the row expands into one fee line
per option. A payment option with no meaningful per-option distinction renders as `-`.
Your actual rates are commercial terms and are shown only on your own page — they are not published
anywhere.
## What is the User Fee column, and why can't I see it?
`User Fee` is a fee borne by the **payer**, not by you, on payment methods that work that way. The
column is **conditional**: the Portal only renders it when at least one row on your page actually
carries a user fee. If none do, the column is absent — nothing is hidden from you.
Its header tooltip reads **"Fees charged to end users."**
Where a user fee exists, the `Refundable` cell for that row gains a second line telling you what
happens to that fee on a refund:
| Second line | Meaning |
| - | - |
| `User fee refundable` (green check) | The user fee is returned as part of a refund |
| `User fee not refundable` (amber warning) | The user fee is not returned |
This matters when you quote a refund total to a customer: the payer's fee and your fee follow
separate rules. Your own fee behaviour on refunds is covered in
[Refund FAQ](/docs/en/support/refund-faq).
## What does a settlement cycle like T+3 mean?
The notation is a **cycle letter, a `+`, and a count**:
| Letter | Cycle |
| - | - |
| `T` | Daily |
| `W` | Weekly |
| `H` | Half-monthly |
| `M` | Monthly |
So `T+3` is a daily cycle with a delay of 3, `W+1` a weekly cycle with a delay of 1, `M+1` a monthly
one. A monthly variant also appears as `M+1X`.
Note the daily letter is **`T`**, not `D` — if you are mapping this into your own systems, do not
assume `D`. How the cycle turns into an actual payout is covered in
[Settlement cycle](/docs/en/support/settlement-cycle-faq).
## What do the agreement statuses mean?
The `Status` column carries one of five values, colour-coded:
| Status shown | Colour |
| - | - |
| `Active` | Green |
| `Initial` | Yellow |
| `Onboarding` | Yellow |
| `Suspended` | Red |
| `Terminated` | Red |
Read the colour as the practical signal: green is the settled, in-service state; yellow means the
row is still being brought up; red means it is not in service. A value the Portal does not
recognise is shown as-is rather than being hidden, and an empty one as `-`.
If a row you expect to transact on is not green, raise it with your Waffo contact before debugging
your integration — the agreement state is set on Waffo's side, not from the API.
## What do the Refundable values mean?
Four values, and they are about **shape**, not about whether any individual order can be refunded
right now:
| Value | What the row permits |
| - | - |
| `No` | No refunds on this payment method |
| `Only Full Refund` | One refund, for the entire amount |
| `Full Refund or One-time Partial Refund` | Either one full refund, or a single partial refund |
| `Full Refund or Multiple Partial Refunds` | A full refund, or several partial refunds |
`Refund Period` next to it is the window after payment inside which a refund is still accepted.
Both feed the Portal's per-order refund control — see
[Why is the Refund button missing or greyed out?](/docs/en/support/transaction-field-reference-faq).
## Which filters does this page give me, and can I export it?
Six filters: `Payment Method Country`, `Payment method type`, `Payment method`, `Order Currency`,
`User Currency` and `Status`. `Product` is a **column, not a filter** — to narrow by product, filter
on the other fields and read the Product column.
**Export** produces a CSV of the current view:
| Behaviour | Detail |
| - | - |
| Scope | Whatever your filters currently select |
| Row cap | Up to 2,000 rows per export |
| Delivery | Downloads straight away — this is not one of the queued report exports |
| Headers | Backend header names, which do not all match the on-screen labels — notably the currency columns are exported as *acquiring currency* and *local currency* where the page shows `Order Currency` and `User Currency` |
Queued, asynchronous exports elsewhere in the Portal behave differently; see
[Exports & Report Downloads](/docs/en/support/export-center-faq).
## Why doesn't the row order match the Payment Method Country column?
Rows are sorted **country → payment method type → payment method name → product**, ascending.
The subtlety: the sort key is the agreement's own country field, while the first column displays the
payment method's country. For most rows these coincide; where they differ, the visible order will
not look strictly alphabetical by the column you are reading. Sorting is also applied per page of
results, so paging does not re-sort the whole set.
## What does the `Order Expiry Customization` column actually tell me?
The **range of order expiry you are allowed to ask for** on that row — not a
single default. Where a payment method accepts a custom expiry, the cell shows a
time range; where it does not, the cell shows `-` and the expiry is whatever the
contract sets.
Two details that decide how you read it:
* The range shown is the **intersection** of what the row allows on a mobile app
and what it allows on a desktop browser, so it is the window that is safe
regardless of which terminal your shopper is on. A row can be more permissive
for one terminal than the range implies.
* The whole column is **hidden** unless at least one row on your page has a
customisable expiry. Its absence means "none of your rows support this", not a
missing permission.
## Can I set the order expiry myself, and what wins if it conflicts?
Send `orderExpiredAt` on `order/create`. Omit it and the contract's expiry
applies — that is the documented default.
What happens to a value you do send:
| What you send | What happens |
| - | - |
| Nothing | The contract expiry applies |
| A time already in the past | The value is **discarded** and the contract expiry applies. The order is created; nothing is refused |
| A time only a few minutes out | Accepted as sent. Bear in mind the shopper needs time to complete the payment, and some methods need more than others |
| A time outside the row's allowed range | Treat the range as a hard requirement and stay inside it. Sending a value outside it is a different situation from sending a value in the past, and the order-creation response does not tell you which of the two happened |
One code not to misread: **`A0034`** is returned when the expiry you provided
**has already expired** by the time the order is being acted on. It is not the
code for "your expiry was outside the allowed range".
Order expiry, once set, is the shopper's payment window — what happens when it
closes, and which order states are final, is in
[Payment Timeouts & Finality](/docs/en/support/payment-timeout-and-finality-faq).
## What is the `Mini Apps Info` column?
The wallet mini-programs registered for that row. Each entry is a mini-app name;
hovering it shows the app's ID and name. The column is pinned to the right of the
table and is **only rendered when at least one row on your page has mini-app
entries** — otherwise it is absent entirely, and a row with none shows `-`.
You need this when you are taking payments from inside a wallet's mini program,
because the app ID is what identifies your mini program to the payment method.
The integration itself, including the product (`MINI_PROGRAM_PAYMENT`) and the
terminal value to send, is in
[Mini Program Payments](/docs/en/support/mini-program-payments-faq).
## My order was rejected for exceeding a limit. Which limit?
There are three, and the message tells you which:
| Message | Error code | Meaning |
| - | - | - |
| `Payment amount should be within the range` | `A0008` | The order amount is outside the `Minimum amount` / `Maximum amount` of the **Service → Payin row** — a payment-method-and-currency range |
| `Payment amount exceeds transaction limit.` | `A0020` | This **single** order exceeds an **account-level** per-transaction limit set for that **order currency**. It is independent of the payment method |
| `Accumulative payment amount exceeds daily transaction limit.` | `A0021` | The order is within the per-transaction limit, but your **daily total in that order currency, across all payment methods**, would exceed its cap |
For `A0008`, compare the order amount against the matching Service → Payin row. For `A0020` and
`A0021`, the row is the wrong place to look: these limits are keyed by **merchant ID + order
currency** only, so switching the payer to a different payment method in the same currency does
not help, and raising one currency's limit does nothing for another. For `A0021` the order size is
not the problem — the day is. All three are returned at order creation, so no order is left
half-created.
Where no account-level limit is configured for that order currency, the `A0020` / `A0021` checks
do not run and neither error can occur. The account-level limits are not shown on Service → Payin;
if you need to know them, ask your Waffo contact.
## How exactly is the daily limit counted, and when is headroom released?
| Question | Answer |
| - | - |
| Which day? | The **UTC+8 calendar day**. A day boundary at `00:00 UTC+8`, not your local midnight and not UTC |
| When is the amount counted? | At **order creation**, immediately after the order is recorded — not at payment success |
| Which orders count? | Orders still in progress **and** successful ones |
| When is headroom released? | Only when an order reaches the **closed** state, and only if it was created on the same UTC+8 day |
| Do refunds release headroom? | **No.** Refunding a successful payment does not give the day its allowance back |
Two practical consequences:
* An order created just before `00:00 UTC+8` that succeeds after it still counts against the day it
was **created**.
* Abandoned checkouts consume headroom until they close. If you are batching high volume near the
cap, that is usually what has eaten the difference — not the payments that succeeded.
## Can my limits change without me doing anything?
Yes. Where the provider underlying a payment method revises its limits, Waffo recalculates the
limits on your agreement, and the new range appears on **Service → Payin**. Two mechanics are worth
knowing:
* Where the provider's limit is expressed in a different currency from your agreement, it is
converted before being applied, so the resulting figure will rarely be a round number.
* Rounding is deliberately conservative: the **minimum is rounded up** and the **maximum is rounded
down**, so the resulting range is never wider than the underlying one. For currencies with no
minor unit — `JPY`, `KRW`, `VND` and similar — the minimum lands on a whole unit.
Requests outside the new range are rejected with the two errors above from the moment the change
lands. If your order volumes sit close to a limit, read the values off Service → Payin rather than
caching them in your integration.
## A payment method I use is being discontinued. How am I told?
When an agreement of yours is terminated, an email naming the payment method goes to the
administrator and super-administrator users on your merchant account. Details:
| Behaviour | Detail |
| - | - |
| Trigger | The agreement row moving into `Terminated` |
| Recipients | Users holding the Administrator or Super Administrator role on the merchant account at the time — so the recipients are whoever you have in those roles under Portal team management, not a fixed contact address |
| Deduplication | One email per merchant per payment method, not one per agreement row |
| Sandbox | Not sent in sandbox |
After that, the row shows `Terminated` in red on Service → Payin, and new orders on that
combination are refused. Existing orders and their refunds are unaffected by the status change
itself. If the method matters to your checkout, raise a replacement with your Waffo contact — the
Portal has no self-service path to re-enable a terminated row.
## Which merchant name does my customer see at checkout?
The checkout page shows a **display name**, which is resolved in this order:
| Case | Name shown |
| - | - |
| Your order-creation request passed a display name explicitly | That name wins over everything below |
| A single merchant account, display name configured | The configured display name |
| A single merchant account, **no** display name configured | **No merchant name is shown at all** on the order-information line |
| Sub-merchant under a parent, sub-merchant name configured | The sub-merchant's name |
| Sub-merchant under a parent, sub-merchant name **not** configured | Falls back to the parent's name |
The important asymmetry: an unconfigured display name does not fall back to your legal entity name
on the main order-information line — it simply shows nothing. If customers report a blank or
unexpected merchant name at checkout, this resolution order is the first thing to check. Other parts
of the checkout page have their own fallbacks, so a name can be absent in one place and present in
another. See [Cashier display rules](/docs/en/support/cashier-display-rules-faq) for what else the page
shows.
# Payment Enrollment: Why Approved Verification Is Not Yet Live Payments
Source: https://waffo.com/docs/en/support/payment-enrollment-faq
Verification approved but still cannot take real payments? This page explains the Payment Enrollment stage that follows KYB approval: where the Activating Payment Capabilities panel is, what IN_PROGRESS and COMPLETED mean, why a payment method sits at Processing, and what Suspended or Rejected means for one method.
Getting an account and getting *verified* is covered in
[Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq).
This page picks up at the moment most merchants are surprised by: **your verification is
approved, and you still cannot take a real payment yet.** There is one more stage — Payment
Enrollment — and it is visible to you in the Portal.
## I passed verification. Why can't I take live payments yet?
Because approval of your company documents and enablement of your payment methods are two
different steps, and they happen in that order:
| Stage | What is being decided | Who acts |
| - | - | - |
| Verify Account | Your company information and documents are collected | You |
| Review | Compliance and risk review of what you submitted | Waffo |
| Payment Enrollment | Each payment method you are contracted for is enabled for live use | Waffo |
Approval of stage 2 is approval of *you as a business*. It does not by itself make any single
payment method live. Stage 3 has to finish for the methods you intend to charge with, and it
finishes method by method — which is why the answer to "am I live?" is often "partly".
## Where do I see which stage I am in?
On the Portal **Home** page, in a panel titled **Activating Payment Capabilities**, at the top
of the page. It shows the three stages above as three cards, numbered 1, 2 and 3:
| Card | Title on screen |
| - | - |
| 1 | Verify Account |
| 2 | Review |
| 3 | Payment Enrollment |
The panel appears while your account is on this journey. Once you are fully live it stops being
the thing you need — the day-to-day questions move to your transaction pages.
## How do the three cards unlock?
Strictly in sequence — a later card cannot start before the earlier one is finished, and the
card tells you so rather than leaving you guessing:
| Card | State you may see | What it means |
| - | - | - |
| 1 Verify Account | Action button *Verify Account →* | You have not submitted yet |
| 1 Verify Account | **Completed** | You have submitted; nothing more to do here |
| 2 Review | *Please complete the information submission first.* | Card 1 is not submitted yet |
| 2 Review | No badge, *Check details* button | Under review — "Your materials are currently in the queue for internal review" |
| 2 Review | **Returned**, *Go to modify* button | Items came back to you to fix and resubmit |
| 2 Review | **Rejected**, *Check details* button | Not approved |
| 2 Review | **Completed** — *Compliance and Risk Approved.* | Review passed |
| 3 Payment Enrollment | *Payment method reporting can be initiated after document review is approved.* | Card 2 has not passed yet |
| 3 Payment Enrollment | **Pending**, *Details* button | Enrollment is running — open Details to see it per method |
| 3 Payment Enrollment | **Completed**, *Details* button | *All payments are successfully enrolled, payment methods are active.* |
Two consequences worth reading twice:
* **A locked card 3 is not a fault.** If card 2 has not passed, card 3 is *supposed* to look
inert. There is nothing you can do to accelerate it from card 3.
* **Returned is the only state that hands the work back to you.** In Returned, the *Go to
modify* button reopens the verification wizard with exactly the flagged items editable. See
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq) for what can be
flagged and what "acceptable" looks like for each item.
## What exactly does the Payment Enrollment status mean?
The account-level status you see has only **two** values:
| Status | Meaning |
| - | - |
| `IN_PROGRESS` | At least one of your payment methods is not yet active |
| `COMPLETED` | Every payment method in your enrollment set is active |
That is deliberately coarse. `COMPLETED` is an *all of them* statement — a single method still
working through enrollment keeps the whole account-level indicator at `IN_PROGRESS`, even when
most of your methods are already usable. So do not read `IN_PROGRESS` as "nothing works";
read it as "not everything works yet", and open **Details** to see which is which.
One behaviour to know if you are polling this from a script or watching it change over a
morning: when the enrollment status cannot be determined at the moment you load the page, the
Portal shows `IN_PROGRESS` rather than showing nothing or guessing `COMPLETED`. It fails
towards "not finished". A single `IN_PROGRESS` reading is therefore not proof that a specific
method is incomplete — the per-method list is the answer, not the summary.
## What is in the Payment Enrollment Details dialog?
Click **Details** on card 3. The dialog is titled **Payment Enrollment Details** and has two
parts.
A summary bar of counts:
| Count | What it counts |
| - | - |
| Total Payment Methods | Payment methods in your enrollment set |
| Successes | Methods at `Active` |
| Failures | Methods at `Rejected` |
| Processing | Methods at `Processing` |
| Suspended | Methods at `Suspended` |
Then one row per payment method, each with its own status badge. The list is **by payment
method** — that is the level Waffo reports at, and it is the level to quote when you open a
ticket ("method X is still Processing"), because it is unambiguous.
## What do Active, Processing, Suspended and Rejected mean for one method?
| Status | What it means for you |
| - | - |
| `Active` | Enabled. You can charge with this payment method. |
| `Processing` | Enrollment for this method is still under way. No action from you. |
| `Suspended` | Enrollment for this method is paused. It is not usable while it stays here. |
| `Rejected` | Enrollment for this method did not succeed. It will not become usable on its own. |
Where a payment method has more than one enrollment record behind it, the method shows the
**furthest-along** of them, in the order `Active` → `Processing` → `Suspended` → `Rejected`.
The practical effect is the useful one: a method reads `Active` as soon as it is genuinely
usable, and only reads `Rejected` when nothing behind it succeeded.
## My payment method has been Processing for a while. What should I do?
This is the single most common question after verification is approved, and in most cases the
answer is that nothing is wrong and nothing is waiting on you. `Processing` is not an error
state and not a queue you can jump.
What is worth doing:
1. **Check whether it is blocking you at all.** If the methods you actually intend to launch
with are already `Active`, you can start integrating and testing against them now — a
`Processing` sibling method does not hold them back.
2. **Note that the account-level indicator lags the useful truth.** It stays `IN_PROGRESS`
until the last method lands, so use the per-method rows to decide what you can ship.
3. **Contact support with the method name** if a method matters to your launch, quoting the
method name and status exactly as the dialog shows them.
What will not help: resubmitting your verification (it is already approved), or submitting a
second application (see below).
## One method is Rejected but the rest are Active. Am I live?
Yes, for the `Active` methods. A `Rejected` method does not invalidate the others and does not
reopen your verification. It means that one method is not available to you, and your
account-level Payment Enrollment indicator will stay `IN_PROGRESS` while it sits there. If you
need that method, raise it with support rather than resubmitting anything — the fix, when there
is one, is not on the verification form.
## My application says Returned. What is different about that state?
Returned is the one review outcome that is *yours* to act on, and the wizard narrows itself
accordingly:
| Application state | What the verification wizard lets you do |
| - | - |
| Draft | Edit everything |
| In review | Read-only |
| Returned | Edit only the flagged items |
| Approved | Read-only |
| Rejected | Read-only |
In Returned mode the reviewer's notes appear at the top, the step indicator marks which steps
contain flagged items, and each flagged field or document carries its own note in place.
Everything else stays locked, so the marks double as your to-do list. Resubmitting puts the
application back into review. The item-by-item detail is in
[KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq).
## My application says Rejected. Can I edit and resubmit?
No — in Rejected the wizard is read-only, the same as Approved. There is no self-service path
from Rejected back into editing, and creating a second account is not a workaround (a second
sign-up with the same email is rejected as already registered, and a fresh account starts the
whole journey from zero with no history). [Contact support](mailto:support@waffo.com)
with your merchant ID.
## Can I keep testing while enrollment runs?
Yes. Your sandbox is unaffected by the enrollment stage — sandbox credentials, test cards and
the full API surface stay available throughout, so integration work does not have to wait for
card 3. What you cannot do until a method is `Active` is charge a real customer with that
method. Sandbox setup and credentials are covered in
[Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq).
## Once everything is Active, where do I check what I am actually contracted for?
The enrollment dialog answers "is this method switched on". It does not list your commercial
terms, the currencies you can charge in, or per-method amount limits. Those live on your
service pages in the Portal, and the amount limits are also readable through your pay-method
configuration query — see the
[Authentication & Signature FAQ](/docs/en/support/auth-signature-faq) for how to read
`payMethodDetails` from that query rather than the empty `payMethods` array.
***
Still stuck after checking the per-method rows? [Contact support](mailto:support@waffo.com)
with your merchant ID, the payment method name as shown in the dialog, and its status.
# Payment Links (No-code Collection)
Source: https://waffo.com/docs/en/support/payment-link-faq
Collect a payment from the Merchant Portal without integrating anything: what a payment link is, the fields you fill in when creating one, how expiry works, why one link means exactly one order, where to track the result, and when to use Checkout instead.
A **payment link** is a checkout URL you create in the Merchant Portal and send to a customer.
Nothing has to be integrated on your side: Waffo creates the order, hosts the payment page, and
shows you the result in the Portal.
Find it under **Transactions → Payment Link**. Creating links is permission-controlled — the
**New Payment Link** button is disabled for users who do not hold that permission. If you can open
the page and the button is active, the feature is enabled for your account.
## When should I use a payment link instead of an integration?
| Situation | Use |
| - | - |
| No website or app, or the sale happens over chat, email or phone | **Payment link** |
| One-off collection: an invoice, a deposit, a manual re-bill | **Payment link** |
| A checkout flow inside your own site or app | [Checkout](/docs/en/developer-docs/integration/checkout/steps) |
| Volume, your own order IDs, webhooks, refunds triggered by your system | [API integration](/docs/en/developer-docs/integration/api/overview) |
| Recurring billing | Subscriptions — a payment link cannot create one |
The two are the same rails, not two products: a payment link is created through the same
order-creation path as an API order, and the URL you copy is the same hosted payment page that
`orderAction.webUrl` returns to an API integration. What differs is who creates the order — the
Portal, or your server.
## What do I fill in when creating a link?
| Field | Required | Notes |
| - | - | - |
| **Portal User Terminal** | Yes | `Web` or `App` — where the payer will open the link. It affects which payment methods can be offered and what kind of link is generated. |
| **Order Description** | Yes | Free text. Shown on the confirmation dialog and as the first column of the list, so make it identifiable. |
| **Product** | Yes | `ONE_TIME_PAYMENT` or `DIRECT_PAYMENT`. These are the only two accepted; anything else is rejected. |
| **Country** | Yes | The payer's country. Drives the payment methods shown on the page. |
| **Acquiring Currency** | Yes | The order currency — the currency you are collecting in. |
| **User Currency** | No | The currency the payer is charged in, when it differs from the acquiring currency. Leave empty for same-currency collection. |
| **Price** | Yes | Must be greater than zero. Fixed once the link is created. |
| **Email** | No | Must be a valid address. Stored as the payer identifier on the order. Leave it blank and a placeholder identity is generated instead — so fill it in whenever you need the order traceable to a real customer. |
| **Order Expired At** | No | See below. Past dates cannot be picked. |
**Product** must be one that is enabled on your Payin contract — check **Service → Payin** in the
Portal. A product that is not in your contract fails validation, the same way it would on
`/api/v1/order/create`.
On submit you get a confirmation dialog with the description, amount and currency, the country, and
the link itself with a copy button. Copy it there and share it — that is the moment the link is
handed to you.
## How does link expiry work?
* **Leave Order Expired At empty** and the default order-expiry window configured for your account
applies. The Portal surfaces that default in the tooltip on the **Order Expired At** column.
* **Pick a date and time** to override it. The picker blocks days in the past; an expiry that has
already passed by the time the request is processed is ignored and the default applies instead.
* Give the payer usable room. A window of only a few minutes is accepted but leaves almost no time
to complete a payment — especially for methods that involve a bank app or an over-the-counter step.
After expiry the underlying order reaches its final state (`ORDER_CLOSE`) and the link no longer
completes a payment. Expiry is the only lapse mechanism the page offers.
## Can I cancel or reuse a link?
**No revoke button.** The list's only row action is **Copy URL**. If you need to close a link early
and you have an API integration, cancel the underlying order with `POST /api/v1/order/cancel` using
the `acquiringOrderId` shown in the list — cancellation is possible while the order is still open,
not after it has succeeded. Otherwise, set a short expiry when you create the link.
**No reuse.** Each link is created together with exactly one order:
| Concept | Relationship |
| - | - |
| One submission of the create form | One acquiring order, one `acquiringOrderId`, one link |
| One link | One payment, at one fixed amount |
| Two customers, or a second payment | Two separate links |
Treat a link as a single invoice, not as a reusable "pay me" page. Because the object behind it is a
normal acquiring order, everything downstream behaves normally: it appears in
**Transactions → Payments**, is searchable there by **Acquiring Order ID**, and is refunded,
reconciled and settled like any other order.
## Where do I see the links I created, and whether they were paid?
The **Payment Link** list shows, newest first:
| Column | Meaning |
| - | - |
| Order Description | What you typed at creation |
| Product | `ONE_TIME_PAYMENT` / `DIRECT_PAYMENT` |
| Country | The payer country selected at creation |
| Price | Order amount and acquiring currency |
| Acquiring Order ID | Waffo's order ID — the key for looking the order up elsewhere |
| Status | The live status of the underlying order |
| Created Time / Created By | When, and which Portal user created it |
| Order Expired At | The effective expiry, with the default explained in its tooltip |
| Action | **Copy URL** |
Status values are the order statuses you already see in **Transactions → Payments**:
`AUTHORIZATION_REQUIRED` (link issued, payer has not finished), `PAY_IN_PROGRESS`,
`AUTHED_WAITING_CAPTURE`, `CAPTURE_IN_PROGRESS`, `PAY_SUCCESS`, and `ORDER_CLOSE` (closed, including
expired and abandoned links).
This page is a creation log, not an analysis view: it has no filters and no export. For filtering,
transaction detail, refunds and receipts, work from **Transactions → Payments** with the Acquiring
Order ID.
## A link failed to create. What now?
Creation runs the same validation as API order creation, so the usual causes apply: a product,
country or currency that is not enabled on your contract, or a payment-method combination your
account cannot serve. The error message names the offending parameter — check it against your
**Service → Payin** page first.
***
Need behaviour this page does not cover — reusable links, your own order ID on the order, or
programmatic creation? That is what the
[API integration](/docs/en/developer-docs/integration/api/overview) path is for.
[Contact support](mailto:support@waffo.com) with the Acquiring Order ID when a specific link
misbehaves.
# Reading Your Payment Method Configuration
Source: https://waffo.com/docs/en/support/payment-method-config-and-limits-faq
How to discover programmatically which payment methods your account can use: every field on the pay-method configuration response, why currentStatus can come back empty, what the four refundAllowed codes mean, the parameter errors that name your merchant ID or your notifyUrl, and the export columns and list filters that changed.
This page is about the **API contract**: how to read your payment method
configuration from code, and the request-level errors that come back when a
configuration field is wrong.
Three neighbouring pages cover the same subject from other angles, and this page
does not repeat them:
* [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq) —
the same configuration as the Portal renders it, on **Service → Payin**: every
column, the fee notation, agreement statuses, filters and export.
* [Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement) —
which limit each of `A0008` / `A0020` / `A0021` refers to, and how the daily
ceiling is counted.
* [Conditional & Required Fields](/docs/en/support/conditional-required-fields-faq) —
which order-creation fields become mandatory for which kind of payment method.
## How do I find out from code which payment methods my account can use?
Call `/api/v1/paymethodconfig/inquiry`. It takes one meaningful field:
| Field | Required | Notes |
| - | - | - |
| `merchantId` | Yes | Must be the same merchant ID the API key you signed with belongs to — see [the merchant-ID error below](#i-got-a-parameter-error-that-mentions-my-merchant-id-what-is-wrong) |
The response is your merchant ID plus `payMethodDetails`, an array with **one
entry per payment method contract that is currently in force on your account**.
Treat this endpoint as the source of truth for your own integration logic —
which methods to offer, which currencies to accept, what amount range to
validate against before you create an order — rather than hardcoding a list you
copied out of a specification once.
## What does each field in the response mean?
| Field | Meaning |
| - | - |
| `productName` | The payin product the contract belongs to: `ONE_TIME_PAYMENT`, `DIRECT_PAYMENT`, `SUBSCRIPTION` or `MINI_PROGRAM_PAYMENT` |
| `payMethodName` | The payment method's name, in the spelling you send back on order creation |
| `country` | The payment method's country, as a three-letter code. Two values are not countries: `EU_LIST` for a group of European markets, and `GLB` for a method not tied to a market |
| `payMethodType` | The category the method belongs to, for example `EWALLET`, `CREDITCARD`, `ONLINE_BANKING` — [the full enumeration](/docs/en/support/payment-method-coverage-faq#what-are-the-paymethodtype-categories) |
| `currency` | The currency this contract supports, as an ISO 4217 code |
| `orderAmountMin` / `orderAmountMax` | The order amount range this contract accepts, as decimal strings |
| `refundAllowed` | What shape of refund the method permits — [four codes, below](#what-do-the-four-refundallowed-values-mean) |
| `currentStatus` | Real-time availability: `1` available, `0` not available. **Can be empty** — see below |
| `fixedMaintenanceRules` | Scheduled unavailability windows, as pairs of cron expressions |
| `fixedMaintenanceTimezone` | The timezone the maintenance cron expressions are evaluated in, for example `UTC+08:00` |
Two parsing rules worth building in from the start:
* **Amounts are strings, not numbers.** Parse them as decimals; do not let a
JSON library turn them into floats.
* **`orderAmountMin` / `orderAmountMax` can be absent.** Where a contract has no
range configured, the field is simply not populated, and no amount-range check
applies to that contract.
## Why does the same payment method appear more than once?
Because the array is keyed on **contracts**, not on payment methods. One entry
is emitted per contract in force, and a single payment method can legitimately
be under several: one per currency, or one per payin product.
So `payMethodName` is **not** a unique key in this response. If you are building
a lookup table, key it on the combination you actually need — typically
`payMethodName` + `currency`, or `productName` + `payMethodName` + `currency`.
Nothing in the response is de-duplicated for you.
## `currentStatus` came back empty. Is the payment method down?
No — an empty `currentStatus` means **"not known right now"**, not "unavailable".
The contract data and the real-time availability come from two different places.
The contract half is always present. The availability half is looked up
separately, and where that lookup does not cover a contract, `currentStatus`,
`fixedMaintenanceRules` and `fixedMaintenanceTimezone` are all left unset for
that entry rather than being guessed at.
How to handle it:
| `currentStatus` | What it means | Sensible behaviour |
| - | - | - |
| `1` | Available | Offer the method |
| `0` | Not available right now | Hide the method, or show it as temporarily unavailable |
| empty / absent | Availability could not be determined | **Do not treat as unavailable.** Fall back to your normal behaviour and let order creation be the authority |
The last row matters most: a client that reads "empty" as "off" will hide
perfectly working payment methods the moment an availability lookup has a bad
minute. The authoritative answer to "can this method take this payment" is
always the response to order creation.
## Does the response include contracts that have ended?
No. The query behind this endpoint selects only contracts that are in **`ACTIVE`
status** *and* whose validity window covers the current moment — a start date at
or before now, and either an end date at or after now or no end date at all.
Consequences:
* Contracts in `INIT`, `SUSPENDED`, `TERMINATED` or `ON_BOARDING` status do not
appear.
* A contract that has not started yet, or whose end date has passed, does not
appear.
* A payment method disappearing from the response is therefore a **contract**
event, not an availability blip. A method that is contracted but temporarily
unusable stays in the response with `currentStatus` of `0`.
So the two questions have two different answers in this response: *"am I
contracted for it?"* is whether the entry exists; *"can it take a payment right
now?"* is `currentStatus`. Do not use one to answer the other.
Agreement statuses as the Portal shows them are described in
[Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq).
## What do the four `refundAllowed` values mean?
They describe the **shape** of refund the payment method permits — not whether
any particular order can be refunded right now.
| Code | Meaning | What a refund call gets |
| - | - | - |
| `0` | Refunds are not allowed on this payment method | Any refund is refused |
| `1` | One full refund only | A partial amount is refused; a second refund is refused |
| `21` | One refund, full or partial | A partial refund is accepted, but only once |
| `22` | Multiple refunds, full or partial | Accepted until the original amount is exhausted |
The Portal renders the same four values as *No*, *Only Full Refund*, *Full
Refund or One-time Partial Refund* and *Full Refund or Multiple Partial
Refunds*.
Two things this value does **not** tell you:
* **Whether the refund window is still open.** That is a separate setting; see
[Payment Method Refunds & Limits](/docs/en/support/payment-method-refund-limits-faq).
* **Whether the order is in a refundable state.** An unpaid or already fully
refunded order is refused regardless of `refundAllowed`.
## What are `fixedMaintenanceRules`, and how do I read them?
They are the payment method's **scheduled** unavailability windows: each rule is
a pair of cron expressions, `startRule` and `endRule`, marking the beginning and
the end of a window. `fixedMaintenanceTimezone` is the timezone they are
evaluated in — it is often not your own, so read it rather than assuming.
Typical shapes:
| Start / end pair | Window |
| - | - |
| `0 30 1 * * ?` / `0 30 2 * * ?` | Every day, 01:30 to 02:30 |
| `0 00 23 ? * 1,2,3,4,5` / `0 59 23 ? * 1,2,3,4,5` | Weekdays, 23:00 to 23:59 |
| `0 00 0 1,15 * ?` / `0 59 23 1,15 * ?` | All day on the 1st and the 15th of the month |
These are known in advance, so they are the one part of availability you can
usefully pre-empt in your UI. Unscheduled outages show up as `currentStatus` of
`0` instead, with no advance notice.
## How do I check the amount range before I create an order?
Read `orderAmountMin` and `orderAmountMax` off the matching entry — matching on
payment method **and currency**, since the range is per contract and two
currencies under the same method can have different ranges.
Validating client-side saves your customer a rejected order, but it is not the
only ceiling. There are three separate limits, with three separate error codes,
and this endpoint only exposes the first:
| Code | Which ceiling | Readable here? |
| - | - | - |
| `A0008` | The payment method's own amount range | Yes — `orderAmountMin` / `orderAmountMax` |
| `A0020` | A per-transaction limit on your account, per order currency | No |
| `A0021` | A daily cumulative limit on your account, per order currency | No |
They are independent: passing the payment method's range says nothing about the
account-level limits, and vice versa. The full breakdown, including how the
daily total is counted and when headroom comes back, is in
[Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement).
## I got a parameter error that mentions my merchant ID. What is wrong?
Every signed endpoint checks the `merchantId` in your **request body** against
the merchant ID that the **API key you signed with** belongs to. If they differ,
the request is refused before anything else happens, with the reason
`mid in params is inconsistent with apiKey`, returned under `A0003`.
This is deliberately a distinct message rather than a generic parameter error,
because the two causes are so different from every other parameter problem:
| Cause | What it looks like | Fix |
| - | - | - |
| Wrong credential for the environment | Sandbox key against a production merchant ID, or the reverse | Match key and merchant ID to one environment |
| Sub-merchant confusion | Sending a sub-merchant's ID in `merchantId` | `merchantId` is the ID the API key belongs to. Sub-merchants are identified in their own field — see [Multi-Brand & Sub-Merchants](/docs/en/support/multi-brand-and-sub-merchant-faq) |
| Copy-paste across accounts | Config from one account, key from another | Keep credentials and IDs in one place per account |
It never means the merchant ID does not exist, and it is never fixed by
retrying.
## `A0003` came back. How do I find out which field was wrong?
Read the message, not just the code. For `A0003` specifically, the specific
reason is **appended to the generic message in parentheses**:
```
Parameter validation failed ( refundAmount must be greater than 0 )
```
The part outside the parentheses is constant. The part inside names the actual
problem — usually the offending field and what was expected of it. `A0003`
comes back with HTTP `400`.
Two implications for your error handling:
* **Log the whole message, not the code.** A log line that records only `A0003`
has thrown away the only part that identifies the bug.
* **Do not parse the parenthesised text programmatically.** It is written for a
human reading a log. Branch on the code; show the text.
Codes other than `A0003` return their standard message with no appended detail,
unless the specific failure is one that is defined to carry extra context.
## What are the rules for my callback URL?
`notifyUrl` — the URL Waffo posts payment notifications to — is validated when
you configure it, and each failure mode has its own error:
| Rule | Failure |
| - | - |
| At most **512 characters** | `notify url exceeds maximum length of 512 characters` |
| Must begin with `http://` or `https://` | `invalid notify url format` |
| **ASCII characters only, no whitespace** anywhere in the URL | `invalid notify url format` |
| Must parse as a URL with both a scheme and a host | `invalid notify url format` |
Both are returned under `A0003`, with the specific reason in the message as
described above.
The two that catch people out are the last two. A URL with a non-ASCII character
in it — an internationalised domain name, or a path segment pasted from a
document — is refused even though a browser would open it: percent-encode it
first. And a trailing space picked up while copying is enough to fail the
whitespace check, with an error that looks like it is about the URL itself.
Leaving `notifyUrl` empty is allowed; no validation runs and no notifications
are sent to it.
## Do I have to send `subscriptionManagementUrl`, and where does it end up?
It is **documented as required** when you create a subscription, and is capped
at **256 characters** — but note that the create call today only enforces the
length: a request without it is **not rejected**. Treat it as required anyway,
because on one account setting (below) it is the only management link your
customer will ever get. What it is for, and why it should be a page that opens in
a desktop browser rather than an app deeplink, is covered in
[Subscription FAQ](/docs/en/support/subscription-faq).
The part that page does not cover is **which management link your customer
actually receives**. That is an account-level setting with two values:
| Setting | The management link in subscription emails is |
| - | - |
| `INTERNAL` | A Waffo-hosted management page, generated per email |
| `MERCHANT_API_URL` | Exactly the `subscriptionManagementUrl` you sent on create |
Two consequences:
* **On `MERCHANT_API_URL`, your value is the link.** If you did not send one, or
sent a page that has since moved, the link in your customer's email is broken
and nothing else compensates for it. Read it back on subscription inquiry to
confirm what is stored.
* **On `INTERNAL`, links from older emails stop working.** Each new email
generates a fresh link and invalidates the previous ones, so a customer
digging out last month's email will need the latest one instead. The generated
link is also time-limited in its own right.
The management page is not part of the real-time payment flow either way — it is
somewhere you point the customer *after* the subscription payment succeeds. See
[Subscription Notifications](/docs/en/support/subscription-notifications-faq) for
which events send an email at all.
## Which columns changed in my transaction and refund exports?
Three changes, and one of them is easy to misread:
| Column | Where it is | What it carries |
| - | - | - |
| `Initiation Type` | Transaction export | `CIT` or `MIT` — who triggered a card transaction |
| `Pay Method Country/Region` | Transaction export | The payment method's country. **Replaces the former `Country/Region` column** |
| `Merchant Original Order ID` | **Refund export only** | On a refund row, your own order ID for the original payment |
The trap is the third row. `Merchant Original Order ID` is defined on the shared
column set but is **deliberately excluded from the transaction export** — it
only means something on a refund row, where it distinguishes your ID for the
refund from your ID for the payment it reverses. If your transaction export
parser is looking for it, it will not find it.
On `Initiation Type`: it is frequently empty. Why, and what populates it, is in
[Transaction & Refund Field Reference](/docs/en/support/transaction-field-reference-faq).
Exports are queued background tasks rather than instant downloads — see
[Exports & Report Downloads](/docs/en/support/export-center-faq).
## Can I narrow the chargeback or refund lists to one subscription or one order?
Yes, and these are the filters people most often miss:
| List | Filter | Matching |
| - | - | - |
| Chargebacks | `relatedSubscriptionId` | All chargebacks arising from one subscription |
| Refunds | `relatedSubscriptionId` | All refunds arising from one subscription |
| Refunds | `merchantOriginalOrderId` | **Exact match** on your own order ID for the original payment |
| Transactions | `relatedSubscriptionId` | All payments belonging to one subscription |
`merchantOriginalOrderId` is the one to reach for when a customer disputes a
purchase and you know only your own order reference: it goes straight from your
order ID to every refund against that payment, without having to look up Waffo's
identifiers first.
Note the naming. The filter is `relatedSubscriptionId`, not `subscriptionId` —
"related" because the row being filtered is a chargeback, refund or payment that
*came from* a subscription, not the subscription itself. To list subscriptions,
filter a subscription list on `acqSubscriptionId` or
`merchantSubscriptionId` instead.
## A customer reopened an old checkout link. What do they see?
A checkout link is a **session**, and sessions do not last forever. Two separate
lifetimes apply:
* Before payment, the link is valid for a limited period from creation. Opened
after that, it reports the checkout as expired rather than taking a payment.
* After the order reaches a final state, the link stops being a payment page and
becomes a **result** page, viewable for a period after completion, then no
longer.
Neither lifetime is something to build your customer experience on. If a
customer needs to see a completed purchase, show it from your own records, or
send them a receipt — see
[Receipt Configuration & Download](/docs/en/support/receipt-configuration-and-download-faq).
And if they need to pay, create a fresh order rather than resending an old link;
what happens to an order behind a reopened link is covered in
[Checkout Order Lifecycle](/docs/en/support/cashier-order-lifecycle-faq).
## Where do I look next?
| Question | Page |
| - | - |
| The same configuration in the Portal, column by column | [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq) |
| What Waffo catalogues at all, and the full field enumerations | [Which Payment Methods and Markets Can I Accept?](/docs/en/support/payment-method-coverage-faq) |
| Which limit each error code refers to | [Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement) |
| Which order fields a given payment method needs | [Conditional & Required Fields](/docs/en/support/conditional-required-fields-faq) |
| Refund windows and per-method refund behaviour | [Payment Method Refunds & Limits](/docs/en/support/payment-method-refund-limits-faq) |
| Signing requests, and what the signed merchant ID is | [Authentication & Signatures](/docs/en/support/auth-signature-faq) |
| Notification delivery, retries and resends | [Webhook Events & Resends](/docs/en/support/webhook-events-and-resend-faq) |
| Sub-merchant identification | [Multi-Brand & Sub-Merchants](/docs/en/support/multi-brand-and-sub-merchant-faq) |
# Which Payment Methods and Markets Can I Accept?
Source: https://waffo.com/docs/en/support/payment-method-coverage-faq
How to answer coverage questions yourself from the published payment method catalogue: which markets are listed, how the table aggregates rows, why one payment method appears under two names, how to tell whether a method does subscriptions or refunds, and how your own contracted list differs from the catalogue.
Most "do you support X in Y?" questions can be answered without asking anyone,
because the catalogue is published: **[Payment Methods](/docs/en/essentials/payment-methods)**
carries a filterable table of every payment method in the Waffo catalogue with
its type, markets, payment currencies, subscription support and refund rules.
This page is about **reading that table** — what a row means, what the filters
do, and which questions the table cannot answer. Three neighbouring pages cover
adjacent ground and are not repeated here:
* [Reading Your Payment Method Configuration](/docs/en/support/payment-method-config-and-limits-faq) —
the same information for **your own account**, from the API.
* [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq) —
the same information for your own account, as the Portal renders it.
* [Payment Method Enrollment](/docs/en/support/payment-enrollment-faq) — getting a
method added to your account.
## Where is the list of supported payment methods and markets?
On [Payment Methods](/docs/en/essentials/payment-methods). The table has three
filters — **Type**, **Country/Region** and **Subscription** — and these columns:
| Column | What it holds |
| - | - |
| Type | The `payMethodType` category — [the full list is below](#what-are-the-paymethodtype-categories) |
| Payment method | The method's catalogue name, in the same spelling you send as `payMethodName` |
| Country/Region | Every market the method is catalogued for, as ISO 3166-1 alpha-3 codes |
| Payment currency | Every currency the method is catalogued in |
| Subscription | Whether the method appears under the subscription product |
| Refundable | Whether refunds are possible at all on the method |
| Partial refund | Which refund shapes the method permits |
| Refund period | How long after payment a refund stays possible |
The **Country/Region filter is the authoritative answer to "which markets do you
cover?"** — read the current option list there rather than from any prose list,
including this page's.
Two market codes are not countries:
| Code | Meaning |
| - | - |
| `EU_LIST` | A group of European markets treated as one catalogue entry |
| `GLB` | Global — not tied to a market at all. Stablecoin acceptance is catalogued this way, so it is never filtered by the shopper's country |
## Is India, Morocco, Pakistan or Russia covered?
None of those four markets appears in the published catalogue. Neither do any
other markets absent from the Country/Region filter — if a market is not in that
filter, there is nothing catalogued for it, and the answer does not change based
on who asks.
The same test settles the reverse question. A market **is** covered if it is in
the filter; whether *your* account is contracted for anything in it is a
separate question, answered by
[your own configuration](/docs/en/support/payment-method-config-and-limits-faq), not
by the catalogue.
## Why does one row cover several countries and currencies?
Because the table aggregates before it displays. Rows in the underlying
catalogue are per market, currency and product; the table groups them by
**payment method name + type** and merges the group into one row:
| Column | How the group is merged |
| - | - |
| Country/Region | Every market in the group, de-duplicated and sorted |
| Payment currency | Every currency in the group, de-duplicated and sorted |
| Subscription | *Yes* if **any** row in the group is under the subscription product |
| Refundable | *Yes* if **any** row in the group is refundable |
| Partial refund | The **most permissive** shape in the group |
| Refund period | The **longest** period in the group |
The consequence matters: a row showing three countries and *Refundable: Yes* does
**not** promise refunds in all three. The optimistic merge means the table
answers *"is this ever true?"*, not *"is this true for my market?"*. For a
per-market, per-currency answer, use the configuration endpoint on your own
account, where each contract is a separate entry.
## Why do I see two names that look like the same payment method?
Because market variants of a wallet are separate catalogue entries with separate
names and separate contracts — for example `ALIPAY` and `ALIPAYHK`, or
`WECHATPAY` and `WECHATPAYHK`. They differ in market, in payment currency, and
in which entry your account is contracted for.
Practical rules:
* **`payMethodName` is what you send, verbatim.** Take it from the catalogue or
from your own configuration response, not from the name a shopper would
recognise. A method's checkout-facing display name and its `payMethodName` are
not always the same string.
* **Being contracted for one variant does not contract you for the other.** If
you send a variant you are not contracted for, order creation fails with
`A0018` — see [Common Errors Troubleshooting](/docs/en/support/errors-troubleshooting-guide).
* The same applies across markets for a single name: one method name catalogued
in several markets is still one contract per market.
## What are the `payMethodType` categories?
`payMethodType` is the coarse category, and it is a fixed enumeration. You can
send it on order creation instead of an exact `payMethodName` to let the shopper
pick within a category, and you can send several comma-separated:
| Value | Category |
| - | - |
| `EWALLET` | e-Wallet |
| `CREDITCARD` | Credit card |
| `DEBITCARD` | Debit card |
| `APPLEPAY` | Apple Pay |
| `GOOGLEPAY` | Google Pay |
| `ONLINE_BANKING` | Online banking |
| `DIGITAL_BANKING` | Digital banking |
| `VA` | Virtual account |
| `OTC` | Over-the-counter |
| `OTCBANK` | Over-the-counter at a bank |
| `BNPL` | Buy now, pay later |
| `QR` | QR code payment |
| `QRIS` | QRIS |
| `USSD` | USSD |
| `NAPASCARD` | NAPAS card |
| `KOREANCARDS` | Korean domestic cards |
| `PREPAIDCARD` | Prepaid card |
| `UNIONPAY` | UnionPay |
| `CRYPTO` | Stablecoin — see [Stablecoin Payments](/docs/en/support/stablecoin-payments-faq) |
Note the spelling: the card categories are `CREDITCARD` and `DEBITCARD`, one
word, and Korean domestic cards are `KOREANCARDS`. There is no `CARD`,
`BANK_ACCOUNT` or `WALLET` value.
Four of these categories — `VA`, `OTC`, `CREDITCARD` and `DEBITCARD` — do not
require you to also name a specific method, because the shopper chooses the
concrete option at the checkout. For every other category, order creation still
expects a `payMethodName` unless you are deliberately sending the shopper to the
Waffo checkout to choose.
## Do Apple Pay and Google Pay need enrolling separately?
Yes. They are their own catalogue categories (`APPLEPAY` and `GOOGLEPAY`) with
their own catalogue entries per market, so each is its own contract — being live
on cards does not make either of them live. Both also carry integration work
beyond the contract, which is why they have their own published guides:
[Apple Pay](/docs/en/developer-docs/integration/apple-pay/overview) and
[Google Pay](/docs/en/developer-docs/integration/google-pay/google-pay).
The same logic applies to any other method: one contract per catalogue entry,
enrolled through the process in
[Payment Method Enrollment](/docs/en/support/payment-enrollment-faq).
## How do I tell whether a payment method can do recurring billing?
There is no "supports subscription" flag on a single contract. Subscription
support is expressed as a **separate product**: `productName` on the catalogue
row. The four payin products are:
| `productName` | What it is |
| - | - |
| `ONE_TIME_PAYMENT` | A single payment |
| `DIRECT_PAYMENT` | A payment taken without sending the shopper to the Waffo checkout |
| `SUBSCRIPTION` | Recurring billing |
| `MINI_PROGRAM_PAYMENT` | Payment inside a wallet's mini program — see [Mini Program Payments](/docs/en/support/mini-program-payments-faq) |
So a method is available for recurring billing when there is a catalogue entry
for it under `SUBSCRIPTION`. On the published table this is the **Subscription**
column; on your own account it is a `payMethodDetails` entry whose `productName`
is `SUBSCRIPTION`.
Two consequences merchants regularly hit:
* **One-time and subscription are enrolled separately.** Having a method live
for one-time payments does not make it available for recurring billing on your
account; that is a second contract.
* Remember the aggregation rule above — on the published table, *Subscription:
Yes* means at least one market/currency combination for that method is under
the subscription product, not all of them.
Which methods behave well under retries, and what a wallet-based subscription
can and cannot do, is in
[Subscription Payment Methods & Retries](/docs/en/support/subscription-retry-and-payment-methods-faq)
and on the published
[Subscription payment methods](/docs/en/developer-docs/tools-and-references/references/subscription-payment-methods)
page.
## Does the payment method depend on whether the shopper is on web or in an app?
It can, and you declare the context with `userInfo.userTerminal`:
| Value | The shopper is |
| - | - |
| `WEB` | On a desktop browser |
| `APP` | On a mobile phone |
| `IN_WALLET_APP` | Inside a payment method's own wallet app |
| `IN_MINI_PROGRAM` | Inside a payment method's mini program |
`WEB` is the default when you send nothing. Not every payment method supports
every terminal, so the value can change which methods are usable for an order —
and for the two in-wallet values it is not optional context but the thing that
makes the payment possible at all.
The catalogue does not carry a per-method terminal matrix. The reliable way to
find out is the order-creation response: send the terminal your shopper is
actually on, and let the response tell you whether the method is usable. Do not
infer a terminal restriction from a method's absence in one context and presence
in another.
## What is `payOption`, and do I enroll each option separately?
`payOption` is the sub-choice *within* a payment method — paying from a wallet
balance, from a debit card, from a credit card, and so on. Two things to know:
* **On order creation and inquiry it is read-only.** It reports what the shopper
actually chose, and it comes back empty on methods that offer no choice.
* **On your contract it can be priced per option.** Where it is, your
[Payin agreement page](/docs/en/support/payin-agreements-and-limits-faq) shows one
fee line per option instead of one per method, and a method with no meaningful
per-option distinction renders as `-`.
So you do not enroll options one by one as if they were payment methods — they
come with the method's contract. What you may see is the same method priced
differently depending on which option the shopper picks.
## How do the catalogue and my own contracted list differ?
They answer different questions, and mixing them up is the most common cause of
"it works in the docs but not on my account":
| Question | Where the answer is |
| - | - |
| Does Waffo have this method at all? | The published catalogue |
| Am I contracted for it? | `/api/v1/paymethodconfig/inquiry`, or **Service → Payin** in the Portal |
| Can it take a payment right now? | `currentStatus` on that same response, and ultimately the order-creation response |
| What are my amount limits for it? | `orderAmountMin` / `orderAmountMax` on that same response, per contract |
Your own list is always the narrower one, and it is the only one your
integration should branch on. Treat the published catalogue as a planning
document for "which markets should we ask to be enrolled in", never as a runtime
source of truth.
## Where do I look next?
* [Reading Your Payment Method Configuration](/docs/en/support/payment-method-config-and-limits-faq) —
every field of your own configuration response, and the errors it produces
* [Payment Method Enrollment](/docs/en/support/payment-enrollment-faq) — how a method
gets added, and what each enrollment status means
* [Payment Method Refunds & Limits](/docs/en/support/payment-method-refund-limits-faq) —
refund shapes, refund windows and refund minimums per method
* [Checkout Display Rules](/docs/en/support/cashier-display-rules-faq) — how the
methods you are contracted for are ordered and grouped on the checkout
* [Payment Methods](/docs/en/essentials/payment-methods) — the catalogue itself
# Refund Limits by Payment Method
Source: https://waffo.com/docs/en/support/payment-method-refund-limits-faq
Why the same refund request succeeds on one payment method and is rejected on another: partial-refund availability, refund windows and deadlines, how many refunds one order allows, duplicate-request protection, refunds before and after capture, closed user accounts, and issuer-declined card refunds.
The [Refund FAQ](/docs/en/support/refund-faq) covers the rules that apply to every
refund: amount validation, idempotent retries, cross-currency conversion,
asynchronous status, and fee return. This page answers the question that comes
after those: **why the identical request succeeds on one payment method and is
rejected on another.** Cross-currency arithmetic lives in
[Exchange Rates](/docs/en/support/exchange-rates-faq), and the error codes involved
are in [Error Codes & Limits Supplement](/docs/en/support/error-codes-and-limits-supplement).
The short version: **refund capability is a property of the payment method, not
of your account.** Two methods enabled on the same account, charged through the
same integration, can have completely different refund behaviour.
## Why does the same refund request succeed on one payment method and fail on another?
Because each payment method carries its own refund rules, and Waffo does not
soften them. The API surface is identical for every method — the difference is
which requests the method accepts.
The table below groups the behaviour we see by **payment-method category**.
Treat it as the shape of the variation you have to design for, not as a lookup
table for a specific method: the exact rules for the methods enabled on your
account are part of your payment-method configuration.
| Payment-method category | Full refund | Partial refund | Multiple partial refunds | Notable limit |
| - | - | - | - | - |
| Cards | Yes | Depends on the configuration; some card setups are full-refund only | Where partial refunds are available | Can be **declined by the issuing bank**, even after Waffo accepted the request |
| E-wallets | Yes | Commonly yes | Commonly yes, sometimes only after it is enabled for your account | Duplicate-request protection and minimum intervals between requests |
| Prepaid electronic money | Yes | Yes | **No** — one refund per original transaction | Refundable only within a fixed window after the original transaction |
| Post-pay / credit (BNPL-style) | Yes | Yes, while the user's account is active | Depends on the method | The window is tied to the method's credit-retention period; a **closed user account** blocks partial refunds |
| Bank transfer / virtual account | Depends on the method — several are **not refundable through the API** at all | Same | Same | Where the API cannot refund, the money is returned outside the payment method (see [Bank Transfers & Virtual Accounts](/docs/en/support/bank-transfer-and-virtual-account-faq)) |
| Methods with separate authorization and capture | Yes | **Only after capture** | After capture | Reversal of a captured payment has its own deadline, counted from the payment |
Three consequences worth designing for:
1. Do not hardcode "this method supports partial refunds" in your own code.
Submit the refund and handle the rejection.
2. Do not assume that because one method on your account supports something,
another does.
3. A refund that Waffo accepted can still end in a failed final state.
## Is partial refund available on every payment method I have enabled?
No. Partial refund is a capability that has to be available for the specific
payment method **and** enabled for your account — it is the `Refundable` column
on your Service → Payin row. Where the row permits only a full refund, a partial
refund request is rejected with `A0014` ("Order cannot be refunded due to refund
rules") while a full refund on the same order succeeds. The same code is returned
when the row allows one partial refund and you ask for a second.
`A0017` ("Order cannot be refunded due to payment channel not supported") is a
different failure: the payment method itself cannot carry a refund at all, so
neither a partial nor a full refund goes through on it.
Where a payment method could behave in more than one way, **the most
restrictive option applies**. Practically:
* If a method can refund only in full for your account, partial requests fail
even though the method is capable of partial refunds elsewhere.
* If a method allows only one partial refund for your account, the second
request fails even though the first succeeded.
Waffo does not silently downgrade a partial refund into a full refund, and does
not queue it for later. If you need a capability that is currently rejected,
ask your Waffo integration contact whether it can be enabled — it is a
configuration change, not something you can pass as a parameter.
## How many refunds can one order take?
Three separate ceilings apply, and you hit whichever is lowest:
| Ceiling | Value | Error when exceeded |
| - | - | - |
| Waffo platform maximum per order | **50** refunds | `A0027` "The refund has exceeded maximum 50 times" |
| Amount | refunded + in-progress + this request ≤ original payment amount | `A0003` — it is treated as a parameter failure, not a refund-rule breach |
| Agreement refund shape | From "one refund only" and "one partial refund per order" up to unlimited partial refunds | `A0014` |
Some methods start at exactly one partial refund per order and can be raised
from there; a few place no limit on the count at all. Because the lowest
ceiling wins, build your reconciliation so that a rejected second refund is a
normal branch, not an incident.
## Is there a deadline for refunding an order?
Yes, and the clock always starts at the **original order**, never at the moment
you decide to refund. The window is a property of the payment-method row on your
account, and you can read it in two places rather than guessing:
| Where | What it shows |
| - | - |
| Service → Payin, `Refund Period` column | The window for that payment method and currency |
| `refundExpiryAt` on the order (Order Inquiry) | The concrete deadline for that order — derived from the window, counted from the order's creation date, landing at the end of that day |
The shape varies by category — prepaid electronic money and post-pay methods tend
to have the shortest windows, cards the longest, subject to the issuer — but the
figures are per method and per account, so do not carry one method's window over
to another.
After the window closes the refund is rejected — there is no override. If your
product allows returns over a long period, check the refund window of the
methods you accept before you publish the policy, and settle late returns
outside the original payment.
## I refunded the order, but its `orderStatus` is unchanged. Did the refund work?
Yes, most likely. **A refund does not change the original order's status.** The
original order stays in the state it reached when it was paid; the refund is a
separate record with its own status, and that is the only place refund progress
appears.
| Where you look | What you see |
| - | - |
| `/api/v1/order/inquiry` on the original order | The original payment status, unchanged by refunds |
| `/api/v1/refund/inquiry` on the refund | `REFUND_IN_PROGRESS` → `ORDER_PARTIALLY_REFUNDED` / `ORDER_FULLY_REFUNDED` / `ORDER_REFUND_FAILED` |
| The refund order notification | The final refund state |
So "the order still shows as paid" is not evidence that the refund was lost. If
your bookkeeping keys refund state off the original order, it will look
permanently stuck — key it off the refund record instead.
## I resent a refund immediately and it was rejected as a duplicate. Was anything refunded?
Probably not by the second request — that is what the protection is for. Two
kinds of guard exist:
* **Duplicate detection.** Some methods reject a second request that carries
the same original transaction reference *and* the same amount within a short
window — a rule commonly seen on wallets that otherwise place no limit on the
number of partial refunds.
* **Minimum interval.** Some methods require a short gap between consecutive
refund requests on the same order.
Both are protections against accidental double refunds, so do not treat the
rejection as a failure to be retried in a tight loop. The safe pattern:
1. Keep the same `refundRequestId` when you resend the *same* refund — it is
the idempotency key, and it makes a retry harmless.
2. Do not fire genuine second refunds on the same order back-to-back; let the
first reach a final state.
3. Between attempts, call `/api/v1/refund/inquiry` rather than resending.
## The user closed or deleted their account. Can I still refund them?
Not always. On post-pay / credit methods a **closed user account blocks partial
refunds**, and once a defined period has passed since the closure the refund is
rejected outright. Full refunds usually remain possible for longer.
There is no way for Waffo to route around a closed account: the money has to go
back to the account it came from. If the refund is rejected for this reason,
settle with the user by other means.
## Can I refund before the payment is captured?
Only in full. On methods where authorization and capture are separate steps:
| Order state | What you can do |
| - | - |
| Authorized, not captured | **Full refund / cancellation only** — no partial refund |
| Captured | Full or partial refund, subject to the method's partial-refund rules |
| Captured, being reversed | Reversal has its own deadline, counted from the payment — read `refundExpiryAt` on the order |
Capture itself has a deadline too, and the capture amount can never exceed the
original authorized amount. If you need to return part of an authorization,
capture the amount you are keeping rather than capturing the whole authorization
and refunding the difference.
## Why can one method on my account only be refunded in full while another accepts several partial refunds?
Because refund capability follows the **payment method**, not the account. It is
routine for one account to have a card method that is full-refund only next to a
wallet method that accepts repeated partial refunds. Nothing about your account,
your contract type, or the API you call changes that.
The design implication is concrete: your refund UI and your customer-service
scripts should be driven by the method that was used on the order, not by a
single account-wide rule.
## Is there a minimum refund amount, and a maximum?
The maximum is always the same: **the original payment amount**, less anything
already refunded or in progress. There is no payment method on which you can
return more than was taken; asking for more is rejected with `A0003`.
The minimum is the practical one people trip over. It is the smallest amount the
currency can express, and on cross-currency orders it is the smallest amount the
*converted* figure can express — which is why a refund that looks non-zero to
you can be rejected. If the converted amount comes out as zero or below, the
refund is rejected with `A0014`; see
[Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement).
A useful habit for cross-currency orders: refund by **proportion** of the
original order rather than by an absolute figure in your own currency, so the
converted amount scales with the original payment instead of rounding away.
## The refund reports success, but the user says the money has not arrived
Both statements can be true at once. A successful refund means **the refund
request completed successfully** — it does not mean the funds have already landed
in the user's account. Settlement of the refund is keyed off that successful
result, not off the user's balance.
In practice the money follows quickly on most methods, but the two events are
distinct and the gap is outside Waffo's control:
| What you observe | What it tells you |
| - | - |
| `ORDER_FULLY_REFUNDED` / `ORDER_PARTIALLY_REFUNDED` | The refund was accepted and processed by the payment method |
| The user's balance | The payment method's or bank's own posting time |
So the support answer is "the refund has gone through; how quickly it shows up
depends on the user's bank or wallet". Escalate to Waffo support only when the
refund has been in a successful final state for a materially long time and the
user still cannot see it — and bring the original order ID and the refund ID.
## The refund ended in `ORDER_REFUND_FAILED`. What happened, and what do I do?
`ORDER_REFUND_FAILED` is a **final** state — the money did not move and will not
move on that refund record. The most common cause on card payments is that the
**issuing bank declined the refund** after Waffo had already accepted the
request, which is why the failure arrives asynchronously rather than as an API
error.
What to do:
1. Read the refund order notification or `/api/v1/refund/inquiry` for the
reason.
2. Do not resend with the same `refundRequestId` expecting a different outcome —
a final state does not reopen.
3. If the decline is not explained by the amount, the window, or the method's
rules, raise it with Waffo support with the original order ID and the refund
ID. Refunds that fail for a reason outside your control are handled
case-by-case.
4. Where the card cannot be credited at all, settle with the user by another
route.
## Which of these limits apply to me, and where do I look them up?
There is no single "refund capability" field you can read before submitting.
What you can rely on:
* The **Merchant Portal → PayIn** page lists the payment methods enabled for
your account, with their currencies and amount limits.
* `/api/v1/paymethodconfig/inquiry` returns the same configuration
programmatically.
* The refund API's rejection is the authoritative answer for a specific
request: `A0014` means the agreement's refund rules do not allow it (shape,
count, window, or a converted amount that rounds to nothing), `A0017` means
the payment method cannot carry a refund at all, `A0027` means the platform
count ceiling was reached, and `A0003` means the amount exceeds what is left
or required refund fields are missing and are listed in the response.
Because of that, the robust integration pattern is **attempt, then branch** —
not **predict, then attempt**.
## Where do I look next?
| Question | Page |
| - | - |
| General refund rules, batch refunds, fee return | [Refund FAQ](/docs/en/support/refund-faq) |
| Refund amounts on cross-currency orders | [Exchange Rates](/docs/en/support/exchange-rates-faq) |
| Over- and underpayment on bank transfers | [Bank Transfers & Virtual Accounts](/docs/en/support/bank-transfer-and-virtual-account-faq) |
| Stablecoin refunds and the mandatory refund address | [Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq) |
| What each refund-related error code means | [Error Codes & Limits Supplement](/docs/en/support/error-codes-and-limits-supplement) |
# Confirming Payment Results
Source: https://waffo.com/docs/en/support/payment-results-faq
How to reliably determine whether a payment succeeded: the roles of redirects, webUrl, webhooks and Order Inquiry, what each orderStatus means, and what to do when no notification arrives.
The questions on this page come directly from real merchant conversations.
The single most important rule:
> **A redirect only means the user's browser came back to your site. It is
> never proof of payment.** The authoritative source of a payment result is
> the [Webhook notification](/docs/en/developer-docs/webhook/overview), with the
> Order Inquiry API as the reconciliation fallback.
## The user was redirected back to my site — is the payment successful?
Not necessarily. `successRedirectUrl` is UI navigation only. Confirm the
result through either of these, in order of preference:
1. **Webhook** — Waffo posts the payment result to your `notifyUrl` when the
payment completes or fails.
2. **Order Inquiry** — call `/api/v1/order/inquiry` to actively query the
order when you need to reconcile.
Mark an order as paid only when one of these reports a final success.
## The user closed the browser before being redirected — did the payment fail?
No. The payment may still complete on the payment method's side. The webhook
fires regardless of what the user's browser does. If in doubt, call Order
Inquiry.
## What is the difference between `webUrl` and `successRedirectUrl`?
They travel in opposite directions:
| Field | Direction | Meaning |
| - | - | - |
| `webUrl` (inside `orderAction`) | You send the user **to** the payment | When `orderStatus` is `AUTHORIZATION_REQUIRED` and `actionType` is `WEB`, redirect the user to `webUrl` to authorize / pay. For `DEEPLINK`, use `deeplinkUrl` instead. |
| `successRedirectUrl` (order creation parameter) | The user comes **back** after payment | Where the user lands after a successful payment. Optional — if omitted, the user stays on the Waffo cashier page. |
`failedRedirectUrl` and `cancelRedirectUrl` cover the failure and
user-cancellation returns in the same way.
## What does each `orderStatus` value mean?
| Status | Meaning |
| - | - |
| `PAY_IN_PROGRESS` | Order accepted; the user is in the process of paying. |
| `AUTHORIZATION_REQUIRED` | The payment method needs user authorization — bring the user to the page indicated by `orderAction`. |
| `AUTHED_WAITING_CAPTURE` | Authorized and waiting for merchant capture (card payments). |
| `PAY_SUCCESS` | Final success. |
| `ORDER_CLOSE` | Final close — the order was cancelled, failed, or timed out. |
Only `PAY_SUCCESS` and `ORDER_CLOSE` are final states.
## The order timed out — `TIMEOUT_CLOSE` vs `TIMEOUT_CHANNEL_CLOSE`?
Both are failure codes on orders closed by timeout. They tell you **where
the user dropped off**:
| Failure code | What happened |
| - | - |
| `TIMEOUT_CLOSE` | The user abandoned the payment on the Waffo cashier / payment page — no request was ever initiated towards the payment service. |
| `TIMEOUT_CHANNEL_CLOSE` ("Timeout waiting for channel response") | A payment or verification request (for example 3DS or a wallet redirect) was already initiated, but no result came back before the order timed out. |
Use the split to analyse your conversion funnel: `TIMEOUT_CLOSE` means
drop-off before authorization started, `TIMEOUT_CHANNEL_CLOSE` means the
flow stalled after it started. When Waffo cannot determine whether a request
was initiated, the order falls back to `TIMEOUT_CLOSE`. The distinction
applies to all payment methods; older orders keep their original code.
## Bank transfer / over-the-counter payments: why two notifications?
Payment methods where the user pays outside the checkout session — bank
transfer / virtual account (VA) and over-the-counter (OTC) — report in two
stages:
1. **Acceptance** — after order creation you receive an acceptance result.
This is **not a final payment status**: the user has not paid yet.
2. **Payment result** — the final notification arrives only after the user
completes the payment at their bank, ATM, or the counter.
The gap between the two can be long, and the automatic close time differs
per payment method. Treat only `PAY_SUCCESS` / `ORDER_CLOSE` as final,
exactly as for synchronous methods.
## I did not receive a webhook. What should I check?
1. `notifyUrl` must be **HTTPS (TLS 1.2+)** and reachable from the public
internet.
2. Verify the `X-SIGNATURE` header with the **Waffo public key** — and make
sure your endpoint isn't rejecting valid requests.
3. Respond with `{"message": "success"}` after processing. If Waffo receives
`failed` or `unknown`, Waffo retries the notification on a back-off
schedule spread over the following day — see
[Webhook retry](/docs/en/developer-docs/webhook/retry) for the schedule.
4. While investigating, use Order Inquiry to fetch the order state so your
business flow is not blocked.
## When should I call Order Inquiry instead of waiting?
* As a **reconciliation fallback** when no webhook has arrived within your
expected window.
* When you receive an `Exxxxx` unknown error on a request — retry the inquiry
until you get a final `PAY_SUCCESS` or `ORDER_CLOSE`.
* In batch reconciliation jobs.
Order Inquiry and webhooks return the same order state; they never disagree
on a final status.
## The order creation failed. Will I still get a notification?
Yes. `PAYMENT_NOTIFICATION` is sent when a payment **completes or fails** — a
failed order is notified exactly like a successful one, so do not build your
flow on the synchronous create response alone.
The notification is also richer than that response:
| Where you look | What you get |
| - | - |
| `/api/v1/order/create` response | `paymentRequestId`, `merchantOrderId`, `acquiringOrderId`, `orderStatus`, `orderAction` (plus `x402Info` for stablecoin orders) |
| `PAYMENT_NOTIFICATION` (and Order Inquiry) | The full order object, including **`orderFailedReason`** — a JSON value carrying `orderFailedCode` and `orderFailedDescription` |
So the failure reason for a rejected order is available in the notification
and through Order Inquiry, not in the create response. Match the notification
to your own record with `paymentRequestId` or `merchantOrderId`; both are
echoed back. Acknowledge it the same way as any other webhook — see
[event type list](/docs/en/developer-docs/webhook/event-types).
## How long can the customer still pay after I create the order?
`orderExpiredAt` is optional. If you omit it, the default expiry time from
your merchant contract applies. What it controls is narrower than it looks:
* It governs the window in which the customer can **submit the order on the
checkout page** (must be UTC+0).
* Once the order has been submitted to the payment method, the expiry is
governed by **that payment method**, not by your value. A small number of
payment methods accept a channel-side expiry time from the merchant — the
Payin page in the Merchant Portal shows the expiry behavior per payment
method.
* Passing a time that has already elapsed is rejected at creation with
`A0034` ("the order expiry time provided by the merchant has expired").
The practical consequence: your close time and the payment method's close
time are not the same clock, which is why a payment can still land after
Waffo has closed the order — see the last section on this page for how to
handle that case.
## An offline or bank-transfer order has been pending for hours. Should I cancel it?
No. For over-the-counter (OTC) and virtual-account / bank-transfer payments
the customer pays outside the checkout session, so a long pending period is
normal rather than a symptom of failure — and the automatic close time
differs per payment method.
Do not infer failure from elapsed time, and do not release or void the
purchase on your side while the order is still open: wait for the final
notification, or poll Order Inquiry. Only `PAY_SUCCESS` and `ORDER_CLOSE`
are final.
## I sent the same request twice at the same time and got an error. Did either go through?
A concurrency rejection is protection, not a business failure. Creating the
same order from two requests in parallel returns `A0040` ("the order cannot
be created concurrently") instead of creating two orders.
Handle it like this:
1. **Serialize calls per order** — one in-flight request per order at a time,
rather than parallel retries.
2. **Resolve the state, do not guess it.** Call Order Inquiry to see what the
order actually is before deciding anything.
3. When you retry the original request, reuse the same `paymentRequestId` —
it is the idempotency key. See the
[error code quick reference](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes)
for `A0011` and for `E`-class unknown-status handling.
## The user says they paid, but the order shows failed or timed out
Occasionally a payment completes on the payment method's side after the
Waffo order has already been closed as timed out ("failed first, succeeded
later"). How to handle it:
* **Never fulfil based on a user's payment screenshot.** Fulfil only when a
webhook or Order Inquiry reports `PAY_SUCCESS`.
* Report the case to Waffo support with the order IDs. Waffo reconciles the
order with the payment method side; the payment is then either confirmed
to you or the debited amount is returned to the user.
* The user may be asked for additional details to locate the transaction,
so collect what they can share — payment time, amount, and the payment
method's transaction reference.
# Timeouts & When a Result Is Final
Source: https://waffo.com/docs/en/support/payment-timeout-and-finality-faq
Why an order that timed out is not proof the payment failed: what Waffo does at close time, whose clock decides the timeout, which failures reach you as a notification and which only surface on inquiry, late notifications and retries, automatic returns after a late success, the separate 3DS authentication timeout, and how long your front end should wait.
[Confirming Payment Results](/docs/en/support/payment-results-faq) establishes the
rule this page depends on: **a redirect is never proof of payment, and the
authoritative result is the webhook with Order Inquiry as the fallback.** This
page is about the second half of that rule — *when* a result is allowed to be
treated as final, and why a timeout is not one of those moments.
> **The one sentence to take away:** a timed-out order means Waffo stopped
> waiting. It does not mean the payment method stopped, and it does not mean the
> user cannot still pay.
## My order timed out. Is the payment definitely not going to happen?
No. Timeout is Waffo's decision about your order, not the payment method's
decision about the payment.
For a number of payment methods the payment stays in a non-final state on the
method's side after your order has been closed — there is no automatic close on
their side at all. In that situation:
* The user can still open the payment page or app and complete the payment.
* The payment can succeed **after** the order shows as timed out.
* A result notification can arrive after the close.
So a timed-out order is a *provisional* failure. Do not destroy the purchase
record, do not release a reserved seat or licence irreversibly, and do not tell
the user "the payment failed and no money will be taken" — tell them the attempt
did not complete and that they should check before paying again.
## What does Waffo actually do when my order reaches its close time?
Two different things, depending on how far the payment got:
| How far the order got | What happens at close time |
| - | - |
| The user never submitted a payment — the order never reached a payment attempt | Waffo closes the order and reports the timeout to you |
| A payment attempt already exists for the order | The scheduled close **skips** it — the order is left to resolve on its real result rather than being forced to failed |
Where the payment method supports it, Waffo also asks the method to close the
payment at that point. Where it does not, the payment simply stays open on their
side until it expires by their own rules.
This is why `TIMEOUT_CLOSE` and `TIMEOUT_CHANNEL_CLOSE` mean different things —
see
[Confirming Payment Results](/docs/en/support/payment-results-faq) for the split. It
is also why the second row above matters more than merchants expect: an order
that stalled *after* authorization started is not going to be force-failed on a
timer.
## Whose clock decides whether a payment is late — mine or the payment method's?
The payment method's. The expiry you set with `orderExpiredAt` governs the
window in which the user can submit the order on the checkout page. Once the
payment has been handed to the payment method, **that method's own server time
decides** whether the payment arrived in time.
The practical effects:
* Network latency and retries on the method's side can push a payment past your
window while still being on time by theirs.
* A payment that reaches the method just before or just after your cut-off can
still be processed normally.
* Your close time and the payment method's close time are two different clocks,
and they are not synchronised.
Treat your own expiry as a UI and housekeeping deadline, not as a financial
cut-off.
## Will I be notified when a payment fails or when the user never pays?
Yes when the **order** closes — but not necessarily when an individual **attempt**
fails, and the gap between those two is the single most common cause of "we never
heard anything" tickets.
| Outcome | Notification |
| - | - |
| Payment succeeds | `PAYMENT_NOTIFICATION` with `orderStatus` = `PAY_SUCCESS` |
| The order closes — on timeout, on cancel, or because the payment method reported a final failure | `PAYMENT_NOTIFICATION` with `orderStatus` = `ORDER_CLOSE` and the reason in `orderFailedReason`. Waffo sends this itself; it does not depend on the payment method |
| An attempt fails but the order stays open | **Nothing yet.** On several payment methods a failed attempt does not close the order — the user may retry inside it — so no notification is produced until the order itself reaches a final state |
| The user simply never pays | Nothing until the order's expiry closes it; then the `ORDER_CLOSE` notification above |
So the notification you may be waiting for is not missing — it is waiting for the
order to end. Until then, the only way to establish the state is to ask:
`/api/v1/order/inquiry`. Build the polling before you go live rather than after
the first reconciliation gap, and see
[What Can I Do to an Order in Each Status?](/docs/en/support/order-status-actions-and-capture-faq)
for what the close notification carries.
A workable pattern: poll every 30 seconds while the order is open, up to a
bounded number of attempts, then poll again once after your own close time to
catch a late success. Stop polling as soon as you have a final state.
## A notification arrived after I had already closed the order. Is that a bug?
No, it is expected behaviour on several payment methods. A method's
notification mechanism does not stop because your order expired. If the payment
succeeded, the notification is sent — and if it is not acknowledged, it is
**retried**, sometimes several times.
Your handler therefore has to accept a success notification for an order you
consider dead. Concretely:
1. Never reject a notification because the order is closed on your side. Accept
it, acknowledge it, and reconcile afterwards.
2. Make the handler idempotent — retries mean the same event arrives more than
once. See [webhook retries](/docs/en/developer-docs/webhook/retry).
3. Acknowledge with `Content-Type: application/json` and body
`{"message":"success"}`, otherwise the retry schedule keeps firing.
4. Decide, once, what your business does with a late success: fulfil, or refund.
Both are legitimate; having no rule is not.
## The user paid after the timeout. Do they get their money back automatically?
Sometimes. On some card-scheme wallet payment methods, a payment that lands
after the method's own payment timeout is **returned automatically** — the
method initiates the return itself and the amount goes back to the cardholder
without either you or Waffo doing anything. The return is not instant — it lands
on the cardholder's account on the method's own timetable. This is the payment
method's own behaviour, not a Waffo service level.
On most other methods there is **no** automatic return. A late success is a real
success: the money has moved, the order is payable, and if you do not want to
fulfil it you have to refund it — see
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq).
Because both outcomes exist, do not tell a user "you will be refunded
automatically". Check the order first.
## How long should my front end wait before showing an error?
Set an explicit ceiling. A reasonable default for redirect-and-return flows is
**up to 5 minutes**, polling order status every few seconds while you wait.
After that:
* Stop the wait and show a neutral message: the payment could not be confirmed,
and the user should retry.
* **Create a new order for the retry.** Do not reuse the original order.
* Keep your server-side polling running on the old order — the user's browser
giving up says nothing about the payment.
What not to do: spin indefinitely on a waiting page, or show "payment failed"
the moment the wait expires. The former loses the user, the latter creates
duplicate payments.
## 3DS authentication timed out. Can I retry on the same order?
No. 3DS authentication carries **its own expiry, independent of your order
expiry** — the authentication link is valid only for a short, fixed period from
the moment it is generated, considerably shorter than a typical order expiry. If
the user does not finish authenticating in that window, the order lands in a
failed state for that reason and **cannot be reused**.
The correct recovery is to **create a new order**. Sending the user back to the
old authentication link, or re-submitting the original order, will not work:
| Situation | Action |
| - | - |
| User abandoned the 3DS page and comes back later | Create a new order |
| Authentication link expired | Create a new order |
| Authentication completed but no result yet | Wait / poll — this is not a timeout |
Note that a frictionless 3DS authentication also produces a redirect the user
passes through very quickly. Seeing that redirect is not, on its own, a result —
the same rule as everywhere else applies.
## A query said the payment failed, and a later query said it succeeded. Which one is right?
The later one. On several payment methods a failed attempt does **not** close the
order — the user is free to try again inside the same order, and a subsequent
attempt can succeed. A query run between the two attempts truthfully reports the
failed one.
An example of the sequence, with times:
| Time | What happened | What a query returns |
| - | - | - |
| 1:00 | The user's first attempt fails | — |
| 1:03 | You query | Failed |
| 1:05 | The user tries again and succeeds | — |
| 1:06 | You query | Succeeded |
The rule that follows: **a failure is only final once the order itself is final.**
While the order is still open, treat a failure result as "this attempt did not
work", not as "this order will never be paid". Concretely:
* Do not release inventory or cancel the purchase on the first failure.
* Do not stop polling on a failure while the order is open.
* Do show the user a retry path — on these methods retrying inside the existing
order is exactly what is supposed to happen.
The failures that *are* final are the ones that come with a closed order
status, or with a rejection at order creation.
## What actually counts as final?
| Signal | Final? |
| - | - |
| Webhook payment result reporting a final order status | **Yes** |
| `/api/v1/order/inquiry` returning a final order status | **Yes** |
| Redirect back to your site | No |
| Your own timeout elapsing | No |
| An `E`xxxxx "unknown status" response | No — keep querying |
| The user's screenshot or word | No |
Only the first two rows may drive fulfilment, accounting, or a refund decision.
Everything else is a hint.
## What does a timeout-safe integration look like?
A checklist you can hold your implementation against:
1. `orderExpiredAt` is set deliberately, in UTC+0, and you know it only governs
the checkout-page window.
2. Every order has server-side polling that survives the user closing the
browser.
3. Your webhook handler accepts, acknowledges and reconciles notifications for
orders you already closed.
4. The handler is idempotent on repeated delivery of the same event.
5. Retries create a **new** order; nothing reuses an expired one.
6. You have a written rule for a late success — fulfil or refund.
7. Nothing in your code treats elapsed time, a redirect, or an `E` code as a
final result.
## Where do I look next?
| Question | Page |
| - | - |
| What each `orderStatus` means, `webUrl` vs `successRedirectUrl`, missing webhooks | [Confirming Payment Results](/docs/en/support/payment-results-faq) |
| Bank transfer and virtual-account orders that stay open for days | [Bank Transfers & Virtual Accounts](/docs/en/support/bank-transfer-and-virtual-account-faq) |
| Refunding a late success | [Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq) |
| Error codes seen at order creation | [Error Codes & Limits Supplement](/docs/en/support/error-codes-and-limits-supplement) |
# Payout Error Codes & Retries
Source: https://waffo.com/docs/en/support/payout-error-codes-and-retry-faq
The payout error code set, which differs from the payment one: what each A/B/C/D/E code means for a payout, which four codes are safe to retry, which mean change the request, why A0003 carries a field name, and what the B-prefixed codes are actually referring to.
Payouts return their own error code set. The prefixes look like the payment ones and several codes
overlap, but **the meanings are not the same** — read this table rather than the
[payment error codes](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes) when you
are handling a payout response. For what to put in each request field, see the
[Payout Request Field Guide](/docs/en/support/payout-request-field-guide).
## What do the prefixes mean?
| Prefix | Class | How to treat it |
| - | - | - |
| `A` | Something about you or your request | Fix the request or your configuration; do not retry unchanged |
| `B` | Something about the **payee** | Fix the payee details, or ask the payee to fix their account |
| `C` | Waffo or the payout route | Some are retryable; see the table |
| `D` | Rejected on risk grounds | Do not retry; contact Waffo |
| `E` | Outcome unknown | **Never treat as failed.** Keep inquiring until a final status |
## The full payout code table
### A — merchant and request
| Code | Message | HTTP | What to do |
| - | - | - | - |
| `A0001` | Invalid Api secret key | 401 | Check the key you signed with |
| `A0002` | Invalid signature | 401 | Check your signing implementation |
| `A0003` | Parameter validation failed | 400 | Fix the named field — see below |
| `A0004` | Permission denied | 401 | Your credential or calling IP is not permitted for this operation |
| `A0005` | Merchant limit exceeded | 400 | An account-level limit was hit |
| `A0006` | Merchant status abnormal | 400 | See below |
| `A0007` | Payout currency is not supported | 400 | The currency is not in a payout agreement of yours |
| `A0008` | Payout limit exceeded — amount should be within the range | 400 | See below |
| `A0009` | Order does not exist | 400 | On inquiry: the id does not resolve to an order of yours |
| `A0010` | Operation not allowed in merchant contract | 400 | See below |
| `A0011` | Idempotent param mismatch error | 400 | Same `payoutRequestId`, different amount / currency / account |
| `A0012` | Merchant account insufficient balance | 400 | Not enough settled balance in the funding currency |
| `A0013` | Too many requests, please try again later | 400 | A duplicate create for the same `payoutRequestId` is in flight — **retryable** |
There is **no `A0014` on payouts.** A duplicate create whose amount, currency and payee account
all match the original returns the **ordinary success code** with the original order's data, not a
distinct idempotency code. Detect it by comparing the returned `payoutOrderId` with the one you
already hold, not by looking for a special code.
### B — the payee
| Code | Message | HTTP | What it is actually about |
| - | - | - | - |
| `B0001` | User status abnormal | 400 | The **payee's** receiving account is not in a usable state |
| `B0002` | User limit exceeded | 400 | A limit on the **payee's** side was hit |
| `B0003` | User does not exist | 400 | The **payee's** account could not be found |
| `B0004` | User info does not match | 400 | The payee name or account details do not match the receiving account |
**"User" here means the payee, not your platform's user and not you.** This is the most commonly
misread part of the payout code set. A `B0001` or `B0003` is a message about the receiving account
you addressed — a closed wallet, a wrong account number, an account that has never existed — and
the fix is with the payee's details or the payee themselves, never with your merchant
configuration.
`B0004` is where a name or account mismatch surfaces; the matching rules are in the
[Payout Request Field Guide](/docs/en/support/payout-request-field-guide).
### C — Waffo and the payout route
| Code | Message | HTTP | Retryable |
| - | - | - | - |
| `C0001` | System error | 500 | **Yes** |
| `C0002` | Invalid merchant contract | 500 | No — see below |
| `C0003` | Order info mismatch error | 500 | No |
| `C0004` | Payout channel rejection | 503 | No — the receiving side declined it |
| `C0005` | Payout channel error | 503 | **Yes** |
| `C0006` | Payout channel submit error, please try again later | 500 | Treat as unknown — inquire |
| `C0007` | Payout channel fx submit error, please try again later | 500 | Treat as unknown — inquire |
`C0002` looks like a system error because of the `C` prefix and the 500, but it is a configuration
problem: no payout agreement matched your request, or no fee is defined for the matched
arrangement. Nothing you change in the request body will fix it — check Service → Payout in the
Portal for an active agreement covering that country, currency pair and method, and raise it with
Waffo if one is there but the payout still fails.
### D and E
| Code | Message | HTTP | What to do |
| - | - | - | - |
| `D0001` | Risk rejection | 406 | Do not retry. Contact Waffo — the reason is not returned in the response |
| `E0001` | Unknown status | 500 | Keep calling `/api/v1/payout/inquiry` until you get `SUCCESS` or `FAILURE` |
## Which codes can I retry as-is, and which need the request changed?
Exactly four codes are classified as retryable on submission:
| Retryable | Code |
| - | - |
| ✅ | `A0013` — duplicate create in flight |
| ✅ | `C0001` — system error |
| ✅ | `C0005` — payout channel error |
| ✅ | `E0001` — unknown status |
Everything else means the payout will not succeed unchanged. Retry those four with the **same**
`payoutRequestId` — that is what idempotency is for, and it is the only way to avoid creating a
second payout. Retry with backoff, and inquire between attempts.
For the rest, the split is:
| Class | Codes | The fix |
| - | - | - |
| Change the request | `A0003`, `A0007`, `A0008`, `A0011`, `B0001`–`B0004` | Correct the field, amount, currency or payee, then send under a **new** `payoutRequestId` |
| Change your configuration or state | `A0005`, `A0006`, `A0010`, `A0012`, `C0002` | Balance, agreement or account status — the request is not the problem |
| Fix your integration | `A0001`, `A0002`, `A0004` | Credentials, signature, IP allowlist |
| Stop and ask | `C0004`, `D0001` | Declined; retrying achieves nothing |
| Do not decide yet | `C0006`, `C0007`, `E0001` | Outcome undetermined — inquire |
## `A0003` told me validation failed. Can I find out which field?
Yes, and `A0003` is the **only** payout code that carries detail. Its message is returned as the
generic text followed by the specifics in parentheses:
```
Parameter validation failed(payoutInfo.payoutMethodProperty.bankCode: invalid bankCode)
```
The detail is keyed by field path — `payeeInfo.payeeBirthDay`, `payoutInfo.payoutMethodName`,
`payoutAmount` and so on — and more than one field can appear. Every other code returns only its
standard message, so do not build parsing that expects detail from anything but `A0003`.
## I got `A0008`. Is it one payout that is too big, or have I used up my quota?
`A0008` on a payout is about the **single payout amount** falling outside the permitted range for
that corridor — either below the minimum or above the maximum. Both bounds come from your payout
agreement and are shown as **Minimum Amount** and **Maximum Amount**, in the funding currency, on
the Service → Payout page in the Portal. Check the amount against the row for the country,
currency pair and method you are using.
The other two are different codes, which is how you tell them apart:
| Code | Meaning |
| - | - |
| `A0008` | This payout's amount is outside the corridor's permitted range |
| `A0005` | An account-level limit was exceeded |
| `A0012` | Your settled balance in the funding currency does not cover the payout plus its fee |
| `B0002` | A limit on the payee's side, not yours |
Running out of balance is `A0012`, not `A0008` — see
[Payout Wallet & coverage](/docs/en/support/payout-wallet-and-coverage-faq) for which account is
debited.
## What does `A0010` "Operation not allowed in merchant contract" mean in practice?
That the payout you asked for is not covered by an active payout arrangement of yours. The usual
causes, in the order worth checking:
1. **No agreement for that combination.** Country, currency pair, method type and method all have
to match one row on Service → Payout.
2. **The agreement is not Active.** Only *Active* transacts; *Initial*, *Suspended* and
*Terminated* do not.
3. **The agreement has lapsed.** Its Validity Period has an end date; past it the agreement stops
transacting.
4. **The `payeeType` is not one the arrangement accepts.** An arrangement can be for individuals,
for companies, or for both.
5. **The `productName` is not one you have.** An unrecognised product is rejected on the field
with `A0003`; a product you have but are not contracted for on that corridor lands here.
Nothing in the request body fixes any of these. Compare your request against the Service → Payout
row first, then raise it with Waffo.
## Do payout agreements expire? Where do I see the expiry?
Yes — each row on Service → Payout carries a **Validity Period** with a start and an end, plus a
**Status**. A payout in a corridor whose agreement has passed its end date is rejected in the
`A0010` / `C0002` family rather than with a dedicated expiry code, so Validity Period is the field
to check when a corridor that used to work stops. Review it before it lapses; renewal is a
contracting conversation, not a Portal action.
## `A0006` "Merchant status abnormal" — have I been suspended?
`A0006` means your merchant account could not be resolved as usable for this payout — either the
account was not found for the id you sent, or its status is not one that permits transacting. It is
not specific to payouts and it is not informative about **why**. Check `merchantInfo.merchantId`
in the request first — a wrong or mistyped id produces the same code — then contact Waffo, because
the response deliberately does not carry the reason.
## The create call timed out and I do not know if it worked. Retry or inquire?
**Inquire.** A create you did not get a response to may have succeeded. Call
`/api/v1/payout/inquiry` with your `payoutRequestId`:
| Inquiry result | What it means |
| - | - |
| An order comes back | The create succeeded. Work from its status. |
| `A0009` (order does not exist) | The create did not land. Safe to send it again — same `payoutRequestId`. |
Re-sending the create is also safe, because `payoutRequestId` is idempotent: an identical resend
returns the original order rather than creating a second payout. What is **not** safe is sending
the same payout under a *new* `payoutRequestId` after a timeout — that is how duplicate payouts
happen.
## Is a payout rejected on risk grounds appealable?
`D0001` is returned without a reason and the response carries nothing to act on, so there is
nothing to correct in the request. Raise it with Waffo with the `payoutRequestId` and the payee
details; whether an individual decision can be revisited is a Waffo-side judgement, not a request
parameter.
***
A code you cannot place, or one whose behaviour does not match this page?
[Contact support](mailto:support@waffo.com) with the `payoutRequestId`, the `payoutOrderId` if you
have one, and the exact code and message.
# Payout FAQ
Source: https://waffo.com/docs/en/support/payout-faq
Answers to the most common questions about Waffo payouts: creating and tracking a payout, notifications, per-country payee field requirements, idempotency, and error codes.
This page answers the questions merchants ask most often about Waffo payouts.
Field-level contracts live in the
[Payout API Specification](/docs/api-reference/introduction).
## How do I create a payout and track it to completion?
Three integration points, all required:
1. **Create** — call `/api/v1/payout/create` with a unique `payoutRequestId`,
the payout amount and currency, the receiving `country`, and the payee
details. Some corridors additionally require a remittance `purpose` —
payouts to mainland China must carry one from a fixed list. The response
returns Waffo's `payoutOrderId` and an initial `status`.
2. **Inquire** — call `/api/v1/payout/inquiry` with either `payoutRequestId`
or `payoutOrderId` to fetch the full payout object at any time.
3. **Listen** — receive the **payout notification** webhook on the
`notifyUrl` you supplied, pushed when the payout completes or fails.
## What statuses can a payout have?
| Status | Meaning |
| - | - |
| `IN_PROGRESS` | The payout is being processed. |
| `SUCCESS` | The payout succeeded. |
| `FAILURE` | The payout failed — check `failureCode` and `failureReason`. |
A `subStatus` field appears only when a reversal occurs after success
(`status` stays `SUCCESS`): `FULL_REVERSED` means the whole amount was
reversed; `PARTIAL_REVERSED` means only part of it was. Most orders never
carry a `subStatus`.
## How does idempotency work? What if I retry a create request?
`payoutRequestId` is your idempotency key. Retrying `/api/v1/payout/create`
with the **same** `payoutRequestId`:
* and the **same** amount, currency and payee account returns the **ordinary
success code** carrying the *original* order's data — including its current
status, and its original failure code if it had already failed. No second
payout is created. Detect this by comparing the returned `payoutOrderId`
with the one you already hold; there is no distinct idempotency code.
* but a **different** amount, currency or payee account returns `A0011`
(idempotent param mismatch) and the request is rejected. Note that the
comparison covers only those three fields — see the
[Payout Request Field Guide](/docs/en/support/payout-request-field-guide) for
what that means if you edit a payee and resend.
If you receive an `Exxxxx` unknown error, the final outcome is not yet
determined — keep retrying the inquiry until you receive a final `SUCCESS`
or `FAILURE` status. Do not treat an unknown result as failed.
## Which payee name fields are required?
`payeeFirstName`, `payeeMiddleName`, and `payeeLastName` are **conditional**:
whether they are required varies by the receiving country and payout method.
The same applies to `payeeIDType`: today `CHN` is the country with enforced
requirements (an ID or passport must be supplied, and bank payouts carry
additional local rules). Other countries are validated by the channel rather
than by a country-specific rule, so follow the field requirements table for
the corridor you use. For Indonesia (`IDN`), the name fields are not
required for either `IDN_BANK` bank payouts or `DANA` eWallet payouts, but
bank payouts require `bankCode` inside `payoutMethodProperty`.
Check the Payout Creation Field Requirements table in the
[Payout API Specification](/docs/api-reference/introduction)
for the country and method you use; the methods available to you depend on
your contracted configuration.
## How do payout notifications work?
When a payout completes or fails, Waffo sends an HTTPS POST to your
`notifyUrl` with `eventType` set to `PAYOUT_NOTIFICATION`. The `result`
object has the same structure as the `/api/v1/payout/inquiry` response.
Respond with `message` set to `success` after processing. If Waffo receives
`failed` or `unknown`, Waffo retries the notification on a fixed back-off schedule; see [Webhook retry](/docs/en/developer-docs/webhook/retry) for the attempts and intervals.
Verify the `X-SIGNATURE` header with Waffo's public key before trusting the
payload (signature algorithm: `SHA256WithRSA`).
## What do the common error codes mean?
Error codes are grouped by prefix: `A` merchant-related, `B` user-related,
`C` system-related, `D` risk rejection, `E` unknown (retry until final).
| Code | Meaning | What to do |
| - | - | - |
| `A0007` | Payout currency is not supported | Use a currency covered by your contracted configuration. |
| `A0012` | Merchant account insufficient balance | Top up your account, then re-submit. |
| `A0008` | Payout limit exceeded | Keep the amount within the allowed range for your account. |
| `A0013` | Too many requests | Back off and retry later. |
| `A0011` | Idempotent param mismatch | Same `payoutRequestId` reused with a different amount, currency or payee account. |
| `E0001` | Unknown status | Retry inquiry until a final `SUCCESS` / `FAILURE`. |
The payout code set is **not** the same as the payment one — several codes
carry a different meaning and payouts have no `A0014`. The complete table,
with which four codes are safe to retry, is in
[Payout error codes & retries](/docs/en/support/payout-error-codes-and-retry-faq).
## What amount is deducted from my balance?
The inquiry response separates the amounts: `payoutAmount` (what you asked
to pay out), `prefundAmount` (what is deducted from your account, **fee
included**), and `payeeAmount` (what the payee receives — for cross-currency
payouts the exact received amount may not be known). If a payout fails, the
remaining amount is returned to you; the handling fee may still be charged
depending on the situation, as reflected by `feeStatus` (`DEDUCTED`,
`RETURNED`, or `NONE`).
# Payout Request Field Guide
Source: https://waffo.com/docs/en/support/payout-request-field-guide
Field-by-field answers for building a payout create request: how payoutRequestId idempotency actually compares requests, which payee fields are conditional by country, the two different date formats, phone and PIX key formats, bank properties per corridor, purpose values, amount precision, and which fields the API rejects.
This page is the field-level companion to the [Payout FAQ](/docs/en/support/payout-faq): what to put
in each field of a payout create request, and why a value that looks correct is rejected. For
which corridors you are allowed to pay out to at all, see
[Payout Wallet & coverage](/docs/en/support/payout-wallet-and-coverage-faq). For decoding a rejection,
see [Payout error codes & retries](/docs/en/support/payout-error-codes-and-retry-faq).
## Do I generate `payoutRequestId` myself? What are the limits?
Yes — it is yours to generate, and it is the idempotency key. Constraints:
| Property | Value |
| - | - |
| Who generates it | You |
| Maximum length | 64 characters |
| Required | Yes, on every create |
| Uniqueness scope | Per merchant account and per request channel (API payouts and Portal batch payouts are separate spaces) |
Waffo returns its own `payoutOrderId` alongside it. Either one can be used to inquire:
`/api/v1/payout/inquiry` accepts `payoutRequestId` **or** `payoutOrderId`, and if you send both,
`payoutOrderId` wins.
## What exactly happens if I reuse the same `payoutRequestId`?
Waffo looks up the existing order and compares **three** fields of your new request against it:
* `payoutAmount`
* `payoutCurrency`
* `payoutInfo.payoutMethodUserAccountNo`
Then:
| Case | Result |
| - | - |
| All three match | The **original** order is returned, with its current status. No second payout is created. |
| Any of the three differs | Rejected with `A0011` (idempotency parameter mismatch). Nothing is created. |
| The original order already ended in failure | The original order is returned **with its original failure code**, not a fresh attempt. |
| Two identical creates arrive concurrently | One is rejected with `A0013`; the other proceeds. |
Two consequences that catch people out:
* **Changing the payee's name, ID, address, country or method while keeping the amount, currency
and account number identical does not trigger `A0011`.** The comparison does not cover those
fields, so you get the original order back and your edited details are silently ignored. Never
edit a payee and resend under the same `payoutRequestId` — issue a new one.
* **You cannot re-send a failed payout under the same `payoutRequestId`.** It returns the old
failure. To retry a failed payout, create a new order with a new `payoutRequestId`.
## The create request returned `A0013` "Too many requests". Am I being rate limited?
On payout create, no. `A0013` there means a second create for the **same `payoutRequestId`** was
already in flight — the request is serialised per request id, and the loser is rejected. It is a
duplicate-submission guard, not a throughput limit. Retry the same call after a short pause, or
better, inquire first: the first attempt may well have succeeded.
## Which payee fields are actually required?
The request schema requires only `payeeId` and `payeeType` inside `payeeInfo`. Everything else is
conditional — it becomes required because of the receiving country, the payout method, or the
receiving side's own rules.
| Field | Constraint |
| - | - |
| `payeeId` | Required. Your own identifier for the payee. |
| `payeeType` | Required. `INDIVIDUAL` or `BUSINESS`; any other value is rejected. |
| `payeeFirstName` / `payeeMiddleName` / `payeeLastName` | Max 60 characters each. Conditional by corridor. |
| `payeeFullName` | Available where a corridor takes a single unsplit name. |
| `nationality` | 3-letter country code, max 3 characters. |
| `payeePhone` | Format `+{country code}-{number}`, e.g. `+62-08990013157`. Validated when present. |
| `payeeEmail` | Validated when present. |
| `payeeBirthDay`, `payeeIDIssueDate`, `payeeIDExpiryDate` | `dd/mm/yyyy`. Validated when present. |
| `payeeIDType`, `payeeIDNumber` | Conditional by country — see below. |
`addressInfo` (`address`, `city`, `region`, `postcode`, `addressCountryCode`) is a separate,
optional object. Nothing in the generic validation makes it mandatory; a corridor that needs it
rejects the payout at the pre-check step with a missing-required-field error naming it, so send it
when you have it.
## Why are there two different date formats?
Because the payee's documents and the request envelope are validated by different rules, and this
is a common source of `A0003`:
| Field | Format | Example |
| - | - | - |
| `payeeBirthDay` | `dd/mm/yyyy` | `05/09/1990` |
| `payeeIDIssueDate` | `dd/mm/yyyy` | `12/01/2021` |
| `payeeIDExpiryDate` | `dd/mm/yyyy` | `12/01/2031` |
| `payoutRequestedAt` | ISO 8601, `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` | `2026-09-07T02:15:30.000Z` |
| `payoutArrivalTime`, `payoutUpdatedAt` (response) | ISO 8601, same shape | |
## Does the phone number need a country code? What about the leading zero?
Send it as `+{country code}-{number}`. The leading `0` of a national number is dropped by some
corridors and not by others, so follow the corridor's own guidance rather than a general rule. For
eWallet payouts the phone number may double as the account identifier, and there the format is set
by the receiving wallet, not by this field — see the account-number question below.
## Which `payeeIDType` values are accepted, and why was `PASSPORT` rejected?
The defined values are `ID`, `PASSPORT`, `CPF` and `CNPJ`, but the accepted **set is narrowed per
country**, and a value outside that country's set is rejected even though it is a valid enum
member:
| Receiving country | `payeeIDType` accepted | Required? |
| - | - | - |
| Mainland China (`CHN`) | `ID`, `PASSPORT` | Yes — the request is rejected without it |
| Brazil (`BRA`) | `CPF`, `CNPJ` | Yes, together with `payeeIDNumber` |
| Other countries | No country-level restriction; the receiving side validates | Per corridor |
So `PASSPORT` on a Brazil payout is rejected because Brazil's accepted set is `CPF` / `CNPJ` only.
The rejection message names the field and lists the allowed values.
## Brazil: when do I use CPF and when CNPJ?
They follow `payeeType`, and the two must agree:
| `payeeType` | Document type | `payeeIDNumber` format |
| - | - | - |
| `INDIVIDUAL` | `CPF` | 11 digits |
| `BUSINESS` | `CNPJ` | 14 digits |
If you omit `payeeIDType`, it is inferred from `payeeType` on that basis. If you send it and it
contradicts `payeeType` — `CNPJ` with `INDIVIDUAL`, say — the payout is rejected. `payeeIDNumber`
is always required for this corridor and is checked against the digit-count pattern above.
## What is the difference between `INDIVIDUAL` and `BUSINESS`?
`payeeType` selects which payout arrangements can serve the request: a contracted method may
accept individuals, companies, or both, and a payout whose `payeeType` no corridor accepts is
rejected as not contracted. Beyond routing, the practical difference is the document rules above —
for Brazil, `BUSINESS` means a `CNPJ`. There is no separate "corporate payout" object to fill in.
## Do I need to register a payee before paying them?
No. There is no payee vault and no payee-registration call: every payout create carries the payee
details in full, and Waffo does not reuse details from a previous order. Keep your own payee
records and populate the request from them each time.
## Which `payoutMethodType` do I choose, and when is `payoutMethodName` required?
| `payoutMethodType` | `payoutMethodName` | `payoutMethodProperty` |
| - | - | - |
| `EWALLET` | **Required** — the wallet's name; a blank value is rejected | Not required |
| `BANK_ACCOUNT` | Leave empty | **Required** — a JSON object; a blank value is rejected |
Those are the only two values. `productName` is validated against the products your account has,
so a payout product you are not set up for is rejected on that field; `ONE_TIME_PAYOUT` is the
value for a single, non-recurring payout.
## What goes in `payoutMethodProperty` for a bank payout?
It is a JSON string. The keys that matter, and where they are enforced:
| Key | When it is required |
| - | - |
| `bankCode` | Required for every `BANK_ACCOUNT` payout **except** mainland China |
| `bankName` | Required for mainland China, and must contain Chinese characters |
| `bankSub` | Required for mainland China when the card number does **not** begin with `62`, and must contain Chinese characters. Used as the branch identifier in other corridors. |
| `accountType` | Required by some corridors — for Brazil one of `CRN`, `PAC`, `SVN` |
| `accountName` | Optional; supply it where the account is held under a name you cannot express through the `payeeInfo` name fields |
Malformed JSON in this field is itself an `A0003`. Where a corridor keeps per-bank branch and
account-number patterns, the pre-check step validates against them and rejects a mismatch with a
missing-or-invalid-field error naming `bankSub` or the account number.
## Where do I get bank codes from?
There is no public bank-code lookup endpoint, and no downloadable bank list in the documentation.
Bank codes are corridor-specific: ask Waffo for the list covering the corridor in your payout
agreement before you build against it. Mainland China is the exception that needs no `bankCode` at
all — send `bankName` (in Chinese) and, for cards not starting with `62`, `bankSub`.
## For a China bank payout, does the account name need to be Chinese?
`bankName` must contain Chinese characters, and so must `bankSub` where `bankSub` applies. The
account holder's name itself goes in the `payeeInfo` name fields; use `accountName` inside
`payoutMethodProperty` only where the account is held under a name those fields cannot carry.
## What do I put in `payoutMethodUserAccountNo`?
It depends on the method:
| Method | Value |
| - | - |
| `BANK_ACCOUNT` | The payee's bank account number |
| `EWALLET` | Either the wallet's own public user identifier for the payee, or — where the wallet accepts it — the payee's mobile number in the format that wallet specifies |
For a wallet that accepts a phone number, the digits are the wallet's own format, not the
`payeePhone` format: national leading zeros are dropped and the country code is included, so an
Indonesian number is sent starting `628`, not `0` or `+62-0`.
## Brazil PIX: which key types work? Is a random (EVP) key allowed?
All five standard key types are accepted, and the type is inferred from the value itself — there
is no key-type field to set:
| Key type | Accepted form |
| - | - |
| CPF | 11 digits |
| CNPJ | 14 digits |
| Phone | E.164, e.g. `+5511987654321` |
| Email | A valid email address |
| EVP (random key) | A UUID, as issued by the payee's bank |
So yes, EVP works. A value matching none of the five is rejected on
`payoutMethodUserAccountNo`.
## What happens if the account number and the payee name do not match?
Where the receiving side returns the account holder's name during the pre-check, Waffo compares it
with what you sent and fails the payout on a mismatch with `B0004` ("User info does not match").
The comparison is narrower than people expect:
* Only **first name** and **last name** are compared. `payeeMiddleName` is not.
* The comparison is **case-insensitive**.
* If either side's value is blank, that part of the check passes.
So the check is real, but it cannot be relied on as full name verification: a corridor that returns
no name performs no check at all, and a payout can succeed to an account whose holder differs from
the name you supplied. Verify payee details on your side before sending.
## Is `purpose` required? What are the valid values?
It is required for **mainland China** payouts and validated against a fixed list. For other
countries it is not required by the country validation, though an individual corridor may reject a
payout without it.
| `purpose` |
| - |
| `SALARY_PAYMENT` |
| `SERVICE_CHARGES` |
| `ADVISORY_FEES` |
| `ADVERTISING_EXPENSES` |
| `CONSTRUCTION_EXPENSES` |
| `PROPERTY_RENTAL` |
| `TAX_PAYMENT` |
| `LIBERALIZED_REMITTANCE` |
A value outside the list is rejected with the offending value echoed back. Choose the one that
describes the payment truthfully; it is a regulatory declaration, not a label.
## Is `description` shown to the payee? Will it be truncated?
Yes — it appears on the payee's bank or wallet statement, which is why it is a required field. Max
128 characters, but write it much shorter than that: receiving banks and wallets truncate, so aim
for roughly 16 English words or 10 Chinese characters and put the essential identifier first.
Waffo does not truncate it for you; the receiving side does, unpredictably.
## What can I put in `extendInfo`, and do I get it back?
A JSON object serialised as a string, e.g. `{"key1":"value1","key2":"value2"}`. It is returned
unchanged on `/api/v1/payout/inquiry` and in the payout webhook. A value that does not parse as
JSON is rejected with `A0003`. It is the right place for your own correlation ids; it is not
carried to the payee.
## How many decimal places can the amount have?
`payoutAmount` is a string, and the accepted precision is **the receiving currency's own**, not a
fixed number of places. A value with more decimals than the currency permits is **rejected** with
`A0003` naming `payoutAmount` — it is never silently rounded. The field's own pattern allows up to
8 decimal places, so the currency check, not the pattern, is what your code has to satisfy.
Amounts are also bounded by the minimum and maximum in your payout agreement for that corridor,
which is where an in-range-looking amount gets an `A0008` instead. Both bounds are on the
Service → Payout page in the Portal.
## Can I set the currency the payee receives?
Not on the request. On the single-payout API, `payeeCurrency` and `payeeAmount` are **rejected if
you send them** — they are outputs, not inputs. You set `payoutCurrency` and `payoutAmount`; the
payee currency comes from the currency pair in your payout agreement for that corridor.
For a cross-currency payout the exact received amount may not be known when the order is created,
so `payeeAmount` can come back empty on the first inquiry and be populated later. Do not promise
your payee an exact figure at request time. What is deducted from you is `prefundAmount` — the
converted amount plus that order's fee — described in
[Payout Wallet & coverage](/docs/en/support/payout-wallet-and-coverage-faq).
## Is `notifyUrl` optional? Does it override anything configured in the Portal?
For payouts created through the API, `notifyUrl` is **required** — a create without it is rejected.
It must start with `http://` or `https://`. There is no account-level payout notification URL to
fall back on and nothing to override: the payout webhook goes to the URL on that order and nowhere
else, so a payout created without one is a payout you will never be notified about.
## What is `merchantInfo` for?
`merchantInfo.merchantId` is required and is your Waffo merchant id; `merchantInfo.subMerchantId`
is optional and only relevant if your account is set up with sub-merchants. Both are echoed back on
inquiry.
***
Rejected on a field this page does not explain? The `A0003` message names the field — see
[Payout error codes & retries](/docs/en/support/payout-error-codes-and-retry-faq) for how to read it, or
[contact support](mailto:support@waffo.com) with the `payoutRequestId`.
# Payout Statuses, Notifications & Reports
Source: https://waffo.com/docs/en/support/payout-status-notifications-and-reports-faq
What each payout status and sub-status means, why DEDUCTED never reaches you, when the payout webhook fires and the two cases where it does not fire at all, how to detect a reversal, and what the daily, weekly and monthly payout reports contain and when they appear.
This page covers reading a payout after you have created it: its status, how you find out it
finished, and where the periodic reports are. Creating one is in the
[Payout FAQ](/docs/en/support/payout-faq); the request fields are in the
[Payout Request Field Guide](/docs/en/support/payout-request-field-guide); batch payouts have their own
statuses in [Batch payout statuses & funding](/docs/en/support/batch-payout-faq).
## Is payout creation synchronous or asynchronous?
Asynchronous, in every practical case. A successful create returns an order with
`status: IN_PROGRESS` and Waffo's `payoutOrderId`; the outcome arrives later. There are exactly
three ways to learn the outcome, and you should build on at least two of them:
| Route | Use it for |
| - | - |
| **`/api/v1/payout/inquiry`** | The authoritative current state, at any time, by `payoutRequestId` or `payoutOrderId` |
| **The payout webhook** | Being told promptly, without polling — see the limits below |
| **The Portal** | Investigating one order by hand |
A create can also come back already final: an identical resend of a `payoutRequestId` whose
original order has finished returns that final status straight away, and a payout that fails at the
pre-check step fails synchronously. Treat `SUCCESS` and `FAILURE` on a create response as real.
## What statuses can a payout have, and which are final?
| `status` | Meaning | Final? |
| - | - | - |
| `IN_PROGRESS` | Being processed | No |
| `SUCCESS` | The payout succeeded | Yes |
| `FAILURE` | The payout failed — read `failureCode` and `failureReason` | Yes |
Three values, and only two of them are final. There is no separate "submitted", "deducted" or
"accounting" value in the API.
## The Portal shows a different status from the API. Which is right?
They agree — the Portal payout list and detail render the **same three values** the API returns,
and the filter on the list offers the same three. Waffo tracks finer internal stages while a
payout is moving, and every one of them is reported to you as `IN_PROGRESS`; neither surface
exposes them. If the two genuinely differ, one of them is stale — re-inquire, and treat the
inquiry response as authoritative.
## I saw `DEDUCTED` somewhere. Has the money left?
`DEDUCTED` is an internal stage name, not a payout status you can receive: it is one of the
positions inside `IN_PROGRESS`. It means the funding has been taken from your settlement account
and moved into the Payout Wallet — **not** that the payee has been paid. Funds sit in the wallet
as *In Transit* from that point until the payout reaches a final status, which is why a payout can
show as deducted on your balance and still fail afterwards, returning the amount. That mechanism
is set out in [Payout Wallet & coverage](/docs/en/support/payout-wallet-and-coverage-faq).
## What is `subStatus`? What about `CONSULT_SUCCESS` and `CONSULT_FAILURE`?
`subStatus` is present **only** when a reversal has happened, and only ever carries one of two
values:
| `subStatus` | Meaning |
| - | - |
| `FULL_REVERSED` | The whole payout amount was reversed |
| `PARTIAL_REVERSED` | Part of the payout amount was reversed |
`CONSULT_SUCCESS` and `CONSULT_FAILURE` are internal markers for the pre-check step and are
**filtered out before the response is built** — the API returns a `subStatus` only for the two
reversal values, so you will never receive either of them. If you have seen them, they came from
somewhere other than the payout API, and they carry no meaning you need to act on.
## A payout succeeded. Can it still be reversed?
Yes. The receiving side can return a payout after it has already reached `SUCCESS`. When that
happens:
* `status` **stays** `SUCCESS`. It does not move to a failed or reversed status.
* `subStatus` becomes `FULL_REVERSED` or `PARTIAL_REVERSED`.
* The reversed amount is credited back to the account that funded the payout — your settlement
account, in the funding currency. A partial reversal returns only the reversed part, so
reconcile against the reversal, not the original payout amount.
**A reversal does not send a webhook.** No notification of any kind is pushed when a payout is
reversed. The only ways to find out are re-inquiring the order or looking at the Payout Wallet
account history, where the reversal posts its own pair of rows. If reversals matter to your
ledger, re-inquire successful payouts on a schedule rather than treating `SUCCESS` as the end of
the order.
## How do I read the fee fields?
| Field | Meaning |
| - | - |
| `feeCurrency` | The currency the fee is charged in |
| `actFeeAmount` | The fee actually taken, once the order is final |
| `feeStatus` | `DEDUCTED`, `RETURNED`, or `NONE` |
`actFeeAmount` and `feeStatus` move together:
| `feeStatus` | `actFeeAmount` | When |
| - | - | - |
| `DEDUCTED` | The fee amount | Charged and kept |
| `RETURNED` | `0` | Charged and given back |
| `NONE` | `0` | There was no fee |
A successful payout ends as `DEDUCTED` or `NONE`. A failed payout can be any of the three: the fee
comes back in full if the order never reached the receiving side, and otherwise comes back only
where your agreement for that method is marked **Fee Refundable = Yes** on the Service → Payout
page. So `feeStatus` is the field to reconcile against, not an assumption that a failure is free.
## Is the payout fee taken out of the amount, or charged on top?
On top. `payoutAmount` is what you asked to pay the payee; what leaves your settlement account is
`prefundAmount`, which is the converted payout amount **plus** that order's fee. The payee is not
funding the fee out of their payment.
## What exchange rate was used, and when was it fixed?
The payout response does not carry a rate. What it carries is the amounts on each side —
`payoutAmount` in the currency you requested, `prefundAmount` in the currency you were debited,
and `payeeAmount` in the currency the payee received — and neither the API nor the Portal payout
detail exposes a rate field. Reconcile from the amounts.
For a cross-currency payout, `payeeAmount` can legitimately be empty on an early inquiry and be
filled in later, because the exact received amount is not always known when the order is created.
Do not quote the payee a figure from a create response.
## Does the payout webhook exist? What is the event called?
Yes: `PAYOUT_NOTIFICATION`. It is delivered to the `notifyUrl` on that order — the URL is
per-request and mandatory for API payouts, with no account-level fallback. The body follows the
same envelope as every other Waffo webhook, with `result` structured exactly like the
`/api/v1/payout/inquiry` response, and is signed in the `X-SIGNATURE` header. Verification,
response format and retry behaviour are identical to the other events — see
[Signature verification](/docs/en/developer-docs/webhook/signature-verification) and
[Webhook retry](/docs/en/developer-docs/webhook/retry). Withdrawals have their own separate event,
`WITHDRAW_NOTIFICATION`; a payout webhook is never a withdrawal webhook or vice versa.
## When does it fire? Are there intermediate notifications?
Once, at the end, and never in between:
* It is pushed when the payout reaches `SUCCESS` or `FAILURE` **and** its fee accounting has
completed — which is why `feeStatus` and `actFeeAmount` are already populated in the payload.
* There is **no** notification for `IN_PROGRESS`, and none for any internal stage.
* There is **no** notification for a reversal.
## Why am I not getting webhooks for my batch payouts?
Because the payout webhook is pushed **only for payouts created through the API**. A payout that
originated as a row in a Portal batch upload does not produce one, however it ends. Track batch
results from the batch record and its result file — see
[Batch payout statuses & funding](/docs/en/support/batch-payout-faq) — or from
`/api/v1/payout/inquiry` on the `payoutOrderId` of each row.
## What if I miss a webhook or return a non-200?
Waffo retries on a `failed` or `unknown` body, or on a delivery failure, on a fixed back-off
schedule; the attempts and intervals are on [Webhook retry](/docs/en/developer-docs/webhook/retry).
Respond `{"message":"success"}` once you have stored the event.
Because the payout webhook fires once and only at the end, a webhook you never process is an
outcome you never hear about. Do not run payouts on webhooks alone — reconcile with
`/api/v1/payout/inquiry` for any order you have no final status for.
## A payout has been `IN_PROGRESS` for a long time. When should I worry?
There is no published time after which an in-progress payout is abnormal, and the answer varies by
corridor, so treat duration alone as uninformative. What to do instead:
| Check | Why |
| - | - |
| Re-inquire the order | The status may already have moved and only the webhook is missing |
| `payoutUpdatedAt` | Tells you when the order last changed, which is more informative than how old it is |
| `payoutArrivalTime` | Populated only when the payout completes — empty means it has not |
| The status timeline on the Portal detail | Shows which stages have been reached |
Do **not** create a second payout to force it. A payout still in flight can complete, and the
second order is a second payment. Raise a stalled payout with Waffo using the `payoutOrderId`
rather than re-sending it.
## A payout failed. Do I resend the original or create a new one?
Create a new one, with a **new `payoutRequestId`**. Resending the same `payoutRequestId` returns
the original order together with its original failure code — it does not re-attempt the payout.
Read `failureCode` first and fix whatever it names; see
[Payout error codes & retries](/docs/en/support/payout-error-codes-and-retry-faq) for which codes are
worth re-sending at all.
## What do `payOption` and `channelId` mean on a payout record?
`payOption` is the specific receiving option resolved for the payout — the label behind a bank code,
for example. It appears on the Portal payout detail as **Payment Option** and is useful when you
need to confirm which of several routes a payout took to a bank.
`channelId` is not a merchant-facing field: it is not on the Portal payout detail and it is not in
the payout inquiry response. If you have it from somewhere, it is not something you need to
reconcile against.
## What periodic payout reports do I get?
Three, all under **Payout → Payout Report** in the Portal:
| Report | Period covered |
| - | - |
| **Daily** | One calendar day |
| **Weekly** | Monday to Sunday |
| **Monthly** | The 1st to the last day of the month |
The list shows, per report, the **Period**, the **Order Count** it covers, the **Generated Time**
(UTC+8) and the **File Name**, which is the download link. Filter by a date range and pick the
report type. Files are CSV.
## Can I get today's report today?
For the **daily** report, yes — it is the one report that may be generated on the day it covers,
as soon as that day's payout settlements have all finished settling. Weekly and monthly reports are only produced after
their period has **ended** — a weekly report does not exist mid-week. In every case a report needs
**both** conditions:
1. Its period has ended (daily: the day itself counts).
2. Every payout settlement in that period has **finished settling**. A period that has ended but
still has unsettled payout activity is skipped and retried later.
That is why a period can end and its report still not be there yet, and why the list only ever
shows reports that were generated successfully — there is no pending or failed row to interpret. A
period with no payout activity at all produces no report rather than an empty one.
## Can payout reports be pushed to SFTP or emailed on a schedule?
**SFTP — yes, by arrangement.** Waffo can deliver a scheduled payout bill file for your account to
an SFTP location on a daily cycle. It is set up by Waffo per merchant account, not switched on in
the Portal: ask support, stating the account and the SFTP details you need. **Email — no.** There is
no payout-report email subscription. Independently of either, the payout report stays available as a
Portal download: open Payout → Payout Report and click the file name, which hands you a time-limited
download link. Settlement report delivery is a separate feature with its own SFTP and email options —
see [Report delivery & email subscription](/docs/en/support/report-delivery-and-subscription-faq) — so do
not assume a settlement subscription covers payouts.
## The report download link stopped working. How do I get a new one?
Click the file name again. The link is generated fresh each time and is deliberately short-lived,
so it is meant to be used immediately rather than stored, forwarded or embedded in a job. A link
that has expired is not an error condition — nothing has been lost.
Downloading requires the report-download permission on your Portal user. Without it you can see
the report row and its file name but the name is plain text rather than a link; ask your account
administrator.
## Can I export the payout lists? Is it synchronous?
Yes, and **asynchronous**. Both the single-payout list and the batch list export as a task: you
start it, and the file appears in the Portal's export area rather than downloading in the browser.
Statuses, retries and link expiry work the same as every other export — see
[Exports & report downloads](/docs/en/support/export-center-faq).
Two limits to plan around:
| Limit | Value |
| - | - |
| Export date range | One month maximum — a wider selection is rejected before a task is created |
| Query date range | Payout list queries are also restricted to a one-month window |
A very large export is split across multiple files rather than truncated, so check for more than
one file before concluding data is missing.
## Should I reconcile from the reports or from the API?
Use whichever matches the grain of what you are reconciling, and expect them to describe different
things:
| Source | Grain | Best for |
| - | - | - |
| `/api/v1/payout/inquiry` | One order, current state | Per-order truth, including reversals |
| Payout list export | Orders in a date range | Your own order-level ledger |
| Payout Report | A settled period | Tying payouts to what actually cleared |
When two sides do not agree, the fields that usually explain it are `prefundAmount` versus
`payoutAmount` (the first includes the fee and is in the funding currency), `actFeeAmount` with
`feeStatus` (a failed payout may or may not have returned its fee), and `subStatus` (a reversal
changes the money without changing `status`). Check those three before raising a discrepancy.
## Can Waffo give the payee an invoice or a payment receipt?
There is no payee-facing receipt or invoice produced for a payout. What the payee sees is the
`description` you sent, on their bank or wallet statement. On your side the evidence of a payout is
the order record, the payout list export and the Payout Report. If you need to give a payee a
document, generate it from the order data — `payoutOrderId`, `payeeAmount`, `payeeCurrency`,
`payoutArrivalTime` and `description` are the fields that identify the payment.
***
Statuses or reports not matching what you expect?
[Contact support](mailto:support@waffo.com) with the `payoutOrderId`, or the report period and
type.
# Payout Wallet & Coverage
Source: https://waffo.com/docs/en/support/payout-wallet-and-coverage-faq
The fourth Waffo account: what the Payout Wallet holds, how funds enter and leave it, how failed and reversed payouts return to your settlement account, how to read its separate Account History, and where to check which payout corridors you are contracted for.
Three of the four Waffo balances relate to collecting money. Paying money out uses a fourth account
with its own ledger. This page covers that account and where to look up what you are contracted to
pay out. For creating and tracking a payout, see the Payout FAQ.
## What is the Payout Wallet, and how is it different from my settlement account?
It holds money that has left your settlement account but has not yet reached the payee. The Portal
labels that balance **In Transit**.
| Account | Portal menu | What it holds | Withdrawable? |
| - | - | - | - |
| Pending settlement | Settlement Account | Successful transactions not yet settled | No |
| Settlement account | Settlement Account | Settled funds, per settlement currency | Yes |
| Reserve | Reserve Account | Amounts withheld under your contract | No |
| **Payout Wallet** | **Payout Wallet** | Funds committed to payouts still in flight | **No** |
The wallet carries no withdraw action and is not a withdrawal source — withdrawals come from the
settlement account only. It exists so in-flight payout money stays visible instead of just vanishing
from your settlement balance.
## Where does the money in the wallet come from?
From your **settlement account** under the same signing entity, moved at the moment a payout is
**submitted** — not when it completes. Two consequences:
* **The currency is the agreement's payer currency**, not what the payee receives. A cross-currency
payout is converted into the payer currency first, so the wallet and its history are denominated in
the funding side of the **Currency Pair** shown on the Service → Payout page.
* **The amount includes the payout fee** — funding amount = converted payout amount + that order's
fee. This is why a wallet row rarely equals the payout amount exactly.
If the settlement balance in the funding currency does not cover it, a single payout is rejected at
creation with a balance-not-sufficient business error rather than queued.
## What does the Payout Wallet page show?
Two tabs: **Payout Wallet**, one card per wallet currency with its **In Transit** balance, and
**Account History**, its ledger. A wallet account only exists once it is needed, so an empty page
means no payout funding has run yet. The page is available to accounts it is enabled for — if you can
see it in the menu, it is yours to use.
Account History here covers the **Payout Wallet only**, separately from the Account History under
Settlement Account: a payout produces rows in both, and neither shows the other's.
Each row carries **Account No**, **Business ID** (the `payoutOrderId` for a single payout, or the
batch id for a batch payout), **Business Type** — always *Payout* on this page — **Direction**
(*Credit* into the wallet, *Debit* out of it), **Amount** signed by direction at 2 decimals,
**Datetime** in UTC+8, and **Description**, which says which leg of the payout the row is.
The date filter defaults to the **last month**, with a free-text **Business ID** search. Export is
capped at a **31-day** range, enforced in the browser and again on the server. The file is
`payout_account_history_{merchant id}_{start}_{end}.csv`, timestamps `YYYYMMDDHHMMSS` in UTC+8, and
it adds two columns the on-screen table omits: **Account Type** and **Balance** (the balance right
after the movement).
## What do the Description values mean?
Rows come in pairs, and the pair tells you which stage the money is at.
| Description | Meaning |
| - | - |
| **Payout Funding In** | Funded from your settlement account into the wallet, at submission |
| **Payout Funding Out** | Released from the wallet towards the payout, at submission |
| **Payout Failed In** | The payout reached a failed final status; funds return to the wallet |
| **Payout Failed Out** | Those funds leave the wallet, back to your settlement account |
| **Payout Reverse In** / **Out** | The equivalent pair for a payout reversed after success |
Both funding rows post together at submission, so a wallet whose payouts have all reached a final
status can legitimately show a zero In Transit balance behind a long history.
## A payout failed. Do I get the fee back too?
The payout amount always comes back. The fee depends on where it failed: if the order never reached
the payout channel, the fee is returned in full; if it reached the channel and then failed, the fee
comes back only when the agreement is marked **Fee Refundable = Yes** — a column on the Service →
Payout page you can check per method before sending. Where it is not refundable, the amount returned
is the funding amount **minus the fee**, and the order's fee status reads as deducted.
## What happens when a successful payout is reversed?
A payout can be returned by the receiving side after it already reached `SUCCESS`. Waffo leaves
`status` as `SUCCESS` and adds a `subStatus` of `FULL_REVERSED` or `PARTIAL_REVERSED`; the reversed
amount is credited back to the account that funded the payout — your settlement account, in the
funding currency. A partial reversal returns only the reversed part, so reconcile against the
reversal amount rather than the original payout amount.
## Which countries, methods and payee types can I pay out to?
There is no global list to work from: coverage is per agreement, and yours is on **Service → Payout**
in the Portal. Each row is one contracted combination, carrying **Country/Region**, **Currency Pair**
(payer → payee currency), **Payment Method Type** and **Payment Method**, **Payment Option**,
**Fees**, **Minimum Amount** and **Maximum Amount** (in the payer currency), **Fee Refundable**,
**Validity Period** (agreement start and end) and **Status**. Filters cover country, method type,
method and status. **Status** is one of **Initial**, **Active**, **Suspended** or **Terminated**, and
only *Active* transacts — a payout rejected as not contracted is usually a non-Active agreement, and
Validity Period tells you whether it has simply lapsed.
Payee type is set per request as `payeeType` = `INDIVIDUAL` or `BUSINESS`. Whether a given method
accepts individuals, companies or both is part of the agreement rather than a column on the page, so
check with support if a method rejects one of the two.
## How do I read Service → Payin, and why are some columns missing?
Same idea per contracted payment method: **Payment Method Country**, **Product**, **Payment Method
Type**, **Payment Method**, **Payment Option**, **Fees**, **User Fee** (fees charged to your end
users), **Settlement cycle**, **Minimum**/**Maximum Amount**, **Order Currency** (the contracted
acquiring currency), **User Currency** (what the payer is charged in), **Refundable**, **Refund
Period**, **Additional Fields For Refunds**, **Status** — plus a direct **Export**.
Some columns are conditional, which is why two accounts see different tables: **User Fee** appears
only if an agreement charges one, and then the Refundable cell also states whether that user fee is
refundable; **Mini Apps Info** only if an agreement has mini-program apps attached; **Order Expiry
Customization** only when it applies to your account; **Fees** and **Settlement cycle** are hidden
for gateway-model accounts.
**Refundable** decodes the same value the API returns as `refundAllowed`: *No*, *Only Full Refund*,
*Full Refund or One-time Partial Refund*, or *Full Refund or Multiple Partial Refunds*. Check it
before building multi-refund logic. Payin statuses add **Onboarding** to the four above; a method
missing from the cashier is usually a non-Active agreement.
***
Wallet history and settlement ledger not tying out?
[Contact support](mailto:support@waffo.com) with the `payoutOrderId` or batch id from the Business ID
column.
# Point Topup Reseller APIs
Source: https://waffo.com/docs/en/support/point-topup-reseller-api-faq
The six signed endpoints a Point Topup reseller can call: catalogue lookup, purchase-balance inquiry, order creation with the resellerRequestId idempotency key, single and paged order inquiry, fulfillment inquiry, plus the four order statuses and the purchase notification.
> [!NOTE]
>
> This page is for **resellers** — parties that buy point-card inventory from Waffo Point Topup through an API against a prepaid balance. It is not part of the merchant payment APIs, and it is not the supplier-side Mode A/B/C integration in [Point Topup integration overview](/docs/en/developer-docs/point-topup/overview).
Message format, amount and time conventions, the `code` / `msg` / `data` envelope and the RSA key exchange are shared with the rest of Point Topup — see [Point Topup API common specifications](/docs/en/developer-docs/point-topup/api-common).
## Which endpoints can I call?
All six are `POST` with a JSON body. The sandbox and production base URLs are issued during onboarding.
| Endpoint | Purpose |
| - | - |
| `/api/v1/gamepin/products` | Your catalogue: items, SKUs, prices, pricing strategy |
| `/api/v1/gamepin/balance` | Available purchase balance per currency |
| `/api/v1/gamepin/create` | Place a purchase order |
| `/api/v1/gamepin/inquiry` | Read one order, optionally with the card serials |
| `/api/v1/gamepin/orders-inquiry` | Paged order list over a time window |
| `/api/v1/gamepin/fulfillment-inquiry` | Fulfillment result of a direct-fulfillment order |
## How is reseller authentication different from `order/create`?
Same transport — `X-API-KEY` plus an `X-SIGNATURE` over the raw body, verified with the public key you exchanged at onboarding, and a signed response to verify in return. Three things differ:
| | Merchant payment APIs | Point Topup reseller APIs |
| - | - | - |
| Identity behind the key | your merchant id (MID) | your `resellerId` |
| Identity in the body | not repeated | **every** body carries `resellerId`, and it must equal the id behind the key |
| Scope of the key | one merchant API set | keys are scoped: a purchase key reaches all six endpoints, a Mode C direct-fulfillment key reaches `fulfillment-inquiry` only |
A `resellerId` that does not match the key is a parameter error; an endpoint outside the key's scope returns `A0004`.
## How do I pull the catalogue, and what is `priceStrategy`?
`products` returns the items active on your reseller contract. Pass `itemId` to narrow to one item, omit it for everything; an unknown or inactive `itemId` gives an empty list, not an error.
| Field | Meaning |
| - | - |
| `category` | `GAME_PIN` — a card serial is delivered to you; `TOP_UP` — the game account is topped up directly |
| `skuList[].price` / `currency` | Unit price, and the currency the order is charged in |
| `skuList[].priceStrategy` | `FIXED_PRICE` — buy the SKU as listed; `SPECIFIED_FACE_VALUE` — you choose the face value and the price derives from it |
| `skuList[].purchaseMaxLimit` | Highest face value you may specify on a `SPECIFIED_FACE_VALUE` SKU |
`balance` then returns one entry per currency with the currently **available** balance. Match it to the SKU's currency: an order is charged in the SKU's own currency.
## What does `create` require?
| Field | Notes |
| - | - |
| `resellerRequestId` | Your own id, max 32 chars. Required, and it is the idempotency key |
| `resellerId` | Must match the API key |
| `itemId` / `skuId` | From `products`. An unknown SKU returns `A0005` |
| `skuQuantity` | Must be `1`; anything else returns `A0006` |
| `skuFaceValue` | Required on `SPECIFIED_FACE_VALUE` SKUs, and must be a whole multiple of the SKU's face-value unit |
| `notifyUrl` | Required, must be a valid URL. Where the purchase notification goes |
| `requestedAt` | Your request time, format `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`. Other formats are rejected |
| `buyerInfo` | Optional. On `TOP_UP` SKUs it carries the top-up account details the item asks for |
| `extendInfo` | Optional, must parse as a JSON object, max 128 chars. Echoed back, not interpreted |
Two behaviours to know: `buyerInfo.userEmail` is always replaced with the e-mail on your reseller account, so your customer's address there has no effect; and the available balance in the order currency is checked **before** the order is accepted, so an underfunded account fails fast with `A0009` instead of producing an order.
## Why does `create` always come back `IN_PROGRESS`?
Because acceptance and fulfillment are separate. `create` returns `resellerRequestId`, the Waffo `orderId` and `status` `IN_PROGRESS`; the debit and the supply run afterwards, and the outcome arrives at your `notifyUrl` or from `inquiry`. Never read the create response as a purchase result.
A repeat of the same `resellerRequestId` is deduplicated on `resellerId` + `resellerRequestId`: you get the original `orderId` back, again as `IN_PROGRESS`, even if that order already finished — so follow a duplicate with an `inquiry`. No second order is created and the repeat's other fields are ignored. Two identical requests racing in parallel return `A0010`; retry after a moment.
## How do I query one order, or many?
`inquiry` takes `orderId` **or** `resellerRequestId` — at least one, and the order must be yours. Set `returnGameCard` to `true` for `gameCardList`: serials, passwords where the SKU uses them, `expiryType` (`FROM_PURCHASE` or `FROM_REDEMPTION`) and `expiry`. Cards appear only once the order is `SUCCESS`.
`orders-inquiry` is the paged form. `page`, `pageSize`, `startTime` and `endTime` are all required; the window filters on `requestedAt` — the time **you** sent — in the same ISO 8601 format, and is meant to stay within 6 months. `resellerRequestId`, `orderId` and `status` narrow it further. `records` come wrapped in `total`, `size`, `current` and `pages`, each shaped like a single `inquiry` but with no card serials.
## What do the order statuses and amounts mean?
| `status` | Meaning |
| - | - |
| `IN_PROGRESS` | Accepted, being paid or being supplied. Not final |
| `SUCCESS` | Supplied; card details available with `returnGameCard` |
| `PAY_SUCCESS` | Paid, and fulfillment is yours to complete — Waffo does not deliver this SKU |
| `FAILURE` | Payment or supply failed. `failureCode` and `failureReason` are populated, and any debited amount is returned to your balance |
`totalAmount` is the list price of what you bought, `totalAmountAfterDiscount` is what your contract charges, and `currency` applies to both. `completedTime` appears once the order is final.
`fulfillment-inquiry` belongs to the direct-fulfillment (Mode C) flow, not the purchase flow, which is why its shape differs: the caller identifies itself as `supplierId`, the order as `salesOrderId` or `payOrderId` (at least one), and the answer is a `fulfillmentStatus` — `IN_PROGRESS`, `SUCCESS`, `PAY_SUCCESS`, `PAY_SUCCESS_SUPPLY_FAILED` or `PAYMENT_FAILED` — not the four values above. See
[Mode C: direct fulfillment](/docs/en/developer-docs/point-topup/mode-c-direct-fulfillment).
## What arrives at my `notifyUrl`?
Once an order is final, Waffo posts a signed JSON body:
```json theme={null}
{
"eventType": "GAMEPIN_PURCHASE_NOTIFICATION",
"result": { "orderId": "...", "status": "SUCCESS", "gameCardList": [ ... ] }
}
```
`result` is the full `inquiry` payload, card serials included. Verify `X-SIGNATURE` against Waffo's public key, then acknowledge with exactly `{"message": "success"}` — any other body, or a non-2xx response, counts as a failed delivery and is retried. Retries do not continue forever, so keep a reconciliation poll against `inquiry` as the source of truth.
## Which error codes do these endpoints return?
| Code | Meaning on this flow |
| - | - |
| `0` | Accepted. Still check the business `status` |
| `A0001` / `A0002` | Unknown API key / signature verification failed |
| `A0003` | Parameter validation failed, including a `resellerId` that does not match the key |
| `A0004` | The key may not call this endpoint |
| `A0005` | The item or SKU is not on your reseller contract |
| `A0006` | `skuQuantity` other than 1 |
| `A0007` | The order does not exist, or is not yours |
| `A0009` | Insufficient reseller balance in the order currency |
| `A0010` | Duplicate concurrent request for the same `resellerRequestId` |
| `C0001` | System error. Query before re-submitting |
***
Something not matching? [Contact support](mailto:support@waffo.com) with your `resellerId` and either the `orderId` or the `resellerRequestId`.
# Portal Sign-in, Links & Account Status
Source: https://waffo.com/docs/en/support/portal-account-access-faq
Why the Merchant Portal signs you out, how long every email link stays valid, why a resend is refused, what the password expiry and reuse rules are, what Continue with Google does, and which account statuses can and cannot sign in.
This page is about getting **into** the Merchant Portal: sessions, email links, passwords and
account status. Role permissions, inviting teammates, login history and two-step verification are
covered in [Merchant Portal Operations](/docs/en/support/portal-operations-faq). The first-time
sign-up, sandbox and account-verification path is covered in
[Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq).
API credentials and public keys live in
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
Every message quoted below is the exact text the Portal shows, so you can match what you see on
screen to the cause.
## Why does the Portal keep signing me out?
The Portal session is **idle-based, with an absolute ceiling**:
| Rule | Value | What it means for you |
| - | - | - |
| Idle timeout | 8 hours | A session with no activity expires. Activity renews it. |
| Renewal threshold | 4 hours | The session is only extended once less than 4 hours of its window remains — so a short burst of activity does not reset the clock every click. |
| Absolute maximum | 16 hours | Even a continuously active session ends here. You must sign in again. |
When the session is gone, requests come back as `Session expired.` and the Portal returns you to
the sign-in screen. There is no way to lengthen these windows from the Portal.
Two other things end a session immediately, before any timer:
* your account being locked after repeated wrong passwords (see below);
* your role being changed by an administrator — see
[Merchant Portal Operations](/docs/en/support/portal-operations-faq).
## How long is each email link valid?
Four different emails carry four different links, and their lifetimes are **not** the same. This is
the single most common source of "the link says it expired" tickets.
| Email | Link lifetime | Reusable? |
| - | - | - |
| Team invitation (an admin added you) | **24 hours** | No — single use |
| Password reset ("Forgot password") | **30 minutes** | No — single use |
| Self-serve sign-up activation link ("Magic Link") | **15 minutes** | No — single use |
| Sign-up one-time code (OTP), where you chose the code instead of the link | Short-lived; the Portal shows the countdown | No |
An expired or already-used link does not silently fail — you get a specific message:
| What you see | Cause |
| - | - |
| `Verify email is expired.` | The invitation or reset link is past its lifetime |
| `Invalid email verify token.` | The link was altered, already consumed, or does not match this account |
| `Sign-up link is invalid or expired.` | The 15-minute self-serve activation link is no longer usable |
In every case the fix is the same: request a new one. Nothing is lost — the link only proves it is
you, it does not carry your data.
## I asked for a new link and nothing arrived. Why?
Because there is a short per-minute cap on these emails, and hitting it returns a message rather
than a mail:
> `Reached the message send times limit, please try again later.`
The cap is counted separately for password resets and for invitation / sign-up-activation emails.
Resending the activation link uses the same budget as invitations.
So if you clicked *Resend* several times in a row, the later clicks were refused and the earlier
mails are the ones in flight. Wait a minute, then request once and check:
1. the spam / junk folder;
2. that the address is the one your administrator actually invited;
3. that your mail server is not quarantining external senders.
If none of that explains it, ask your administrator to re-send from the team member list, which
also shows whether the account is still `Pending`.
## What are the password rules beyond complexity?
[Merchant Portal Operations](/docs/en/support/portal-operations-faq) covers the complexity requirement.
Three further rules decide whether a password change is accepted at all:
| Rule | Behaviour |
| - | - |
| Format | At least 8 non-blank characters, with at least one uppercase letter, one lowercase letter and one number |
| Reuse | A new password **cannot match any of your last three passwords** |
| Expiry | The password expires **90 days** after it is set |
The messages map one-to-one:
| What you see | Meaning |
| - | - |
| `The password format must be at least 8 non-blank characters, at least one uppercase letter, at least one lowercase letter, and at least one number.` | Complexity not met |
| `For security reasons, your new password cannot be the same as your last three passwords` | Reuse rule hit — pick a genuinely new one |
| `Passwords do not match.` | The two fields differ |
| `Wrong password.` | The *current* password you typed to authorise the change is wrong |
The reuse check runs both when you change your password while signed in and when you set one
through a reset link, so cycling back to an old password is not possible in either path.
## The Portal forced me onto a "set a new password" screen. What happened?
Your password reached its 90-day expiry. Sign-in succeeds but returns:
> `Your password has expired. Please set a new password for enhanced security.`
and the Portal routes you to a forced password-change screen. This is not skippable — no other
Portal page will load until the new password is set. The new password must satisfy the format rule
and must not repeat your last three, so have a fresh one ready. Setting it also restarts the
90-day clock.
Password-based sign-in is what triggers this. If you signed in through Google, the expiry check
does not stand between you and the Portal — but your password stays expired for password sign-in
until you change it.
## My account is locked. How long, and what unlocks it?
Wrong-password attempts are counted for **one hour**. The **fifth** failure inside that window
locks the account:
| Step | What happens |
| - | - |
| Failures 1–4 | Sign-in refused with `Username or password wrong.` |
| Failure 5 | Account status becomes `Locked`; the unlock time is set **1 hour later**; any active session of yours is revoked |
| While locked | Sign-in refused with `Your account is temporarily locked. Please try again later.` |
| After 1 hour | The account returns to normal by itself |
Two consequences worth knowing:
* The counter only runs on accounts that are currently `Active`, so failures against an account
that is already `Pending` or `Locked` do not stack further penalties.
* A successful sign-in clears the counter, so four failures followed by a success leaves you with a
clean slate rather than one attempt remaining.
There is no Portal button to unlock early. If you cannot wait, an administrator on your account can
help you through the reset-password path — the reset link is independent of the lock timer.
## Which account statuses exist, and which can sign in?
Only one status can sign in. The rest each produce their own message:
| Status | Can sign in | What the Portal says / does |
| - | - | - |
| `Pending` | No | `The user needs to set the password first.` — the invitation was never completed. Use the invitation link, or ask for a new one |
| `Active` | **Yes** | Normal sign-in |
| `Locked` | No | `Your account is temporarily locked. Please try again later.` — temporary, see above |
| Any other status | No | `User disabled.` |
In **Settings → Members**, your team list labels the first two — `Pending` and `Active` — because
those are the two states an invitation flow produces. A row showing anything else is an account
that can no longer sign in, and only an administrator can act on it.
`User disabled.` is therefore not a bug report: it means the account exists but is no longer in a
state that permits sign-in. Contact an administrator on your merchant account rather than
retrying.
## What does "Continue with Google" do?
It sits on both the sign-in and the sign-up screen. Clicking it opens Google's own consent window;
Waffo never sees your Google password.
What happens next depends on what already exists:
| Situation | Result |
| - | - |
| Your Portal user already exists with that email address | You are signed in, and the Google account is linked for future sign-ins |
| Your Portal user exists but is still `Pending` (invited, never activated) | Google's proof that you own the address activates the account, then signs you in |
| No Portal user exists for that address | An account can be created this way only where self-serve sign-up is open — see [Self-Serve Sign-up](/docs/en/support/getting-started-self-serve-faq). Where it is not, sign-in is refused |
Two rules always apply:
* Google must report the address as **verified**. An unverified Google address is rejected.
* The status check above still runs. Google sign-in does **not** bypass a locked or non-active
account.
Because the link is made on the email address, signing in with Google and signing in with a
password land you on the *same* Portal user — not a second account. If Google sign-in fails
unexpectedly, the Portal surfaces the reason inline; retry once, then fall back to email and
password.
## Why does the sign-up form make me pass a "security check"?
Self-serve sign-up sends a real email to the address you type, so the first send is protected by a
human-verification challenge. If it does not pass you get:
> `Security check failed. Please refresh and try again.`
Refresh the page and submit once — a stale page is by far the most common cause, because the
challenge is only good for a short time. The check runs on the send that starts sign-up, not on
every subsequent step.
## What is the invitation code field on the sign-up form?
It is **optional**. Leave it empty and sign-up proceeds normally.
If you *were* given a code and it is not accepted, the message is:
> `The invitation code is invalid.`
Codes are exact — no leading or trailing spaces, correct case. If it still fails, ask whoever gave
it to you rather than guessing, and note that removing the code lets you register anyway.
## I tried to sign up but the address already has an account.
You get `Email already exists.` and no second account is created. One email address maps to one
Portal user, so:
* if you set a password before, sign in with it;
* if you never finished the invitation, use the invitation link, or ask your administrator to
resend it;
* if you do not know the password, use **Forgot password** — that works on an existing account
regardless of how it was created.
Repeating sign-up will not produce a fresh account, and it consumes your per-minute email budget.
## Can I choose the link or a code when signing up?
Yes. Self-serve sign-up supports two ways of proving you own the address:
| Method | How it works | Validity |
| - | - | - |
| Activation link (default) | You receive an email with a one-time link; opening it activates the account | 15 minutes |
| One-time code (OTP) | You receive a numeric code and type it into the form | Short-lived; the form shows the countdown |
Both prove the same thing, so pick whichever your mail setup handles better — the link path is
easier when mail arrives on the same device, the code path when it does not. Either way the
account is created once, and the per-minute send cap applies to both.
## Sign-in worked, but the Portal says my user is not attached to a merchant.
The message is:
> `User not set relation merchants. Please contact your administrator to set the relation merchants.`
Your credentials are fine; the account simply is not associated with a merchant account yet, so
there is no data for it to open. Only an administrator can attach it. This most often appears
right after an account is created outside the normal invitation flow.
## Does the Portal ask for a verification code at every sign-in?
Only if two-step verification is switched on for your user. That, including the case where it
appears without you enabling it and what to do when the code does not arrive, is covered in
[Merchant Portal Operations](/docs/en/support/portal-operations-faq) and
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
For sizing expectations here: the code is delivered by email, is short-lived, and resending it is
rate-limited in the same spirit as the link emails above — repeated clicks return
`OTP rate limit exceeded.` rather than more messages.
# Portal Dashboards & Analytics
Source: https://waffo.com/docs/en/support/portal-analytics-faq
How to read the Merchant Portal dashboards: Home data freshness and the settlement calendar, Payments Analytics metric definitions, Chargeback Analytics ratios and periods, and the rules behind transaction, refund and settlement exports.
This page explains what each Merchant Portal dashboard counts, over which period, and how
the exports behind those numbers are built. Settlement mechanics live in
[Merchant Settlement](/docs/en/essentials/merchant-settlement).
## Why doesn't today's activity appear on the Home dashboard?
The Home dashboard is a **T-1 view**: it shows the last two weeks ending yesterday, and
today or later cannot be selected. Figures are grouped **by order currency** — you switch
between the acquiring currencies on your contract (a dropdown once there are more than
nine). The metrics are successful payment amount and count and successful refund amount and
count, partial and full refunds included, each with a trend chart. If the previous day's
data has not finished refreshing, the page says so rather than showing incomplete figures.
## What does the unsettled amount on Home include, and how do I read the calendar?
The unsettled amount is shown per currency, across every acquiring currency you have ever
signed for, and equals the **sum of the settlement amount of all packaged settlement
documents, excluding fees**. Today's transactions are not packaged yet — fees cannot be
calculated before packaging — so the figure is not real time and will not tie back to
today's transaction list. Negative values are displayed as-is. Home also lists the amount
and expected settlement date of your next three settlements.
The calendar covers the next three calendar months; today and later dates are selectable,
earlier ones are not. A selected day is either not a settlement day, a settlement day not
yet settled (with an expected amount, or "no settleable amount"), settled successfully, or
failed. Expected amounts come from packaged settlement documents scheduled for that day,
and **if a day nets out negative the shortfall carries forward** and is offset against the
next settlement day, which then shows the net figure.
## What does Payments Analytics cover, and how are its rates defined?
**Transactions → Payments Analytics** covers one-time payments and Direct Payment;
subscriptions are out of scope. Order currency is required when your contract allows
multi-currency ordering; interval (half-hour, one hour, day, week) defaults to no slicing;
payment method type is single-select, and payment method is multi-select, limited to Active
methods and driven by the currency and type you picked. You get a **funnel** for the overall
view, a single type or a single method, and a **table** once several methods are selected —
split by country where one currency and method resolve to more than one configuration.
The funnel has three counts — total orders created, payment orders successfully created at
checkout, and successful orders — and the rates build on them exactly as the metric tooltips
state:
| Metric | Definition |
| - | - |
| Checkout Conversion Rate (CCR) | Checkout success orders ÷ total orders |
| Payment Conversion Rate (PCR) | Successful orders ÷ checkout success orders |
| Success Rate | Successful orders ÷ total orders |
So CCR measures how many created orders reached a payment attempt, PCR how many attempts were
paid, and Success Rate is the product of the two. Subscription products can be read separately
for first period and renewals.
## What do the ratio cards in Chargeback Analytics measure?
**Analytics → Chargeback Analytics** shows four ratio cards, each with its numerator and
denominator in the card tooltip:
| Card | Numerator | Denominator |
| - | - | - |
| Visa Chargeback Rate | Fraud reports (TC40) + first chargebacks (TC15) | Settled successful card transactions |
| Visa Fraud Rate | Fraud reports (TC40) + fraud-coded first chargebacks (Visa reason code 10.4) | Settled successful card transactions |
| Mastercard Chargeback Rate | First chargebacks, any reason code | Settled successful card transactions |
| Mastercard Fraud Rate | Fraud-flagged first chargebacks (reason code 4837 only) | Settled successful card transactions |
Each card carries the card network's early-warning threshold; crossing it turns the value
red and shows how far above you are. The page prints two caveats. These are **operational
early-warning figures, not the card networks' compliance calculations** — those programs use
offset monthly windows, and fraud-report coverage is not exhaustive, so your real compliance
ratio can be higher than what is displayed. And chargeback data is revised as cases are
decided, so read the latest value rather than one you noted earlier.
## Which period does Chargeback Analytics report on?
The date filter defaults to the 1st of the current month through today, so the cards are
**month-to-date**. A custom range switches every linked card, the trend chart and the
reason-code chart to a cumulative value for that range, which is not the monthly compliance
view; the same start and end date is treated as the 1st of that month through that date.
Chargeback Count then covers every case created in that period except cancelled ones.
* **Chargeback Amount** is dispute amount plus chargeback fee, converted to USD for display
(hover for the two parts), and does not distinguish won from lost.
* **Expiring Soon** is real time and **ignores every filter**, card brand included: it always
shows all disputes still awaiting your response whose deadline is near, and clicking it
opens the Disputes list with that filter applied.
* **Reason Code distribution** shows the top 10 codes plus "Others", with an in-chart tab for
all brands, Visa or Mastercard, switchable independently of the page filter.
## What are the rules for transaction, refund and settlement exports?
Exports are what-you-see-is-what-you-get: generated from the filters currently applied to
the list. They are separate from the scheduled settlement report files described in
[Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports).
* **The date span is capped at 31 days**; a wider range is blocked before the file is
generated.
* File names are `payments_{mid}_{from}_{to}.csv` and `refunds_{mid}_{from}_{to}.csv`, both
dates `YYYYMMDD` in UTC+8; settlements history exports as
`Settlements_{MID}_{download time}.csv`.
* **An empty result still produces a file** with only the header row, so "no matching
transactions" is distinguishable from a failed export.
* The file carries more columns than the list — including `Original Order Id`, `Merchant Id`,
`Transaction Type`, `Order Description`, `Product Name`, `Merchant User Id` and
`Settlement Status` — and splits the list's single `Order Amount` cell into `Order Amount`
plus `Order Currency`.
* Time zones are explicit in the column names: `Created Datetime(UTC+8)`,
`Payment Request Datetime(UTC+8)`, `Payment Datetime(UTC+8)`, formatted
`YYYY-MM-DD hh:mm:ss`. In the Portal, every settlement-related label is marked UTC+8 and
all other timestamps follow your local zone.
* A `Settlement Status` of `-` means the transaction is not settled yet (the field is null),
not that settlement failed; once settlement runs it becomes `SETTLE_SUCCESS`,
`SETTLE_FAIL` or `SETTLE_FAIL_NEED_RETRY`.
# Missing Columns, Filters & Views
Source: https://waffo.com/docs/en/support/portal-conditional-screens-faq
Why a Portal column, filter, tab or edit button you read about is not on your screen: which elements are contract-derived, which only appear when data exists, which analytics view you get and why, why a time interval is greyed out, and why a notification URL was rejected.
The single most common Portal support question is a variant of "the documentation mentions a column
I do not have". Almost always the element is conditional, and this page lists the conditions.
For what the dashboards actually count, see
[Portal Dashboards & Analytics](/docs/en/support/portal-analytics-faq) and
[Home Dashboard](/docs/en/support/portal-dashboard-faq). For who can see which module at all, see
[Merchant Portal Operations](/docs/en/support/portal-operations-faq).
## Why is a column or screen missing for me?
There are four different reasons, and the fix differs for each:
| Reason | How to recognise it | What to do |
| - | - | - |
| **Permission** | Colleagues on a different role see it; the module or button is absent for you | Ask an administrator on your account — see [Merchant Portal Operations](/docs/en/support/portal-operations-faq) |
| **Contract-derived** | The element depends on what you are contracted for — product tabs, subscription-only columns | Nothing to configure; it appears if the contract covers it |
| **Data-conditional** | The column only renders when at least one row on the page has a value for it | Nothing to configure; it appears when such data exists |
| **Environment** | Present in production, restricted in sandbox, or the reverse | Check which environment you are signed in to |
Known data-conditional elements: the `User Fee` and `Mini Apps Info` columns on
[Service → Payin](/docs/en/support/payin-agreements-and-limits-faq), and the `Initiation Type` column on
the payment list — see
[Transaction & Refund Field Reference](/docs/en/support/transaction-field-reference-faq).
## Why does my payment list have a Sub Merchant Id column when my colleague's does not?
Because the column is only rendered for accounts that operate sub-merchants. Where your account has
none, `Sub Merchant Id` is absent from the payment list entirely rather than showing a column of
dashes.
If you expect it and do not have it, the question is whether your account is set up with
sub-merchants at all — not a Portal setting. The same applies in reverse: a colleague signed in
against a different merchant account can legitimately see a different column set from yours.
## Which product tabs do I get on Payments Analytics?
Tabs are derived from the products your account is signed for:
| Your products | Tabs shown |
| - | - |
| Anything other than subscriptions | `One-time Payment` |
| Subscriptions | `Subscription` |
| Both | Both tabs |
| Only one of the two | **No tab bar at all** — the screen shows that product directly |
So an absent tab bar is not a fault; it means there is only one product to look at. You cannot add a
tab from the Portal — it follows the contract.
## Why do I get a funnel instead of a table (or the other way round)?
The view follows how many payment methods you selected:
| Payment method filter | View |
| - | - |
| Empty, or a single payment method | **Funnel** — the conversion steps for that selection |
| **Two or more** payment methods | **Table** — one row per payment method, so they can be compared |
That is the whole rule. If you want the funnel back, reduce the payment method selection to one or
clear it; if you want the comparison table, select at least two.
## Which time intervals can I choose, and why is one greyed out?
The available granularity depends on the length of the date range you picked. The range is never
restricted — the interval list is:
| Interval | Allowed date span |
| - | - |
| `Half-hourly` | Exactly 1 day |
| `One-hourly` | 1 to 7 days |
| `Daily` | 1 day and up, no ceiling |
| `Weekly` | 8 days and up |
A disabled option carries a tooltip explaining exactly what to change:
| Interval | Tooltip |
| - | - |
| `Half-hourly` | "Not available for ranges over 1 day. Narrow the date range to use "Half-hourly"." |
| `One-hourly` | "Not available for ranges over 7 days. Narrow the date range to use "One-hourly"." |
| `Weekly` | "Not available for ranges under 8 days. Widen the date range to use "Weekly"." |
| `Daily` | ""Daily" is not available for the current date range." |
Changing the date range **resets** the interval rather than leaving an invalid one selected: a
single-day range resets to `Half-hourly`, anything longer resets to `Daily`. So if your interval
changed by itself after picking new dates, that is why — set it again after the date change, not
before.
## What date range does Payments Analytics start with?
The **last 30 days**, with `Daily` granularity. Both are editable; the range picker imposes no
maximum span of its own, and days are not greyed out.
Very wide ranges at fine granularity are constrained by the chart's own rendering rather than by a
hard limit, so a long range with `Daily` is legitimate — expect the chart to compress rather than
refuse.
## Why does Payments Analytics warn me about freshness?
The screen carries a standing notice that data is refreshed on a short interval and that
transactions completed recently may not yet be reflected in the current view.
Take it literally: a payment taken a minute ago may be absent from analytics while being fully
visible on the transaction list. The transaction list is the source of truth for whether an
individual payment exists; analytics is an aggregation over it.
The Home dashboard has its own, different freshness model — see
[Home Dashboard](/docs/en/support/portal-dashboard-faq).
## Can I split subscription conversion into first payment and renewals?
Yes, on the `Subscription` tab only. A three-way selector appears:
| Option | Scope |
| - | - |
| `All` | Every subscription payment |
| `First Period` | Only the initial payment of a subscription |
| `Renewal` | Only subsequent renewal charges |
The selector is not rendered on the `One-time Payment` tab, because the distinction does not exist
there. Switching tabs resets the selection to `All`.
This split is often the answer to "our conversion looks worse than we expect" — first payments and
renewals convert quite differently, and `All` blends them.
## Why did my payment-method filters clear when I changed country?
Because payment method availability depends on country, so a country change invalidates the
narrower selections. Changing `Country/Region` clears both `Payment method type` and
`Payment method` and reloads the options for the new selection.
The same cascade runs one level further down: changing `Payment method type` clears
`Payment method`.
Set the filters top-down — country, then type, then method — and nothing will be cleared under you.
## Why is the payment-method-type filter disabled?
Because there are no options to offer for the countries currently selected. The filter is disabled
rather than shown empty, which distinguishes "nothing matches your country selection" from
"everything matches".
Widen or clear `Country/Region` and the options reappear. The payment method list is also filtered
by what your account is contracted for, so it will never offer a method you cannot transact on.
## Why is there no currency filter on Payments Analytics?
There is not one on this screen. Its filters are `Country/Region`, the date range, the interval,
`Payment method type` and `Payment method`, plus the product tabs and — on subscriptions — the stage
selector.
If you need a currency view, work from the transaction list or the exports, where currency is
carried per record. See [Exports & Report Downloads](/docs/en/support/export-center-faq).
## Why can't I edit the Tokenization Notify URL in sandbox?
Because that one control is restricted to production. The three notification URLs behave
differently:
| Notification URL | Editable in sandbox |
| - | - |
| Refund Notify URL | Yes |
| Chargeback Notify URL | Yes |
| Tokenization Notify URL | **No** — production only |
The URLs are per API operation type row on **Settings → Integration**, so a merchant holding several
of `Payin`, `Payout`, `Payin & Payout`, `Withdraw` and `Client` configures them per row rather than
once. Where a row has no URL set, the cell shows `-`.
Where to find the page and how the URLs relate to your API keys is covered in
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
## Why was my notification URL rejected when I saved it?
The dialog validates the URL before it will let you save, and shows exactly one message:
> "Invalid URL: must start with http\:// or https\:// and be a valid address."
The check requires all of the following:
| Requirement | Detail |
| - | - |
| Scheme | Must begin `http://` or `https://` — no other scheme, and the scheme cannot be omitted |
| Host | A valid host name; labels may contain letters, digits and hyphens |
| Port | Optional, numeric, up to five digits |
| Path and query | Optional, and restricted to characters legal in a URL |
| Whitespace | Not permitted anywhere, including a trailing space — a common cause of a URL that "looks fine" |
| Non-ASCII | Not permitted; an internationalised domain must be supplied in its ASCII form |
Length caps differ by URL:
| URL | Maximum length |
| - | - |
| Refund Notify URL | 512 characters |
| Chargeback Notify URL | 512 characters |
| Tokenization Notify URL | 2,048 characters |
The dialog shows a live character counter against the applicable cap, and stops accepting input at
it — so a truncated URL means you hit the cap rather than the field misbehaving.
**To remove a configured URL, clear the field and save.** The dialog states this ("leave blank to
remove"), and a blank value is accepted without triggering the validation error. Saving takes effect
without an approval step.
## Where can I filter by settlement status?
On the **Disputes** list, where settlement status is both a column and a filter. The payments list
does not carry it — see
[Transaction & Refund Field Reference](/docs/en/support/transaction-field-reference-faq).
For settlement figures at the transaction level, work from the settlement reports:
[Settlement reports](/docs/en/support/settlement-reports-faq).
## Why do numbers on analytics not match the transaction list exactly?
Three structural reasons, all expected:
| Reason | Effect |
| - | - |
| Refresh interval | Analytics runs a few minutes behind, as its own notice says |
| Aggregation window | Analytics buckets by your chosen interval; a bucket boundary will not align with an arbitrary list filter |
| Filter scope | The analytics filters and the list filters are not the same set — analytics has no currency filter, the list has no interval |
For a figure you intend to reconcile against, export from the transaction list rather than reading
it off a chart. Metric definitions are in
[Portal Dashboards & Analytics](/docs/en/support/portal-analytics-faq).
# Home Dashboard
Source: https://waffo.com/docs/en/support/portal-dashboard-faq
How the Portal Home dashboard counts: the Today panel's three TPV baselines, the TPV / Txns / ASP definitions, product split, the five time granularities, Compare, and what switching display currency does and does not convert.
The Home dashboard is the business-overview screen you land on after signing in to the Merchant
Portal. It is available to accounts it has been enabled for — **if you can see it on Home, it is
active for you**; accounts without it keep the previous Home layout. Settlement mechanics live in
[Merchant Settlement](/docs/en/essentials/merchant-settlement).
The screen has two independent areas. **Today** covers the current day only, from `00:00` **UTC+8**,
and reloads on a short interval (its tooltip states the exact cadence). **Your overview** — the Payment
and Refund tabs — covers any range you pick, ending yesterday at the latest, on T-1 offline data.
## What are the three TPV figures in the Today panel?
| Figure (all in the selected display currency) | Scope |
| - | - |
| **Total TPV** (large number) | Today, from `00:00` UTC+8 up to the latest available hour |
| **/ … (Yesterday TPV)** | Yesterday's **full day** — a finished day to size today against |
| **vs same time yesterday** (badge) | Yesterday accumulated **only up to the current hour**, so the comparison is like-for-like |
The badge is hidden — not shown as zero — when yesterday's same-time figure is zero or missing. The
**TPV Trend** chart plots two cumulative curves, today and yesterday, with volume bucketed to the
**start of each hour**: `00:00–00:59` appears at `00:00`.
The two mini cards next to it are also today-only, independent of the date range chosen further
down the page: **Refunded** is refunds *completed* today, **Refunds in Progress** is refunds
*initiated but not yet completed*. Note that **Total TPV includes refunded orders** — refunds are
reported alongside it, never subtracted from it.
## Why does the Today panel say data is delayed?
Today's figures come from a real-time pipeline that reports its own freshness. When it is behind, the
panel shows a banner instead of quietly displaying partial numbers, and names the last complete hour
it does have: *Data updated to `HH:00`*. Figures on screen remain valid up to that hour; if no hour
of today has landed yet, the banner asks you to refresh later rather than quoting a timestamp.
Two details are deliberate: the **last refresh time** is when this view fetched its data, not the
time of your last order; and the delayed state affects **Today only** — the Payment and Refund tabs
run on a separate T-1 pipeline.
## What exactly do TPV, Txns and ASP mean?
| Metric | Definition | Counting basis |
| - | - | - |
| **TPV** | Successful payment amount, refunded orders included | Order creation time |
| **Txns** | Number of successful payments | Order creation time |
| **ASP** | Average Sale Price = **TPV ÷ Txns** | Derived from the two above |
| **Refund Amount** | Total refunded amount | Refund initiation time |
| **Refund Count** | Number of refunds | Refund initiation time |
Because TPV is gross — refunded orders in, fees and taxes not deducted, converted at a reference
rate — it will not equal a settlement amount. Use the settlement batch and its reconciliation
reports for anything you have to tie to money received.
## Why can't I select today in "Your overview"?
The Payment and Refund tabs read T-1 data, so the date picker stops at **yesterday**. It opens on
the **last 30 days ending yesterday**, and a range longer than **one year** is refused with
*Date range cannot exceed 1 year*. Today's activity is what the Today panel is for.
## What do the five granularities change?
`Daily`, `Weekly`, `Monthly`, `Quarterly` and `Yearly` change how the range is bucketed in the trend
charts. Each bucket is labelled by the **first day of the period** as `YYYY-MM-DD` — Monday for
`Weekly`, the 1st for `Monthly`, the first month's 1st for `Quarterly` and `Yearly`. On `Daily`,
days with no activity are drawn as zero so the axis stays continuous.
## How does Compare work, and why is Δ% empty?
`Compare to` offers **Previous period** and **Custom**. Previous period derives the comparison
window for you; Custom asks for a second date range, and nothing is queried until both of its dates
are set. With Compare on, every amount and count — summary metrics, product split, performance
table rows — carries a Δ% badge, and hovering a trend point shows the matching bucket from the
comparison window. Δ% is intentionally **blank (—)** when the comparison value is zero or absent:
there is no growth percentage against a zero baseline, so no number is invented.
## What does switching display currency actually convert?
Everything on the page is expressed in one **display currency**, defaulting to `USD` (always
offered, even if you never transact in it), with your own transaction currencies listed alongside.
Amounts are converted at a **reference rate**, which is why an information banner reminds you that
the figure is an estimate and the final settlement rate may differ. Conversion here is presentation
only and **does not affect settlement**.
One column is deliberately exempt: the **TPV (local)** / **Refund Amount (local)** column in the
`By currency` table holds the original amount in that row's own currency — not converted, unchanged
when you switch display currency, and with no Δ% badge.
If no reference rate is available for the currency you pick, the switch is rejected and the selector
stays where it was, with a message that the rate is unavailable or invalid.
## How do I read the product split and the performance table?
**Product split** breaks the period into **One-time Payment** and **Subscription**, each with its
amount, count, and share of total TPV. On the Payment tab, Subscription further splits into **New
Subscription** and **Subscription Renewal**. The Refund tab shows only the two top-level groups.
**Performance table** has two views on the Payment tab:
| View | Rows | Extras |
| - | - | - |
| **By currency** | Every currency in the period, no truncation | Local-amount column |
| **By payment method** | **Top 10** by amount, remainder folded into an **Others** row that shows how many methods it covers | Per-method TPV Trend sparkline; **Region** filter |
The Region multi-select applies to the **By payment method** view only — it does not re-filter the
summary metrics, product split or trend charts. The Refund tab has a single `By currency` view and
no Region filter.
***
Numbers still not reconciling? [Contact support](mailto:support@waffo.com) with the date range,
granularity and display currency you were looking at.
# Portal List Filters & Date Ranges
Source: https://waffo.com/docs/en/support/portal-list-filters-and-date-ranges-faq
What you can filter and search on each Merchant Portal list, how far back each list looks by default, which pickers refuse a range longer than a month, how to cover a whole year, and why a button or a file name on a list is not clickable.
Every Portal list screen has its own filter set, its own default date window, and in some cases a
hard limit on how wide a range you may select. This page is the per-screen reference. For *why an
element is absent altogether*, see
[Missing Columns, Filters & Views](/docs/en/support/portal-conditional-screens-faq). For what happens
after you press Export, see [Export Centre](/docs/en/support/export-center-faq).
## Which filters does the payment list give me?
| Filter | What it does |
| - | - |
| Search box | One identifier at a time — see the next question |
| `Sub Merchant Id` | Only rendered if your account operates sub-merchants |
| `Currency` | Order currency, from the currencies your account has actually used |
| `Payment Method Country` | The country the payment method belongs to |
| `Payment Method Type` | The **category** — card, wallet, bank transfer and so on |
| `Payment Method` | The **individual method**, presented as a two-level picker under its category |
| `Status` | Order status |
| `Card Transaction Type` | `CIT` or `MIT` — see below |
| `Created Datetime` | Range picker, capped at one month (see the date table) |
`Payment Method Type` and `Payment Method` are the two levels of the same tree, not alternatives:
the type is the parent category, the method is the leaf. Filtering on the type gives you every
method in that category; filtering on the method narrows to one. You can set both, and the method
selection is what the list is finally narrowed by.
## What can I type into the search box, and does it accept my own order number?
The box is one dropdown plus one input, so it searches **one identifier at a time**. Yes, your own
order number is one of the options — that is `Merchant Order Id`. The option set differs per list:
| List | Searchable by |
| - | - |
| Payments | `Order Id`, `Merchant Order Id`, `Payment Request Id`, `Merchant User Id`, `Card Last 4 Digits`, `Subscription Id` |
| Refunds | `Order Id` (the refund), `Original Order Id`, `Merchant Order Id`, `Merchant Original Order Id`, `Payment Request Id`, `Card Last 4 Digits`, `Subscription Id` |
| Chargebacks / disputes | `Original Order Id`, `Dispute Id`, `Subscription Id`, `Merchant User Id` |
| Subscription agreements | `Subscription Id`, `User Id`, `User Email`, `Card Last 4 Digits`, `Order Id` |
| Payout (single) | `Payout Order Id`, `Payout Request Id` |
| Payout (batch) | `Payout Batch Id`, `Batch Name` |
| Settlement transactions | `Order Id`, `Merchant Order Id`, `Payment Request Id`, `Merchant User Id`, `Sub Mid` |
Two behaviours to know:
* **Switching the dropdown clears what you typed.** The identifier formats are different, so the
input is reset rather than carried over.
* **`Card Last 4 Digits` accepts digits only and exactly four of them.** Anything shorter returns an
empty result rather than a partial match, so an incomplete entry looks like "no data" instead of
an error.
There is no free-text search that guesses which identifier you pasted, and no search across
consumer name or email on the payment list — the customer-side identifiers you can search are the
`Merchant User Id` you sent us and the card's last four digits.
## What is `Card Transaction Type` for?
It splits card traffic by who initiated the charge:
| Value | Means |
| - | - |
| `CIT` | Customer-initiated — the cardholder was present in the flow and acted |
| `MIT` | Merchant-initiated — you charged a stored credential without the cardholder acting, as with a subscription renewal |
Use it when you want to separate checkout traffic from recurring charges: renewals are `MIT`, a
first payment or a one-off is `CIT`. The filter only makes sense for card payments; non-card methods
have no value for it. The same distinction appears as a column — see
[Transaction & Refund Field Reference](/docs/en/support/transaction-field-reference-faq).
## A colleague has a filter I do not have. Can I turn it on?
No. Some filters are released per account and only appear when they are enabled for yours; others
only exist when the underlying data does, such as the sub-merchant filter on accounts that have no
sub-merchants. Neither is something you or your administrator can switch on from the Portal — ask
your Waffo contact whether the filter applies to your account. The four general reasons an element
is missing are listed in
[Missing Columns, Filters & Views](/docs/en/support/portal-conditional-screens-faq).
Screenshots in documentation and demonstrations are taken on accounts with a different feature set,
so a filter in a screenshot is not evidence that your account should have it.
## How far back does each list look, and how wide a range can I pick?
Two separate things: the **default window** the screen opens with, and whether the picker **refuses**
a wider selection.
| Screen | Opens with | Picker limit |
| - | - | - |
| Payments | Last 7 days | One month — once you pick one end, dates 31 days or more away are greyed out |
| Refunds | Last 90 days | No limit on the picker; export is limited (below) |
| Chargebacks | Last 30 days | No limit on the picker |
| Customer disputes | Last 90 days | No limit on the picker |
| Chargeback alerts | Last 30 days | No limit on the picker |
| Subscription agreements | Last 60 days (`Created Datetime`) | Two months, and future dates are blocked |
| Payout — single | Last 30 days | One month |
| Payout — batch | Last 30 days | One month |
| Payout reports | Last 30 days | Future dates blocked |
| Settlement transactions, batches, estimation, report downloads | Last 30 days | See [settlement report spans](/docs/en/support/settlement-reports-export-faq) |
| Balance history, payout wallet history, reserve history | Last 30 days | See [Balance History](/docs/en/support/settlement-batch-and-calendar-faq) |
| Webhook records | Last 7 days | — |
| Login history | Fixed to the last 90 days, no picker | — |
The one-month picker limit works from whichever end you click first: after the first date, anything
31 days or more away from it is unselectable. So it caps the **width** of the window, not how far
back you may go — you can look at any month in the past, just one month at a time.
## I need six months of transactions. What do I do?
Not through the list filter — the payment list is capped at a one-month window, and transaction and
refund **exports are refused before a task is created** if the selected range is wider than 31 days
(the Portal says the time frame should not exceed one month). Your options:
| You need | Use |
| - | - |
| A long period of transaction-level detail | Run one export per month and combine them — twelve tasks for a year |
| Recurring delivery without clicking each time | A scheduled report subscription — see [Report Delivery & Subscriptions](/docs/en/support/report-delivery-and-subscription-faq) |
| Settlement-level rather than transaction-level history | The settlement report exports, which have their own spans — see [Settlement Report Exports](/docs/en/support/settlement-reports-export-faq) |
Each export becomes a task with its own status and download link; those mechanics, including what
`Retry Needed` and a non-zero failure count mean, are in [Export Centre](/docs/en/support/export-center-faq).
## How do I find every subscription agreement that is still live?
Filter on `Status`. The agreement list exposes the full status set, and "still live" is not one
value:
| Status | Reading |
| - | - |
| `IN_PROGRESS` | Being set up |
| `AUTHORIZATION_REQUIRED` | Waiting for the customer to authorise |
| `ACTIVE` | Live and billing |
| `EXPIRED` | Ran to its end |
| `CLOSE` | Closed |
| `MERCHANT_CANCELLED` | Cancelled by you |
| `USER_CANCELLED` | Cancelled by the customer |
| `PLATFORM_CANCELLED` | Cancelled on the Waffo side |
| `CHANNEL_CANCELLED` | Cancelled at the payment method's end |
Note the date filter is on **creation** time and defaults to the last two months, so an agreement
created last year is `ACTIVE` but outside the default window — widen the date range before
concluding it is gone. For what you can do to an agreement from the Portal, see
[Subscriptions in the Portal](/docs/en/support/subscription-cross-currency-and-portal-faq).
## Where does a payout I submitted in a batch show up?
The payout history screen has two tabs and they are keyed differently:
| Tab | Keyed by | Filters |
| - | - | - |
| Single | The individual payout order | `Payout Order Id` or `Payout Request Id`, payment method type, payment method, status, created date |
| Batch | The batch | `Payout Batch Id` or `Batch Name`, status, created date |
So if you have the payout order ID or your own payout request ID, search the Single tab. If you only
know which batch you submitted, open the Batch tab, find the batch, and open it — a batch's detail
lists its member payouts, searchable by payout order ID or recipient account within that batch; the
recipient account is a column there, not a search field on the Single tab. Payout statuses themselves
are covered in [Payouts](/docs/en/support/payout-faq).
## Why is the file name on the payout report list not clickable?
Because the download action on that screen is a separate permission from viewing the list. Where
your user can see the report list but not download from it, the `Report` cell renders as **plain
text instead of a button** — nothing is broken and the file is not missing. An administrator on your
account has to grant the download capability, or a colleague who has it downloads for you. Roles and
who can change them are in [Merchant Portal Operations](/docs/en/support/portal-operations-faq).
The list itself shows the period covered, the number of orders in it, and when it was generated, so
you can confirm the report exists before chasing the download.
## Which settlement screen should I reconcile against?
They answer different questions, and only one of them is the settled record:
| Screen | What it is |
| - | - |
| Settlement transactions | Transactions awaiting settlement — a forward view, not a settled figure |
| Settlement history / records | The settlement records themselves — this is what you reconcile against |
| Settlement batches | Records grouped as they were paid out |
| Settlement estimation | A projection, superseded by the report exports |
| Settlement calendar | Which dates are settlement days |
The identifiers, statuses, timeline and amount definitions of a settlement record are in
[Settlement Batches & Calendar](/docs/en/support/settlement-batch-and-calendar-faq), and the export
choices in [Settlement Report Exports](/docs/en/support/settlement-reports-export-faq) — including which
screen replaces the estimation view.
## This button is greyed out. Is it my permission or the record's state?
Both mechanisms exist, and you can tell them apart by whether the button is **absent** or **present
but disabled**:
| Symptom | Cause | What to do |
| - | - | - |
| The action is not on the screen at all | Your role does not carry it — permission-gated controls are not rendered | Ask an administrator on your account |
| The action is visible but disabled | The record's own state does not allow it | Check the record's status; a completed or already-actioned record cannot be actioned again |
| A file name shows as text where you expected a link | Download is a separate permission from viewing | See the payout report answer above |
Concrete examples of the state-driven kind: the refund action on a payment that is not in a
refundable state (see
[Transaction & Refund Field Reference](/docs/en/support/transaction-field-reference-faq)), resend on a
webhook record that is not eligible (see [Webhook Records](/docs/en/support/portal-webhook-log-faq)), and
withdrawal when the balance conditions are not met (see
[Withdrawal Exceptions & Status](/docs/en/support/withdrawal-exceptions-and-status-faq)). Pause, resume
and terminate on an automatic withdrawal agreement are state-driven in the same way — see
[Automatic Withdrawal](/docs/en/support/auto-withdrawal-and-withdrawal-integration-faq).
## Where is the API documentation link in the Portal?
Top right, next to the FAQ entry, when it is enabled for your account. It opens the public developer
documentation in a new tab and follows your Portal interface language. If it is not there, the
documentation is public anyway — start at
[Getting started](/docs/en/developer-docs/getting-started/overview). Nothing in the Portal is required to
read it.
# Merchant Portal Operations
Source: https://waffo.com/docs/en/support/portal-operations-faq
Account and team administration in the Waffo Merchant Portal: why menus differ between merchants, what each role can do, inviting and removing users, login security and two-step verification, interface language, and where to find your integration keys.
Account, team, and access administration in the Merchant Portal. See
[Merchant Portal](/docs/en/essentials/merchant-portal) for a module overview, and
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq)
for public keys, notification URLs, and the IP allowlist.
## Why can't I see Settlements, Balance, or the Disputes tab?
Parts of the navigation are driven by **what you have contracted**, not by your
role — so a missing menu is not something your Admin can grant.
| Not contracted | Hidden from the Portal |
| - | - |
| Settlement | `Settlements` and all submenus, `Account → Balance`, `Account → Balance history`, and the settlement calendar on Home |
| Withdrawal | `Account → Withdrawal history` and `Settings → Bank accounts` |
| Card acquiring | The `Disputes` tab under `Transactions` |
| Multi-currency ordering | The order-currency filter on list and analytics pages |
## What can each role do?
Every user has exactly one role, and the Portal shows a role's scope while you
select it, so you can confirm before inviting someone.
| Permission | Admin | General | Read-only | Dispute Manager |
| - | - | - | - | - |
| View transactions | ✓ | ✓ | ✓ | ✓ |
| Modify transactions | ✓ | ✓ | | |
| View settlements | ✓ | ✓ | ✓ | |
| View financial information | ✓ | ✓ | ✓ | |
| Make withdrawals | ✓ | ✓ | | |
| View contract information | ✓ | ✓ | ✓ | |
| Manage disputes | ✓ | | | ✓ |
| User management | ✓ | | | |
A separate **Dev** role carries the *Integration info view* permission, so Dev
and Admin are the only roles that can open the Integration page. The member
list lives under `Settings → Team` (formerly *Members*); the invite button is
*Add user*.
## How do I add a teammate, and how long is the invitation valid?
A Super Admin or Admin adds a user with first name, last name, email, and role.
The account is pre-created as **Pending** and an activation email goes out; the
activation link is valid for **24 hours**, and Pending rows show a **Resend**
action. Resends share a short per-minute cap with the other account emails — if
one is refused, wait a minute and send once (see
[Portal Sign-in, Links & Account Status](/docs/en/support/portal-account-access-faq)).
The member sets their own
password from that link — unless the same email is already active under another
merchant, in which case activation only needs a confirmation. Members are
searchable by first name, last name, or email, with your own row pinned to the
top.
Three limits surprise people: you cannot edit or delete **yourself**; the
**Super Admin** account cannot be edited or deleted from the Portal by anyone;
and changing a member's role **signs that member out automatically** — so if a
colleague was unexpectedly logged out, check whether their role just changed.
## What are the password rules?
At least **8 non-blank characters** with at least one uppercase letter, one
lowercase letter and one digit — a symbol is allowed but not required — and not
identical to any of your last three passwords. Passwords must be
rotated every **90 days**: on the first login after that window the Portal hides
everything else and forces the password-update page, and you must sign in again
with the new password before you can use the Portal. The forgot-password reset
link is valid for **30 minutes**, and a successful reset sends a confirmation
email to the account address — your signal if you did not initiate it.
## My account is locked, and how do I see who signed in?
Repeated failed password attempts within a short window lock the account for
**one hour** and send an email notification. During the lock, signing in returns
*"Your account is temporarily locked. Please try again later."* instead of a
wrong-password error; the lock then clears on its own.
To audit access, open the user menu at the top right and choose **Login
History** — the last **90 days** of attempts for your account, with login email,
time, source IP, and success or failure.
## How do I turn on two-step verification?
Two-step verification is an **email OTP**, **off by default**. Turn it on at
`Settings → Preference`; enabling runs one OTP round to confirm the mailbox is
reachable, and turning it off requires re-entering your password. After that,
signing in shows an **OTP Verification** dialog. The code is 6 digits and valid
for **10 minutes**; any unexpired code works, so an earlier email is still
usable if a later one arrives first, and **Resend** stays greyed out until a
short cooldown ends. If codes never arrive at all, an Admin can switch two-step
verification off for you from the `2-Step Verification` column in the Team list
— a fallback for undeliverable email only.
That column is a **two-way switch**, which answers the other common question:
an Admin can also turn two-step verification **on** for a member who has not
enabled it themselves. It is done per user from that column, one row at a time;
there is no account-wide policy that forces it on for everyone, and no setting
that prevents a member turning it off again afterwards. Rows the Portal marks as
not modifiable — your own, and the super administrator's — have the switch
disabled.
The setting belongs to the **user**, not the merchant account: if your email is
registered under more than one merchant, an Admin at any of them toggling it
affects all of them, and you can override it yourself after signing in.
## I switched the Portal to Chinese, so why are my emails still in English?
The top-right language switcher controls the **interface only**, and it is stored
in your browser. (The switcher is itself released per account, so if you do not
have it, the Portal is showing you its default language rather than hiding a
setting your role should have.) The choice lives in that browser, so a different
browser, device, or a cleared cache starts from the default again.
Two things follow their **own** language setting rather than the switcher:
settlement report exports carry a **Report Language** selector (English, 中文,
にほんご) on the Customized Export form, and settlement report email subscriptions
carry an **Email Body Language** per subscription — see
[Settlement Reports Module](/docs/en/support/settlement-reports-export-faq) and
[Report Delivery & Email Subscription](/docs/en/support/report-delivery-and-subscription-faq).
Other system emails, API error messages and the transaction and refund list
exports are English regardless of the interface language.
## Where do I find my API key, and why are production keys masked?
`Settings → Integration` (Admin and Dev roles only) is split into sandbox and
production blocks. Every value has a copy action; what differs is what is shown
by default.
| Value | Sandbox | Production |
| - | - | - |
| Merchant name, Merchant ID | Shown | Shown |
| Waffo Public Key | Shown | Masked — click *show* |
| Merchant Public Key | Shown | Masked — click *show* |
| Merchant Private Key | Shown | Not listed |
| API Key | Shown | Masked — click *show* |
If **Merchant Public Key** reads `To be updated` for production, no production
public key has been registered yet — see
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq)
for how to submit and rotate it.
## Why am I suddenly asked for a verification code I never turned on?
Because an Admin on your merchant account switched **2-Step Verification** on for
your operator account. There is nothing for you to set up: enabling it on
someone's behalf binds the **email** method straight away, against the login
email already on the account, so the next sign-in shows the *Verification*
dialog with a code already on its way to that mailbox.
| State when the Admin flipped the switch | What you get |
| - | - |
| Two-step verification was off | Now on, bound to your login email, active from the next sign-in |
| You had already turned it on yourself | Nothing changes — the switch is a no-op against a method you already confirmed |
You can switch it off again yourself once you are signed in. The switch sits in
the **top-right user menu**, next to your name and role, and turning it off asks
for your account password — worth agreeing with whoever turned it on, since it
removes the extra protection.
## Why is the 2-Step Verification switch greyed out for one of my operators?
The column is disabled on rows whose **role is not editable** — the same rows
whose *Edit* and *Remove* actions are greyed out, the owner-level role included.
Nothing about that person's two-step verification can be changed from the
operator list; they change it themselves from their own user menu.
## I changed my phone and can't receive the code. What now?
The Portal's code is delivered by **email**, to the login address on your
account. No phone number and no SMS are involved, so a new phone or a new number
changes nothing — work the mailbox instead:
1. Check the mailbox for the address shown in the *Verification* dialog, spam
folder included. Codes last 10 minutes and any unexpired code works, so an
earlier email is still usable.
2. Use **Resend** once the countdown ends.
3. If nothing arrives, do what the dialog says and *contact your Admin for
help* — an Admin can switch two-step verification off for you from the
`2-Step Verification` column in the operator list under `Settings`.
Switching it off yourself needs a signed-in session, so it is not a way out of a
mailbox you have lost access to; the Admin fallback is. If no Admin can be
reached, [contact support](mailto:support@waffo.com).
The sign-in mechanics behind these dialogs — the challenge response, the resend
token, and the endpoints each toggle calls — are in
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
# Receipts: View, Send & Configure
Source: https://waffo.com/docs/en/support/portal-receipt-faq
How to view a payment receipt in the Merchant Portal, email it to a customer, read the delivery statuses, and understand what controls the receipt content.
A receipt is the customer-facing document for a successful payment. This page
covers the receipt actions available in the Merchant Portal. Merchant-name and
logo rules on the receipt are described in the Checkout Page Lifecycle &
Receipt FAQ.
## Where do I find the receipt for a transaction?
Open the transaction in **Transactions → Payment Orders** and look for the
**Receipt** block on the detail page. **View Receipt** opens the receipt page
in a new tab, where the receipt can be switched between languages and
downloaded.
If you prefer to handle this from your own system, the order inquiry response
returns `userReceiptUrl` for orders that support a receipt; see the
[Developer FAQ](/docs/en/developer-docs/tools-and-references/references/faq) for how
long a receipt stays valid.
## Why do some transactions have no Receipt block at all?
The Receipt block appears only when a receipt can actually be produced for
that transaction. All of the following must be true:
* the payment reached a **successful** state,
* the payment went through a Waffo checkout session, and
* receipts are **enabled for your contract type**.
When any of these is not met, the whole block — including **View Receipt** —
is hidden, and a send request for that order is rejected because receipts are
not enabled for it. There is no way to produce a receipt for such a
transaction from the Portal.
## How do I email a receipt to a customer?
Use **Send Receipt** on the transaction detail page and enter the recipient
address. It does not have to be the address the customer used at checkout, so
you can also send a copy to yourself or to your finance mailbox.
The email is sent from Waffo's official sending address, is titled
`Your Receipt from {your merchant display name}`, and contains a link to the
receipt page rather than an attachment.
## I clicked Send twice. Will the customer get two emails?
No. Repeated submissions for the **same order + same recipient + same Portal
operator** within a short window are de-duplicated: the Portal reports
success, but only one email goes out.
Outside that window — or with a different recipient — the send is treated as a
new request and creates a new send record. You can re-send as often as you
need.
## What do the receipt-email statuses mean?
The detail page always shows the **most recent** send record for the order.
| Status | Meaning |
| - | - |
| `PENDING` | The send request is recorded and queued. |
| `SENT` | The mail service has **accepted** the message. This is not proof of delivery. |
| `DELIVERED` | The message was actually delivered to the recipient's mail server. |
| `FAILED` | Delivery failed — including bounced and dropped messages. A failure reason is shown. |
Treat `SENT` as "handed off", not "received". If a customer says the receipt
never arrived while the record still shows `SENT`, wait for the record to move
to `DELIVERED` or `FAILED` before re-sending.
## Can I see who sent it and where it went?
Yes. Each send record carries the delivery status, the **actual recipient
address**, how it was triggered, the Portal operator's email, the send
timestamp, and the failure reason when it failed. This is the record to quote
when a customer disputes whether a receipt was ever issued.
Automatic retries are performed by the mail service and only update the status
of the **existing** record — they never create a new one. A new record appears
only when you click **Send Receipt** again.
## Does Waffo email the receipt automatically after a payment succeeds?
No. Sending a receipt email is a manual action in the Portal today. Also not
available: batch download of receipts, several recipients in a single send,
scheduled sending, and customer-initiated re-sending from the receipt page.
If your customers expect a receipt email on every successful payment, send it
from your own system using `userReceiptUrl` from the order inquiry response.
## What controls the content shown on the receipt?
Receipt content is driven by a **receipt configuration held for your account**,
not by fields passed per order. Order and subscription payment information
report which configuration was applied as `receiptTemplateId`.
You manage that configuration yourself at **Settings → Receipt Configuration**:
document title, merchant display name and logo, the "From" entity block, the
footer and support copy, and whether the customer's name and email are shown.
Amount, fee and subscription rows are filled by Waffo from the transaction
itself and cannot be edited. Changes take effect on save, with no approval step,
and apply to past as well as future transactions — see
[Receipt Configuration & Download](/docs/en/support/receipt-configuration-and-download-faq).
# Portal Self-Service Security Configuration
Source: https://waffo.com/docs/en/support/portal-security-config-faq
How to manage API security settings in the Merchant Portal: submitting and rotating public keys with challenge-string verification, configuring refund and chargeback notification URLs, and restricting API access with an IP allowlist.
This page covers the security settings you can manage yourself on
**Merchant Portal → Integration**. For request signing mechanics, see the
[Authentication & Signature FAQ](/docs/en/support/auth-signature-faq).
## Can I add or rotate my public key myself in the Merchant Portal?
Yes. Production public keys are submitted online through the Integration page.
Each submission registers a **new** key record with its own API key — you do
not edit an existing key in place. The submission goes through Waffo review:
| Status | Meaning |
| - | - |
| Pending | Submitted, awaiting Waffo review |
| Active | Approved — used to verify your request signatures |
| Rejected | Not approved — you can correct and resubmit |
You receive an email when the key is activated or rejected (rejection emails
include the reason). To rotate keys, submit the new public key, wait for it to
become Active, then switch your integration to the new API key.
## Why does submitting a public key require signing a challenge string?
To prove you actually hold the private key paired with the public key you are
uploading. The Portal shows a fixed challenge string in the form
`WAFFO_VERIFY_{mid}`; sign it locally with your private key and submit the
signature together with the public key. Waffo verifies the signature with the
submitted public key before accepting it.
| Item | Requirement |
| - | - |
| Key type | RSA 2048-bit, X.509 format, Base64 encoded |
| Signature algorithm | SHA256withRSA (RSASSA-PKCS1-v1\_5) |
| Signature encoding | Base64 |
The same public key cannot be submitted twice for the same merchant.
## How many API keys and public keys can I have?
Each API key maps to exactly **one** public key. A merchant can hold multiple
API keys, scoped by API operation type:
| Operation type | Scope |
| - | - |
| `PAYIN_ONLY` | Payment, refund, and query APIs |
| `PAY_OUT_ONLY` | Payout and transfer APIs |
| `PAYIN_AND_PAYOUT` | All payin and payout APIs |
| `WITHDRAW` | Withdrawal APIs |
A per-operation-type limit applies to how many public keys you can hold; it is
generous enough to keep old and new keys active in parallel during a rotation,
and the Portal tells you when you reach it. The count includes submissions that
were rejected, so clean up abandoned attempts if you approach the limit.
## Where do I configure the refund and chargeback notification URLs?
Also on **Merchant Portal → Integration**. Unlike public keys, notification
URL changes need **no approval** — once the URL passes validation, it takes
effect immediately. Validation rules: the URL must start with `http://` or
`https://`, conform to RFC 3986 (no spaces or non-ASCII characters), and be at
most 512 characters. Clearing the field and saving deletes the configuration.
## Can I set different notification URLs per API key?
No. Each MID has one global Refund Notify URL and one global Chargeback
Notify URL — they are not stored per API operation type. The Chargeback
Notify URL applies to card acquiring only. Withdrawal notifications do not
use these settings: the withdrawal notify URL is passed per order in the API
request.
## How do I restrict API access to my servers' IP addresses?
Enable the **IP allowlist** in the Portal — an optional safeguard that is off
by default. Once enabled, a request whose signature is valid but whose source
IP is not on your allowlist is rejected with error `A0046` ("Access denied:
the request IP is not in the whitelist", HTTP 401). Configuration changes take
effect shortly after you save — saving also invalidates the cached lookup, so in
practice it is usually immediate. Merchants that never enable it see no behavior
change.
The allowlist is evaluated **after** signature verification, so a request with
a bad signature fails on the signature error (`A0002`) rather than `A0046`,
even when its IP is also off the list.
## What formats and limits does the IP allowlist support?
* Entries accept **IPv4 addresses** and **CIDR ranges** (for example
`203.0.113.7` or `198.51.100.0/24`). IPv6 is not currently supported.
* Up to **20 entries** per merchant; duplicates are rejected.
* Each entry can carry a label and be toggled on or off individually.
* The allowlist cannot be switched on with no active entries, and deleting or
disabling the last active entry automatically turns the master switch off.
## What happens if Waffo cannot determine my request's source IP?
By default, a request whose source IP cannot be determined is **allowed** and
logged — only an IP that is determined and not on your allowlist is blocked.
Waffo supports stricter and looser enforcement policies than this default, but
the Portal exposes only the master on/off switch; the policy itself is set on
Waffo's side. Contact integration support if you want to run in
observe-only mode first, or if you need requests blocked even when the source
IP cannot be determined.
## How does the two-step verification challenge work at sign-in?
Two-step verification is a **sign-in** control rather than an Integration
setting — the account-side questions (who can switch it on, what to do when
codes never arrive) are in
[Merchant Portal Operations](/docs/en/support/portal-operations-faq). Mechanically it
is checked *during* the login call, not after it:
1. You submit the sign-in form as usual. If the account has a confirmed two-step
method, no session is issued: the sign-in comes back as a
**challenge required** response, and a code is already on its way to the
account's login email.
2. The Portal opens the *Verification* dialog with a short-lived resend token
attached to that sign-in attempt.
3. You enter the code and the Portal repeats the **same sign-in**, this time
with the code. Only then is a session issued.
Only the account's **default** method is challenged. A wrong or expired code
fails the sign-in itself, which is why the dialog asks you to re-enter the code
and not the password.
## Why does Resend stop working if I leave the sign-in page open?
At that point you have no session, so Resend cannot be authorised by one. It is
authorised by the resend token handed out with the challenge. That token belongs
to a single sign-in attempt and expires after a while; reloading the page or
starting a new attempt discards it. If Resend fails, sign in again — that issues
a fresh challenge and a fresh token.
## What does each two-step verification control actually do?
All of these are Portal actions performed with your **Portal session** — they
are not part of the API-key-signed merchant API and cannot be scripted against
with your integration keys.
| Control | What it does |
| - | - |
| Switching it **on** (`Settings → Preference`) | Opens a binding for the email method and sends the first code |
| *Resend* inside the binding dialog | Sends another code for the same binding |
| *Verify* inside the binding dialog | Confirms the code and completes the binding |
| Switching it **off** | Unbinds the method; asks for your account password |
| *Resend* inside the sign-in dialog | Sends another code for the current sign-in attempt, using the resend token |
| The `2-Step Verification` column in the operator list (Admin) | Enables or disables the email method for another operator |
Three consequences are worth knowing:
* **A binding only counts once the code is verified.** Closing the dialog after
the code was sent leaves the attempt unconfirmed and two-step verification
off; switching the toggle on again starts a fresh round.
* **The first method you confirm becomes the default** — and the default is the
one the sign-in challenge uses.
* **Switching off is authorised by your password, not by a code**, travelling
encrypted the same way the sign-in form's password does. A wrong password
returns the standard wrong-password error and the method stays bound.
# Merchant Switcher, Demo Mode & Environments
Source: https://waffo.com/docs/en/support/portal-switcher-and-environments-faq
The merchant ID dropdown at the top of the Portal and the alias beside it, how it differs from the sub-merchant filter, what the Demo Mode badge means and how to leave it, whether sandbox and production are the same account, and what to do when an administrator leaves.
Questions about *which* account you are looking at: the switcher at the top of the Portal, the two
environments, and the badge that tells you the account cannot transact yet. Roles, invitations,
passwords and two-step verification are in
[Merchant Portal Operations](/docs/en/support/portal-operations-faq); how brands and sub-merchants relate
is in [Multiple Brands & Sub-Merchants](/docs/en/support/multi-brand-and-sub-merchant-faq).
## What is the merchant ID dropdown at the top of the Portal?
It selects **which merchant account you are working in**. It only appears when your user is attached
to more than one, so a single-account user never sees it.
Everything below the header follows the selection: transactions, settlements, balances, agreements,
integration keys and settings all belong to the selected merchant ID. There is no combined "all
merchants" view — you look at one account at a time.
You can type in the dropdown to filter, and it matches on the merchant ID, the merchant name, and
the alias in brackets, so any of the three finds the row.
## What is the name in brackets next to a merchant ID?
An alias for the account, shown so you can tell two similarly named entities apart. It is displayed
in the dropdown, in the collapsed selector, and in the hover text, and it is searchable — but it is
**not editable from the Portal**: there is no field for it anywhere in the interface. If the alias is
wrong or you want your own project name there, ask your Waffo contact to change it.
## How is that different from the `Sub Merchant Id` filter on the lists?
They operate at different levels:
| Control | Level | Effect |
| - | - | - |
| Header merchant switcher | The merchant account (MID) | Changes which account's data the whole Portal shows |
| `Sub Merchant Id` filter | A sub-merchant under the current account | Narrows one list to traffic of one sub-merchant, inside the account you are already in |
So the switcher moves you between accounts; the filter narrows within one. The filter is only
rendered on accounts that actually operate sub-merchants — see
[Multiple Brands & Sub-Merchants](/docs/en/support/multi-brand-and-sub-merchant-faq).
## Why was I sent back to Home and my filters cleared when I switched?
Both are deliberate. Switching resets the Portal to Home and clears the filters kept from the
previous account, because a filter set built for one account — a sub-merchant, a payment method, a
currency it used — will not be meaningful in the next one and would silently return nothing.
The same reset happens if you switch account in one browser tab and then return to another tab that
is still on the old account: that tab reloads onto Home rather than showing stale data. If you want
two accounts side by side, use two browser profiles rather than two tabs.
## Why does my account show a Demo Mode badge, and can I still receive real money?
The badge appears when your user holds the **read-only demo role**. Hovering it says you are in
Demo Mode and need to verify your business details before you can transact. In that state the Portal
is for looking around: screens are populated so you can see what each one does, and several of them
carry an extra explanatory tooltip that only demo users get.
It answers your question directly: **no real money moves while the account is in this state.** Live
payments require verification and payment enrollment to complete — see
[Payment Enrollment](/docs/en/support/payment-enrollment-faq) for the exact gates.
## How do I leave Demo Mode?
By completing the onboarding path, not by changing a setting. In order:
1. Submit account verification (your entity, ownership, and documents).
2. Wait for the verification result and clear anything sent back to you.
3. Let payment enrollment complete for the methods you want.
[Getting Started as a Self-Serve Merchant](/docs/en/support/getting-started-self-serve-faq) walks the
whole path, [KYB Document Requirements](/docs/en/support/kyb-document-requirements-faq) covers the
documents, and [Payment Enrollment](/docs/en/support/payment-enrollment-faq) covers the stage after
approval. There is nothing to click in the Portal to remove the badge itself.
## Is my sandbox sign-in the same account as my production sign-in?
No. The two environments are separate Portals on separate hostnames, and your sign-in belongs to
one of them. The sign-in screen tells you which environment you are on and offers a link to the
other; self-serve **sign-up always lands in the sandbox** — submitting the registration form on the
production host sends you to the sandbox host to register.
What follows from that:
| | Consequence |
| - | - |
| Passwords | Set and changed per environment; changing one does not change the other |
| Two-step verification | Configured per environment in the same way |
| Merchant ID and credentials | Different per environment — see [Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq) |
| Configuration you make in the sandbox | Stays sandbox configuration; it does not become your live capability set |
Which URLs belong to which environment, whether enabling a payment method in the sandbox enables it
in production, and where to read your merchant ID for the environment you are in are all covered in
[Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq). What you can
actually accept in production comes from what you are contracted and enrolled for — see
[Payin Agreements & Limits](/docs/en/support/payin-agreements-and-limits-faq).
## Can one email address reach several merchant IDs?
Yes. A user can be attached to more than one merchant account, and that is exactly what the header
switcher is for — you sign in once and move between them. You do not need one login per merchant ID.
Two consequences worth knowing:
* Your **role is per merchant account**, so you can be an administrator on one and read-only on
another.
* Two-step verification belongs to the **user**, not the account, so it applies wherever that email
signs in — see [Merchant Portal Operations](/docs/en/support/portal-operations-faq).
## We are adding another legal entity. New merchant ID, or a new Portal account?
Neither requires a second login. A new entity becomes an additional merchant account reachable from
your existing user through the switcher, and it goes through verification on its own — an entity is
approved as an entity, so the documents are submitted for the new one even where the group is the
same.
Whether a second entity is the right structure at all, versus sub-merchants under the account you
have, is answered in [Multiple Brands & Sub-Merchants](/docs/en/support/multi-brand-and-sub-merchant-faq):
sub-merchants separate brands inside one account, additional merchant accounts separate legal
entities. Sub-merchant identifiers themselves are not self-allocated from the Portal — there is no
screen that creates one.
## Our administrator has left the company. What can we do ourselves?
From `Settings → Team`, an administrator can:
| Action | Notes |
| - | - |
| **Remove** the departed user | Ends their access; their rows are struck from the team list |
| **Edit** their role | Changing a role signs that user out immediately |
| **Resend** an invitation | Only for users still in `Pending` |
| Turn **off** two-step verification for a user | The fallback when their codes are undeliverable |
Two rows resist all of this: **your own**, and any row the Portal marks as not modifiable — the
account's original super administrator among them. On those, Edit and Remove are visibly disabled
rather than hidden.
So the answer splits in two:
* **An ordinary administrator left** — remove or re-role them yourself; nothing is lost, because
Portal records belong to the merchant account rather than to the user who happened to be looking
at them. There is nothing to "hand over".
* **The super administrator's own mailbox is gone** — that cannot be reassigned or reset from the
Portal by anyone, including another administrator. Raise it with support with the new contact.
## Can we have more than one administrator?
Yes, and it is worth doing. Roles are assigned per invited user from the role list, so you can hold
an administrator role on more than one user and avoid the situation where a single person's absence
blocks every administrative action. The one row you cannot duplicate or move is the original super
administrator.
What each role can do, and how invitations work, is in
[Merchant Portal Operations](/docs/en/support/portal-operations-faq).
## How do I check who has been signing in?
The user menu at the top right has **Login History**. It lists, for the account, the sign-in email,
the source IP, the timestamp, and whether the attempt **succeeded or failed** — so failed attempts
are visible, not just successful ones.
The drawer is explicitly scoped to the **last 90 days** and has no date picker, so it cannot be
widened from the interface. For a longer period, or for an evidence export, raise it with support.
# Webhook Event Log & Manual Resend
Source: https://waffo.com/docs/en/support/portal-webhook-log-faq
How to inspect webhook delivery in the Merchant Portal: what counts as a successful delivery, why a record can be missing entirely, which Order ID each event shows, and how manual resend interacts with automatic retries.
The **Webhooks** page in the Merchant Portal records every webhook Waffo sent
you and lets you resend failed ones yourself. The callback contract itself lives
in [Webhook overview](/docs/en/developer-docs/webhook/overview) and [Webhook retries](/docs/en/developer-docs/webhook/retry);
URL setup lives in [Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
## How do I check whether a specific webhook was delivered?
Open **Merchant Portal → Webhooks** and filter by event type, status
(`SUCCESS` / `FAILED`), Order ID, or time range. Each record carries:
| Field | What it tells you |
| - | - |
| Event type | Which event was pushed (payment, refund, chargeback, subscription). |
| Status | `SUCCESS` or `FAILED`, derived from your HTTP response. |
| Order ID | The business identifier of the event — see the table further down. |
| Notify URL | The callback URL actually used for this attempt. |
| Triggered at | When the delivery record was created. |
| Next retry | When the next automatic retry is scheduled. It is populated **only** while a record has failed *and* still has retry budget left — so it is empty both for a delivered record and for one whose retries are exhausted. |
| Request body | The full payload Waffo sent, in the detail view. |
| HTTP response | The failure summary your endpoint returned (failed records only). |
The log starts from the point the feature went live; older notifications are not backfilled.
## I just triggered a notification and it is not in the list. Is it lost?
Probably not. A notification still being sent sits in an in-flight state the
list **does not return by default**, so a record can take a few seconds to
appear — deliberately, so an in-flight attempt is never shown as `FAILED`.
## My server returned HTTP 200, so why does Waffo show FAILED?
HTTP 200 alone is not enough. A delivery counts as `SUCCESS` only when your
endpoint returns **HTTP 200 *and* a response body of `{"message":"success"}`**.
Every other outcome — HTTP 200 with a different or empty body, `failed`,
`unknown`, a timeout, an unparseable response — is `FAILED`.
## Can I resend a failed webhook myself?
Yes, in three modes:
| Mode | Use it when |
| - | - |
| Single | You are re-driving one known event. |
| Multi-select | You picked several failed records from the list. |
| By time range | Your endpoint was down for a period and you want to replay the failures in that window. |
A single request resends **at most 50 records**. In time-range mode you can
estimate the window's failed-record count first, then narrow it if it exceeds the limit.
## Does a manual resend go back into the automatic retry queue?
**Yes — and this is the point most easily missed.** A manual resend runs through
the same delivery path as an automatic one: it counts as an attempt against the
record's retry budget, and if it fails, the next automatic retry is rescheduled
as long as budget remains. The next retry time is only cleared once the budget
is exhausted.
The consequence: **a manual resend against a still-broken endpoint spends one of
your remaining attempts.** Verify or fix your endpoint first, then resend. Once
the budget is exhausted the record stays `FAILED` with no next retry time, and
recovering it is your side's job — read it back with
[Order Inquiry](/docs/api-reference/introduction)
rather than waiting for another delivery.
## Why is Resend greyed out on some records?
There are exactly three reasons a record cannot be resent: it has **no callback
URL** stored (there is nothing to send to), it is **still in its first delivery**
(an in-flight attempt is not resendable), or the record does **not belong to your
merchant ID**.
A record that already delivered successfully is **not** in that list — a
successful record *can* be resent, deliberately, because a delivery Waffo
recorded as accepted may still not have been processed on your side.
Resend also requires the webhook-resend permission on your Portal role. Without
it the action and the row selection are not rendered at all rather than shown
disabled, so an operator who sees no resend control anywhere is looking at a
permissions question, not an eligibility question — ask your Portal Admin.
## The Webhooks page has no record at all for one of my transactions
An empty result almost always means **no notification URL was resolved** — not
that delivery failed. With no URL, Waffo sends nothing and writes no log record.
Check where that event type takes its URL from:
| Event | URL source |
| - | - |
| Payment | The `notifyUrl` passed when the order was created. |
| Refund | `refundNotifyUrl` on the refund request takes priority; if empty, the Refund Notify URL configured in the Portal is used and stored on that refund order. |
| Chargeback | Only the Chargeback Notify URL configured in the Portal — it cannot be passed per request. |
| Subscription | The `notifyUrl` held on the subscription object itself. |
A missing chargeback record therefore usually means the Chargeback Notify URL
was never configured; a missing refund record means neither the request
parameter nor the Portal setting was present.
## Which Order ID does the log show, and why can't I find a chargeback by my original order number?
The Order ID column is the event's own identifier, and it differs per event:
| Event | Order ID in the log |
| - | - |
| Payment | `acquiringOrderId` |
| Refund | `acquiringRefundOrderId` |
| Subscription status / period change | `subscriptionId` |
| Chargeback | The dispute reference of the chargeback case |
Chargeback records are listed and searched by the dispute reference, **not** the
original payment order — searching a chargeback notification by your original
order number returns nothing. That number travels as `originalOrderId` inside
the payload, so read it from the request body in the record's detail view.
# Production Public Keys & API Operation Types
Source: https://waffo.com/docs/en/support/production-api-key-and-public-key-faq
Submitting and rotating your production public key yourself: the exact format the field accepts, why the Portal asks you to sign a challenge string, what the three API operation types mean and why Withdraw must stand alone, and how old and new keys coexist during a rotation.
Three pages divide the integration-security surface:
| Question | Page |
| - | - |
| How do I sign a request, verify a response, and what do `A0001` / `A0002` mean? | [Authentication & Signature FAQ](/docs/en/support/auth-signature-faq) |
| How do I set notification URLs and restrict access by IP? | [Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq) |
| How do I submit or rotate a **production** public key, and what is an API operation type? | This page |
## Can I add my production public key myself?
Yes. On **Settings → Integration** there is a **Configure** button above the *Merchant Sign
Configuration Details* table. You submit the key yourself, from the Portal, without emailing
anyone a file.
Self-service submission and Waffo review are not alternatives — you get both. When you confirm,
a dialog says so explicitly: *once submitted, it will enter the review process*. The key is
registered immediately as a new version at `Pending`, and becomes usable when it reaches
`Active`. Your existing key keeps verifying your traffic throughout, so a submission never
interrupts live payments.
| Status | Meaning |
| - | - |
| `Pending` | Submitted, awaiting review |
| `Active` | Approved — verifying your request signatures |
| `Rejected` | Not approved; the reason is recorded against the version |
## What exactly does the public key field accept?
It is strict, and the dialog shows you a live character counter against the required length so
you can tell before submitting:
| Requirement | Value |
| - | - |
| Algorithm | SHA256withRSA |
| Key length | RSA 2048-bit |
| Encoding | Base64, X.509 |
| String length | Exactly **392 characters** |
| Wrappers | None — no `BEGIN PUBLIC KEY` / `END PUBLIC KEY` lines |
| Whitespace | None — no spaces, no line breaks |
The 392 is not advisory. The field validates only once your input is exactly that length, and
the counter turns red above it. What this means practically: **take the one-line Base64 body of
your PEM file and nothing else.** A pasted PEM file will be the wrong length because of its
header, footer and line breaks. The
[go-live checklist](/docs/en/developer-docs/getting-started/go-live) shows how to strip a PEM down
to the value this field wants, and the
[Authentication & Signature FAQ](/docs/en/support/auth-signature-faq) has the `openssl`
commands to generate the pair.
Three distinct rejections come out of validation, and they tell you which of the above failed:
a **length** mismatch, an **illegal character** (something that is not valid Base64 — usually a
stray space or a wrapper line), and an **algorithm** mismatch (a valid Base64 string that is
not a 2048-bit RSA public key at all).
## Why does the Portal make me sign a challenge string?
To prove you hold the private key that pairs with the public key you are uploading. Without it,
anyone able to reach the form could register a key they do not control.
The flow inside the dialog:
1. Paste your public key. It is format-checked first.
2. Once it passes, an **Ownership Verification** section appears with a challenge string of the
form `WAFFO_VERIFY_{your merchant ID}`, with a copy button.
3. Sign that string locally with the **private key** paired to the public key you just pasted,
using SHA256withRSA, and Base64-encode the result.
4. Paste the signature into **Signature Result**.
Confirm stays disabled until all three of an operation type, a valid public key and a signature
are present. The signature is then verified with the public key you submitted — a mismatched
pair is rejected at submission, so you find out immediately rather than after review.
Sign the challenge string **exactly as shown**, with no trailing newline. That is the single
most common cause of a mismatch on a key pair that is in fact correct.
## What are the three API operation types?
An operation type scopes what a key pair — and the API key issued alongside it — is allowed to
be used for. The dialog offers three buttons:
| Button | Scope |
| - | - |
| Payin | Payment, refund and the related query APIs |
| Payout | Payout and transfer APIs |
| Withdraw | Withdrawal APIs |
You can select **Payin and Payout together**, and the Portal registers that as a single
combined scope covering both. You will see it in the table afterwards as a Payin-and-Payout row
rather than as two rows.
## Why can't I combine Withdraw with Payin or Payout?
Because Withdraw is a different class of operation: it moves your own settled funds out, rather
than moving a customer's money in or paying a third party. Waffo keeps it on its own credential
so that a key used by your checkout or payout service can never be used to move your balance
out, and so that compromising one does not imply the other.
The dialog enforces this rather than warning about it: selecting **Withdraw** clears Payin and
Payout, and selecting either of those clears Withdraw. There is no combination that includes
Withdraw with anything else. So if you operate withdrawals through the API, plan on **at least
two credentials** — one for your payments traffic and a separate one for withdrawals — and keep
them in different places in your secret store.
## How does key rotation work without downtime?
The important mechanic: **a submission never edits a key in place.** Each accepted submission
creates a new version, and each version gets its **own API key**. Which public key verifies a
given request is decided by the `X-API-KEY` on that request — one API key, one public key.
That is what makes a clean rotation possible, because more than one version can be `Active` at
the same time under the same operation type:
1. Generate a new key pair. Keep the old one in place and in use.
2. Submit the new public key from the Portal. It appears as a new version at `Pending`. Your
traffic continues to verify against the old key, unaffected.
3. When it reaches `Active`, read its **API key** from the *Merchant Sign Configuration Details*
table.
4. In your integration, switch the `X-API-KEY` **and** the signing private key **together**, as
one change. They are a set; swapping one without the other produces `A0002`.
5. Verify traffic on the new credential, then stop using the old one.
Because both versions are `Active` during step 4, you can cut over service by service, or run a
canary, without a window where neither credential works. And because the two are matched pairs,
a rollback is just switching the header and the private key back.
Do not skip step 3. Reusing your existing API key with a newly submitted public key is the most
common rotation failure — the old API key still points at the old public key, so your new
signatures fail verification while everything looks correctly configured in the Portal.
## Can I submit the same public key twice?
No. A public key that is already registered under the same operation type is rejected rather
than creating a duplicate version. This is a guard against accidentally "rotating" onto the key
you are already using, which would look like a successful rotation and change nothing.
The same key **may** be submitted under a *different* operation type, though a separate pair per
scope is the better practice — it is the point of scoping them.
## Is there a limit on how many keys I can hold?
Yes, there is a cap per operation type, and a submission that would exceed it is refused with a
dedicated error. The count includes versions that were rejected, so abandoned attempts consume
room. Two habits keep you well clear of it: rotate deliberately rather than exploratively, and
fix a rejected submission's cause before resubmitting.
## Where do I see my past submissions and why one was rejected?
**Historical Versions**, below the configuration table on the same page. Each row is one
submission:
| Column | Content |
| - | - |
| Version | `V1`, `V2`, … per operation type |
| API Operation Type | The scope it was submitted for |
| Merchant Public Key | The key, masked, with a copy action |
| Status | `Pending` / `Active` / `Rejected` |
| Submission Time | When you submitted it |
| Reject Reason | The reason, for rejected versions |
| Action | **View Reason** on a rejected version, opening the full text |
Rejection is not terminal and there is no cap on attempts: read the reason, fix it, and submit
again as a new version. The list is paginated, newest first, so a long rotation history stays
readable.
## Who is told when a key is activated or rejected?
Your account's **Super Admin** users receive an email either way. An activation email confirms
the operation type and the activated key in masked form; a rejection email carries the reason.
Plan for this if the person who submits keys is not the person who holds the Super Admin
mailbox — the submitter learns the outcome from the Portal, and the email lands elsewhere. Check
Historical Versions rather than waiting on a forwarded email.
## Do I need to configure a public key for sandbox?
No, and you cannot. The Configure button and Historical Versions appear only outside sandbox —
in sandbox, Waffo generates the pair for you when the sandbox is provisioned, and the sandbox
key material is shown to you directly on the Integration page so you can start signing
immediately.
Two consequences when you go live:
* **Do not reuse sandbox key material in production.** Generate a fresh pair; the production
public key must be one you submitted and whose private key only you hold.
* **Switch the API domain and the credential together.** Each environment has its own API
address and its own API key; the address is shown on the Integration page for the environment
you are in.
## Why is my production public key masked, and my sandbox private key not?
Because they are not the same kind of secret. In production the page masks key material by
default with a show/hide control, so opening the Integration page in a shared screen or a
recorded call does not expose it. A copy action is always available without unmasking, so you
never need to reveal a value merely to move it into your secret store.
Your **production private key is never on this page** and never should be — Waffo does not hold
it. If you have lost it, no Portal action recovers it: generate a new pair and rotate as above.
## What is in the Merchant Sign Configuration Details table?
One row per operation type you hold, tying the credential to the URLs it uses:
| Column | What it is |
| - | - |
| API Operation Type | The scope of this row |
| Merchant Public Key | The public key currently verifying your requests for this scope, masked |
| API Key | The `X-API-KEY` to send with requests in this scope, masked |
| Refund Notify URL | With an **Edit** action |
| Chargeback Notify URL | With an **Edit** action |
| Tokenization Notify URL | With an **Edit** action |
The URL columns are configuration rather than key material, and they change with no review —
see [Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq) for
their validation rules and scope. If you hold a client-side credential for card collection, it
appears in this table as its own row.
***
Signature still failing after a rotation? Work through the `A0002` checklist in the
[Authentication & Signature FAQ](/docs/en/support/auth-signature-faq) — in a
post-rotation failure the cause is almost always an API key and private key that no longer
belong to each other.
# What Has to Happen Before I Can Take Live Payments?
Source: https://waffo.com/docs/en/support/production-go-live-faq
The whole path to production in one place: which stages gate live payments, what actually changes when you switch from the sandbox, what integration acceptance asks for and when you can use the shorter template, what technical preparation is on your side, and how adding a country or a payment method works once you are already live.
This page is the map. Each stage has a page of its own; what was missing was something that says
which stages exist, in what order, and which of them you drive yourself.
## Which stages gate live payments?
Four, and they are sequential — the Portal's Home page shows the first three as cards that unlock in
order:
| Stage | Who drives it | Where it is covered |
| - | - | - |
| 1. Account verification | You | [Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq) |
| 2. Review of what you submitted | Waffo | Same page — including what a returned application means |
| 3. Payment enrollment — your payment methods being registered | Waffo, per payment method | [Payment Enrollment](/docs/en/support/payment-enrollment-faq) |
| 4. Integration acceptance | You, confirmed by Waffo | Published [Acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria) |
Two things follow from the ordering:
* **Payment enrollment cannot start before your review is approved.** The Home card stays locked and
says so. Chasing enrollment while verification is outstanding does not move it.
* **Integration acceptance runs in the sandbox**, so you can do all of stage 4 while stage 3 is
still in flight. You are not idle waiting.
Passing verification is not the same as being able to take payments — that distinction, and how to
read each payment method's enrollment state, is the first section of
[Payment Enrollment](/docs/en/support/payment-enrollment-faq).
## What actually changes when I switch to production?
| Thing | Sandbox | Production |
| - | - | - |
| API domain | The sandbox domain | The production domain |
| SDK environment | `Environment.SANDBOX` | `Environment.PRODUCTION` |
| API key | A sandbox key | A **separate** production key |
| Signing keys | A pre-generated pair, private half visible in the Portal | A pair **you** generate; the private half is never shown to you, and never sent to Waffo |
| Payment methods | Whatever your sandbox configuration enables | Whatever your payin agreements say — a different list |
| Limits and refund rules | Sandbox configuration | Your agreement values — read them fresh |
| Payment behaviour | Simulator buttons, no real channel | Real payment methods, real money |
| Merchant ID | Its own sandbox merchant ID | A different one — the Integration page names the one you are signed in to |
How the two environments are isolated from each other, and everything else that is sandbox-specific,
is in [Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq).
The pairs to be deliberate about are the two key rows. **Do not carry sandbox credentials or a
sandbox key pair into production** — they are not interchangeable, and a key from the wrong
environment fails as an invalid key rather than as a permission problem. The full item-by-item
checklist, including the commands to generate a production key pair, is the published
[Go-live checklist](/docs/en/developer-docs/getting-started/go-live); why it fails the way it does is in
[When my order is rejected](/docs/en/support/order-rejection-self-diagnosis-faq).
## What technical preparation is expected on my side?
The published [Go-live checklist](/docs/en/developer-docs/getting-started/go-live) is the authority, and
it covers four things worth naming here so you can plan them into a sprint rather than discover them
on cutover day:
* **Timeouts** — a recommended call timeout, with a floor.
* **DNS** — a recommended TTL, and not pinning addresses.
* **Logging and alerting** — on order creation, order inquiry and webhook handling specifically,
because those are the three places a go-live problem shows up first.
* **Where you deploy** — a recommendation about region and latency, with an extra step if you deploy
in mainland China.
If you integrate inside an app, there is a fifth group about what your WebView must allow. Several
payment methods depend on launching an external app, downloading a QR image, or preserving query
parameters across a redirect — see the published
[Payment method integration notes](/docs/en/developer-docs/tools-and-references/references/payment-method-integration-notes).
## What does integration acceptance ask for?
You run a set of cases in the sandbox, record the results in a template, and submit the template to
your Waffo technical support group, which confirms it. What is checked: the payment success and
failure paths, webhook handling **and webhook idempotency**, refunds and subscriptions if you
integrated them, your handling of the exception scenarios, parameter quality, and whether you
persist the right identifiers.
Two practical points people miss:
* **Some cases are reproduced with reserved sandbox amounts.** They return the matching error on
purpose. Use the published list rather than inventing amounts — see
[Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator).
* **You do not have to test every payment method.** Coverage is by representative: at least one
card, at least one method per country per type, plus any method that needs a special parameter,
and the wallet-native methods tested on a real device.
## I have integrated before and I am only adding one payment method. Do I repeat the whole thing?
No — there is a **shorter template for a subsequent one-time-payment integration**, and it exists for
exactly this case. Use it only if you have already passed acceptance at least once; a first
integration uses the full template.
It asks you to state the scope up front — your merchant ID, the contracted product, the payment
methods and the APIs this integration covers — and then to run the cases that are actually in
scope: order payment success and failure, payment notifications, cancellation, refunds and refund
notifications, with cancellation and refund only if this integration includes them. Both templates
and the selection rule are on the published
[Acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria) page.
## I already run one-time payments and now want subscriptions. Is that a second go-live?
Not a second account, but it is a second integration, and it has its own acceptance path: there is a
separate subscription acceptance template alongside the one-time-payment one, with its own cases —
first-period payment activating the subscription, a simulated renewal, and cancellation.
Two consequences:
* The two products are separate on your agreements, so a payment method contracted for one-time
payments is not automatically contracted for subscriptions. Check the `Product` column on Service
→ Payin, and the product field in the payment-method configuration response.
* The sandbox lets you simulate renewals without waiting for a real billing cycle, which is how you
run the renewal cases — see [Sandbox Testing](/docs/en/support/sandbox-testing-faq).
Everything about the subscription contract itself — cycles, retries, notifications — starts at
[Subscription FAQ](/docs/en/support/subscription-faq).
## What should I watch on the first day of live traffic?
Four things, and all four are readable by you without asking anyone:
| Watch | Where |
| - | - |
| Are webhooks reaching you, and being acknowledged? | The Portal's webhook log — see [Webhook Log](/docs/en/support/portal-webhook-log-faq) |
| Are orders being rejected, and on what? | The error code, then [When my order is rejected](/docs/en/support/order-rejection-self-diagnosis-faq) |
| Are payments failing after creation, and why? | The failure code and description on the transaction record — see [Transaction Field Reference](/docs/en/support/transaction-field-reference-faq) |
| Do your own alerts fire on order creation, inquiry and webhook handling? | Your side — this is the logging item on the published checklist |
The one habit worth forming on day one: **treat the webhook or an inquiry as the authority on a
payment result, never the customer's redirect back to your site.** Why, and what the redirect does
and does not prove, is in [Payment Results](/docs/en/support/payment-results-faq).
## Can I keep testing after I am live?
Yes. The sandbox does not go away, and it remains the place to reproduce a problem without moving
real money. Keep the two sets of credentials clearly separated in your configuration — the fastest
self-check on which environment a credential belongs to is in
[What can I change myself?](/docs/en/support/self-service-boundaries-faq).
## Once I am live, how do I add a country or a payment method?
The mechanism is the same one that got you your first methods: an agreement row is created on
Waffo's side, and it then appears on **Service → Payin** and in the payment-method configuration
response. You do not reconfigure anything and there is no downtime — existing rows are untouched, and
whether your integration has to change at all depends on whether you name payment methods
explicitly. That, and the country-versus-region question, are in
[Currency & Method Enablement](/docs/en/support/currency-and-method-enablement-faq).
Where a new method needs its own registration, its state is visible per method in the payment
enrollment detail — the same view you used before your first go-live.
## Where do I look next?
| Question | Page |
| - | - |
| The item-by-item pre-production checklist | Published [Go-live checklist](/docs/en/developer-docs/getting-started/go-live) |
| What each stage of the sign-up path asks for | [Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq) |
| What my payment methods' enrollment states mean | [Payment Enrollment](/docs/en/support/payment-enrollment-faq) |
| What I am contracted for, with limits and refund rules | [Payin Agreements & Transaction Limits](/docs/en/support/payin-agreements-and-limits-faq) |
| Which settings are mine to change | [What can I change myself?](/docs/en/support/self-service-boundaries-faq) |
| How to test each scenario in the sandbox | [Sandbox Testing](/docs/en/support/sandbox-testing-faq) |
# Receipt Configuration & Download
Source: https://waffo.com/docs/en/support/receipt-configuration-and-download-faq
How the customer-facing receipt works end to end: what the customer chooses when downloading (language, company name for expense claims, and why it can only be set once), what you can configure in the Portal, whether a configuration change affects past transactions, receipt URLs, and the transactions your configuration cannot touch.
This page covers the receipt as a document: what the customer sees when they
download it, and what you control about its content. Viewing and emailing a
receipt from a transaction, and the delivery statuses of that email, are
covered by the Receipts: View, Send & Configure page.
## What does the customer choose when they download a receipt?
Whichever entry point they arrive from, they land on the same receipt page and
get the same dialog:
| Choice | Behavior |
| - | - |
| **Language** | English is always offered and preselected. If the order's own language is not English, it is offered as a second option and is also preselected. At least one language must stay selected |
| **Company name** | Optional free text per selected language — the name that should appear on the receipt, typically for an expense claim |
Selecting both languages downloads **both receipts** in one action. The company
name is entered per language, because a company's legal name in English and in
the local language are usually not the same string.
The company-name field only appears for receipts that carry that block. If the
customer does not see it, that transaction's receipt does not support it.
## Why can't the customer correct the company name they typed?
Because a company name is **locked once submitted, per language**. It is
deliberate: the receipt is an accounting document, and letting the same
transaction produce two receipts with two different bill-to names is exactly
the ambiguity a receipt is supposed to remove.
The rules in full:
| Action | Result |
| - | - |
| First submission for a language | Accepted and stored |
| Same language, **same** value again | Succeeds, nothing changes — safe to retry |
| Same language, **different** value | Rejected: `008001B115` — "Receipt company name has already been submitted and cannot be changed. Please download the receipt with the submitted name." |
| A **different** language, first time | Accepted — languages are independent |
Once a language has a stored name, the input is no longer offered for it: the
download simply uses the stored value.
The name is bound to **that order and that language**. A new order starts
clean, and nothing is inherited from a previous purchase by the same customer —
so a customer who needs a corrected name needs a new transaction, not a new
download.
Validation on the value:
| Rule | Limit |
| - | - |
| Maximum length | **200 characters** |
| Leading/trailing spaces | Trimmed before storing |
| Disallowed | `<` and `>`, and control characters |
## The receipt link my customer has does not open. What are the valid link shapes?
Two forms exist and both are live:
| Shape | What it is |
| - | - |
| `/receipts/form?orderKey=…&locale=…` | The intermediate receipt page — the language and company-name dialog, then the download |
| `/receipts/{orderKey}.pdf?locale=…&tz=…` | The receipt document itself |
The intermediate page refuses to open in three cases, and the message says
which: the order key is missing, the order is not in a successful state, or the
transaction has no receipt. `008001B116` — "Receipt link expired" — means the
link is past its window; issue a fresh one from the Portal or from the order
inquiry response.
## What can I configure about the receipt content?
In the Merchant Portal, open **Settings → Receipt Configuration**. Configurable
blocks:
| Block | Fields |
| - | - |
| **Receipt header** | Document title, merchant display name, merchant logo |
| **Billed to (customer info)** | Show customer name (on/off), show customer email (on/off) — the values themselves come from the transaction and cannot be edited |
| **From (service entity)** | Entity name, tax ID, address |
| **Footer & support** | Support copy, support website |
Field rules:
| Field | Rule |
| - | - |
| Document title | Up to **50 characters**. `<` and `>` are rejected. Empty falls back to `Receipt`. This is how you get `Tax Invoice`, `Invoice`, `Order Confirmation` and so on |
| Every other text field | Up to **128 characters**. Empty fields are **hidden** on the receipt rather than rendered blank |
| Support website | Inserted into the support copy at a `{website}` placeholder |
| Merchant logo | Leave empty to hide the logo |
| **Receipt Enabled** | Per template: whether transactions using it produce a receipt at all |
The fee row and subscription information are rendered by the system when they
apply to the transaction. You can neither add nor suppress them.
Editing supports **Reset** on a single field and **Restore all defaults** for
the whole template; both still require **Save** to take effect.
## If I change the configuration, does it change receipts for past transactions?
**Yes — and this is the one thing to internalize before you save.** There is no
per-transaction snapshot of the configuration. The Portal states it in two
places, and the confirmation dialog spells it out: the change takes effect
immediately and affects the receipt display for **all historical and future
transactions**.
Practically: a customer who downloaded a receipt last month and downloads it
again after your change gets a document with the new title, entity details and
logo. If a past receipt must stay exactly as issued, you need to keep your own
copy of it — Waffo does not version it for you.
## Can I have more than one receipt layout?
Yes, as **templates**:
| Rule | Detail |
| - | - |
| Multiple templates | Each appears as its own tab, named `Template 1`, `Template 2`, … |
| Exactly one default | The default is tagged, and it **cannot be deleted** |
| Adding a template | **Add Template** copies the current default in full, then you edit the copy |
| Changing the default | Behind a confirmation. Transactions without a specific template use the default |
| Deleting a template | Behind a confirmation. Transactions that were using it fall back to the **default** template |
| Selecting per order | Pass `paymentInfo.receiptTemplateId` at order create |
Editing the default does **not** propagate to the other templates — they were
copies, not references.
If you have never configured anything, the page opens on a single prefilled
template with receipts not yet enabled.
## Do I need Waffo to review my receipt configuration?
No. It is self-serve and takes effect on save, with no approval step. Two
things do get in the way of a save:
| Rejection | Meaning |
| - | - |
| Version conflict (`008001B126`) | Someone else saved the same configuration while you were editing. Refresh and reapply — the later save is refused rather than silently overwriting |
| Title errors (`008001B129` / `008001B130`) | Title too long, or contains disallowed characters |
| `008001B128` | Attempt to delete the default template |
| `008001B127` | Template no longer exists |
Every change is recorded. The page's **Change history** shows who changed what
and when, per template, newest first, including create / update / set-default /
delete events, and the card above the form shows the last operator and
timestamp.
## Is there anything my configuration cannot change?
Yes, and this is a hard boundary rather than a setting.
Transactions that already produce a receipt under Waffo's own rules — receipts
issued for compliance reasons, and receipts issued because Waffo is the party
of record for the payment — **always** follow those rules. Your configuration
does not apply to them, and you cannot switch those receipts off with the
**Receipt Enabled** toggle. The Portal states this on the configuration page
itself.
Your configuration governs the other direction: transactions that would not
otherwise produce a receipt. That is what enabling a template does.
## Why does a transaction have no receipt at all?
A receipt can only exist when all of the following hold: the payment reached a
**successful** state, it went through a Waffo checkout session, and receipts
apply to that transaction — either by Waffo's own rules or because you enabled
a template. When none of that holds, the transaction detail page hides the
receipt block entirely, and an email request for that order is refused with
`RECEIPT_NOT_ENABLED`.
## What language is the receipt document in?
The receipt page always offers **English**, plus the **order's own language**
when that is not English. That language is determined by the system from the
order, not configured by you and not chosen per download beyond picking from
the offered options. Currently supported receipt languages: English, Português,
Español, 繁體中文, 简体中文, Tiếng Việt, ไทย, Bahasa Indonesia, 日本語, 한국어,
Bahasa Melayu, Polski, Türkçe.
## The quantity on the receipt says 1, but the customer bought three items. Why?
Because the receipt only shows a real quantity when the order explicitly
carries one greater than 1. If the order carries no quantity, carries a value
that cannot be parsed, or carries 1 or less, the receipt shows **1**.
If quantity matters on your receipts, send it on the order — there is no way to
set it afterwards, and no configuration switch for it.
## Can I tell which template produced a given receipt?
Yes. Order and subscription payment information report the applied
configuration as `receiptTemplateId`, and the configuration page shows the
template ID on each tab. That pairing is what to quote when a receipt does not
look the way you expected: it tells you whether the transaction used the
template you think it did, or fell back to the default.
## Can I preview a receipt before saving?
Yes. The configuration page renders a live A4 preview beside the form, driven
by fixed sample transaction data with switches for product type
(one-time / subscription), whether a fee row is present, and card versus other
payment method. The preview is sample data only: it does not mean the
configuration is live, and it never sends an email.
***
Receipt content wrong for one specific transaction rather than all of them?
[Contact support](mailto:support@waffo.com) with the order ID and the template
ID shown on the configuration tab.
# Refund FAQ
Source: https://waffo.com/docs/en/support/refund-faq
Answers to the most common questions about Waffo refunds: multiple partial refunds, refund preconditions, idempotent retries, cross-currency amounts and rounding, refund vs cancel, processing time and payment method limitations.
This page answers the questions merchants ask most often about refunds.
Field-level contracts live in the Acquiring API Specification
(`/api/v1/order/refund`, `/api/v1/refund/inquiry`, Refund order notification).
## Can I refund the same order more than once?
It depends on the payment method and your contract configuration:
* **Multiple partial refunds supported** — each refund is validated so that
*refunded + in-progress + current request ≤ original payment amount*. A
single order allows up to **50 refunds** in production; exceeding this returns
error `A0027`. Sandbox and test environments use a lower cap, so hitting
`A0027` there after only a few refunds is expected, not a bug. Every refund response includes `remainingRefundAmount` so you can
track what is still refundable.
* **Not supported** — once one refund has completed for the order, any
further refund request is rejected.
Each successful partial refund sets the order's **refund status**
(`refundStatus`) to `ORDER_PARTIALLY_REFUNDED`; it only becomes
`ORDER_FULLY_REFUNDED` when the total successfully refunded amount equals the
order amount. The order's `orderStatus` itself is **not** changed by a refund —
a fully refunded order still reports `PAY_SUCCESS`.
## Why was my refund rejected?
A refund request must pass all of the following preconditions:
| Precondition | Detail |
| - | - |
| Order status | The original payment must be successful. |
| Contract supports refunds | Refund capability is a contract setting; requests against a non-refundable contract fail (e.g. `A0014`). |
| Refund validity window | Refunds are only accepted within a contract-defined period counted from the **order's creation date** (not from payment success), ending at the end of the last day. Check `refundExpiryAt` in the order inquiry response — it is `null` when refunds are not allowed. |
| Sufficient funds | Waffo first draws the refund from your pending-settlement balance (available balance including any overdraft allowance). If that is not enough it falls back to your refund reserve account. Only when neither can cover the amount is the request rejected with `A0012`. |
| No open dispute | An order under chargeback dispute is locked against refunds (`A0019`). An order already refunded by the chargeback alert service returns `A0039`. |
Validity windows and multi-refund support are
contract-dependent — check your contract in the Merchant Portal or contact
integration support for your exact configuration.
## My refund request timed out. Can I safely resend it?
Yes. `refundRequestId` is the idempotency key:
* Resending with the **same ID and same amount** returns the current refund
status; if the refund is still in an intermediate state, processing is
pushed forward.
* Resending with the **same ID but a different amount** is rejected.
To retry safely, always resend the identical request. Use a new
`refundRequestId` only when you intend to create an additional refund.
## How are cross-currency refund amounts calculated?
When the user paid in a currency different from your order currency, the
refund amount you submit (in order currency) is converted to the user's
currency. Two rules apply:
* **Round down** — converted refund amounts are always rounded down, so the
total refunded never exceeds what the user actually paid.
* **Last refund tops up** — with multiple partial refunds, FX rounding could
leave the refunded total slightly below the payment amount. The final
refund that exhausts the remaining balance returns the full remainder, so
a fully refunded order truly reaches `ORDER_FULLY_REFUNDED`.
The `finalDealAmount` in the refund inquiry response is the actual amount
the user receives in their currency.
## I cancelled the order, but the user says they already paid
Cancellation only works on orders still in an intermediate state. If the
user has in fact completed the payment, the cancel request fails and the
order proceeds as a successful payment — you receive a payment success
notification. To return the funds at that point, use the refund API; do not
retry the cancel.
## How long does a refund take? The status stays in progress
Refunds are **asynchronous**. A successful `/api/v1/order/refund` call means
the request was *accepted* (`REFUND_IN_PROGRESS`), not completed. Waffo then
processes the refund with the payment method and automatically retries
recoverable failures until a final state is reached.
* Provide `refundNotifyUrl` to receive the final result via the refund
order notification, or poll `/api/v1/refund/inquiry`.
* A refund is final only at `ORDER_PARTIALLY_REFUNDED`,
`ORDER_FULLY_REFUNDED` or `ORDER_REFUND_FAILED` — treat
`REFUND_IN_PROGRESS` as pending, not failed.
## Do all payment methods support API refunds?
No, and there are two distinct cases.
**Refunds not permitted.** Two codes, two meanings. `A0017` ("payment channel
not supported") means the payment method itself cannot carry a refund — full or
partial. `A0014` ("refund rules") means the method can refund but your
agreement's refund shape does not allow *this* request: a partial refund on a
full-refund-only row, or a second refund where only one is permitted. Asking for
more than the remaining refundable amount is a third thing again — a plain
`A0003` parameter failure. See
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq).
**Refunds needing extra fields.** Some refunds require additional user
details, such as bank account information, because the money is returned by
transfer rather than reversed on the original payment. **Which fields are
required is configured per channel, so do not decide it from the payment
method in your own code.** Submit the refund; if fields are missing the API
returns `A0003` together with the list of missing field names, and you resend
with those fields filled in.
For some payment methods refunds are executed over payout rails on Waffo's
side — your API contract stays the same.
## Can I submit many refunds at once?
Yes — the **Batch Refunds** page under the Portal's Transaction module. It is
enabled per merchant account, so if the page is not there it has not been
switched on for you; ask support. The template and upload buttons are visible
only to the roles that can already initiate a single refund.
The flow is: export the batch refund template (CSV), fill it in, upload it,
confirm the summary dialog (record count, total amount, currency), and Waffo
creates a **Refund Batch ID** in the form `BRP` + MID + `YYYYMMDD` + a 6-digit
sequence — for example `BRP20260225000001`.
| Template column | Required |
| - | - |
| Original Order Id | Yes |
| Refund Amount | Yes |
| Refund Currency | Yes |
| Refund Reason | No |
## My whole batch file was rejected. Why?
Upload validation is all-or-nothing: a single bad row rejects the whole file,
and you correct it and upload again. What is checked:
* **The file** — must be the exported template in CSV, with exactly its four
columns and no extra ones; not corrupted, encrypted or empty; at most
**2,000 records** and **2 MB**; and not a file already uploaded before
(duplicates are detected from the file content, not the name).
* **Currency** — one refund currency for the entire file, and it must equal
each original order's currency.
* **Each row** — the original order exists, is paid and not yet fully
refunded, its payment method allows refunds, it is still inside the refund
window, the amount is above zero and no more than the remaining refundable
amount, and the order has no refund already in progress.
* **Duplicates** — the same order may not appear twice in one batch.
* **Batch total** — the file's total must be covered by your
pending-settlement balance plus your refund reserve balance at upload time.
The per-row rules are the same preconditions a single refund passes; see
"Why was my refund rejected?" above for what each one means.
## How do I track a batch after submitting it?
Rows are processed independently: a failing row is marked with its reason and
the rest continue — one failure does not stop the batch.
| Batch status | Meaning |
| - | - |
| `INIT` | Batch created, processing not started |
| `PROCESSING` | At least one row still in progress |
| `PARTIAL_SUCCESS` | Some rows succeeded, some failed |
| `FULL_SUCCESS` | Every row succeeded |
| `FAILED` | Every row failed |
The batch list shows total / success / failed / in-progress counts, the batch
amount and currency, and the creation and update times. Every refund carries
its Refund Batch ID, so you can filter the refund list by batch, open any
individual refund's detail as usual, and export the batch result including
failure reasons. There is no retry action on a batch — resubmit the failed
rows in a new file, or refund them one at a time.
## Do I get the original payment fee back on a partial refund?
The default is **refund without fee return**. If your contract includes a
returned fee, a refund returns the matching share of the original payment fee,
rounded **down** to 2 decimals:
```
Returned fee = ROUNDDOWN2( refund amount / original order amount × original fee amount )
```
A partial refund returns the pro-rata share, a full refund returns all of it.
Because the rule rounds down, the returned fee can land a cent below the figure
you get from straight pro-rata arithmetic. It appears as a positive component
of the refund row's Settlement Amount — see
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
# Where Refund Money Comes From
Source: https://waffo.com/docs/en/support/refund-funding-source-faq
Which of your Waffo balances a refund is actually drawn from, in what order, why a refund can fail with insufficient balance right after a settlement paid out, how a refund reserve account prevents that, and why none of this changes the refund API.
A refund moves money **out** of your Waffo balances, so it can fail for a reason
that has nothing to do with the original order: there is nothing to take it from.
This page explains which balance is used, in what order, and what to do when a
refund is rejected for funds.
For everything else about refunds — partial refunds, retries, cross-currency
amounts, fee returns — see [Refund FAQ](/docs/en/support/refund-faq). For what each
balance is, see
[Balances & Account History](/docs/en/support/balance-and-account-history-faq).
## Which balance is a refund taken from?
In this order. The first source that can cover the refund is used, and the search
stops there:
| Order | Source | Condition |
| - | - | - |
| 1 | **Pending-settlement balance** | Its available balance **including any overdraft allowance on that account** covers the refund |
| 2 | **Refund reserve account** | You have an active refund reserve arrangement for that merchant account, entity and currency, **and** that account's available balance including its own overdraft allowance covers the refund |
| — | Neither | The refund is rejected with `A0012` — *Insufficient merchant account balance* |
Three details that decide most real cases:
* **Overdraft is part of step 1, not a step of its own.** Your pending-settlement
account carries an overdraft allowance, and the check at step 1 already counts
it. So a refund can succeed and push that balance negative — that is the
designed behaviour, and the negative is cleared by your next incoming
transactions.
* **The refund reserve account is a fallback, not the preferred source.** It is
only consulted once the pending-settlement balance and its overdraft allowance
are both exhausted.
* **The refund reserve step is gated on an active arrangement.** With no active
refund reserve arrangement for that currency, there is no step 2 — the refund
fails as soon as step 1 cannot cover it.
## Which currency's balance is checked?
The one the refund is in. Balance checks are **per currency**, against the
account held in that currency for your merchant account and legal entity.
This matters if you transact in several currencies: a healthy balance in one
currency does nothing for a refund in another. Funds are not swept between
currencies to cover a refund, and there is no cross-currency fallback in the
selection above — a refund in a currency whose balance is empty fails even
while your other currencies are well funded.
The same applies to the refund reserve account: the active-arrangement check is
made for that specific currency, so a refund reserve arrangement covering one
settlement currency does not extend to another.
## Why did a refund fail with insufficient balance the day after a settlement?
Because settlement empties the balance refunds are drawn from.
Your funds sit in the pending-settlement balance until their settlement date,
then move out to your settlement account and on to your bank. Once a settlement
has run, the pending-settlement balance can be at or near **zero** — and a refund
requested at that moment has nothing to draw on beyond the overdraft allowance.
This is why refund failures cluster right after a settlement payout, and why they
look inexplicable when the original order settled successfully weeks ago. The
order is fine; the balance is empty.
Your options:
| Option | Effect |
| - | - |
| Wait for new transactions | Incoming payments top the pending-settlement balance back up, and the refund then succeeds on retry |
| Top up the pending-settlement balance | The classic fix — but it means a funding transfer every time the timing works out badly |
| Set up a **refund reserve account** | Pre-fund an account dedicated to refunds, and step 2 above covers you without any per-incident action |
A refund reserve account exists precisely to remove the wait-or-top-up choice: it
is prepaid, it is only used for refunds, and it carries its own overdraft
allowance. Talk to your Waffo account manager about whether one makes sense for
your refund volume.
## A refund failed for funds. Can I just retry it?
Yes. An insufficient-funds rejection happens **before the refund order is
created**, as a pre-flight check — so nothing was booked, nothing is half-done,
and there is no state to clean up. The original order is untouched and its
refundable amount is unchanged.
Retrying is therefore safe, and it is the normal resolution: retry once funds are
available. Follow the same idempotency practice you would for any refund retry so
that a retry after an ambiguous response cannot double-refund — see
[Refund FAQ](/docs/en/support/refund-faq).
What a retry will **not** do is queue. There is no pending state for a refund that
could not be funded: it is rejected outright, and nothing happens automatically
when your balance later recovers. If you need the refund to go out as soon as funds
arrive, you have to re-issue it yourself.
## Does an overdraft allowance mean a refund can never fail?
No. An overdraft allowance is a limit, not an absence of one.
The check at each step is *balance plus that account's overdraft allowance*
against the refund amount — so a refund larger than that combined figure still
fails, and it fails the same way, with `A0012`. What the allowance buys you is
tolerance for the common case of a balance that has just been emptied by
settlement; it is not an unlimited line.
Two consequences:
* A large refund can fail while smaller ones on the same account succeed. The
check is per refund, against the full amount — refunds are not partially funded
from one source and topped up from another.
* Once the pending-settlement balance is negative, the remaining allowance is
smaller, so subsequent refunds have less room. A burst of refunds against an
empty balance will start failing part-way through the burst rather than all at
once.
## Does any of this change my settlement or withdrawal?
Not directly, but a negative pending-settlement balance has a knock-on effect
worth knowing about.
* **Settlement**: refunds are netted into your settlement calculation as `REFUND`
transactions regardless of which account funded them. If a period's refunds
outweigh its payments, the period nets negative and carries forward — see
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
* **Withdrawal**: withdrawals draw on your settlement account, not on the
pending-settlement balance, so a refund does not directly reduce what you can
withdraw today. It reduces what will settle into that account later.
## Can I choose which balance a refund comes from?
No, and you do not need to. The selection happens entirely server-side.
* The refund API's **request fields are unchanged** — there is no parameter for
the funding source, and nothing to add to your integration.
* The refund API's **response fields are unchanged** — the chosen source is not
returned, in the create-refund response or in a refund inquiry.
* The behaviour is the same whether you refund through the API, through a batch
refund file, or from the Merchant Portal.
The practical consequence: **if you already integrated refunds, a refund reserve
account requires no code change from you at all.** It changes where the money
comes from, not how you ask for it.
One field that *is* on the refund API and is easy to mistake for this:
`refundSource` describes **who initiated** the refund (you, or one of the
dispute-prevention services that can auto-refund on your behalf). It has nothing
to do with which balance funds it.
Where the funding source does become visible is after the fact, in your
reconciliation data: the settlement and ledger records distinguish a refund
funded from the pending-settlement balance from one funded out of the refund
reserve account, so you can tell them apart when you reconcile.
## Does a batch refund check balances differently?
No — a batch refund runs the same check, per row, before the refund order is
created. A row that cannot be funded is rejected with the same insufficient-funds
condition rather than being queued for later.
The consequence for batch planning is that you should size a batch against your
**pending-settlement balance plus overdraft allowance plus refund reserve balance
at the time of upload**, not against the total value of the orders you are
refunding. See [Refund FAQ](/docs/en/support/refund-faq) for how a rejected row
affects the rest of the batch.
## Can a refund be taken out of my contractual reserve?
The **rolling or fixed reserve held under your contract** is a different thing
from a refund reserve account, despite the similar name:
| | Refund reserve account | Contractual reserve (rolling / fixed) |
| - | - | - |
| Purpose | Pre-funded source for refunds | Risk cover held back under your contract |
| Who puts money in | You, by topping it up | Withheld automatically from each settlement |
| Comes out automatically | Yes, as refund fallback | No |
| Where you see it | Refund reconciliation records | Portal **Reserve Account** module |
See [Reserve Accounts](/docs/en/support/reserve-faq) for the contractual reserve. Note
that the two are asked about interchangeably in support tickets — if you are
being told "your reserve can/cannot fund refunds", establish which of the two is
meant first.
## Where does a chargeback take its money from?
A chargeback and its chargeback fee are debited **at settlement time, against
your pending-settlement balance**, and both travel through the same path — the
fee is not funded differently from the amount.
Because that balance is overdraftable, a chargeback is not blocked by an empty
balance the way a refund is: it is allowed to push the balance negative, and the
negative is cleared by later incoming funds. If the whole settlement period nets
out negative, no funds move that day and the amount carries forward — see
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
The contractual reserve is not drawn on for a chargeback either.
# Refund Records, IDs, Notifications and Timing
Source: https://waffo.com/docs/en/support/refund-records-and-notifications-faq
Every identifier a refund carries and which one to quote to whom, why some refunds ask you for the customer's bank account and name, what to do when you typed those details wrong, when the refund webhook fires and where it goes, how long a refund typically takes to reach the customer, and whether a customer can start a refund themselves.
Whether a refund is *allowed* is covered elsewhere; this page is about the refund
**record** — its identifiers, the extra details some refunds ask you for, how you
are told the outcome, and what to tell a customer who is waiting.
| Page | Use it for |
| - | - |
| [Refund FAQ](/docs/en/support/refund-faq) | The core rules, cross-currency amounts, retrying safely |
| [Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq) | How many, how much, by when, and per-method differences |
| [Where Refund Money Comes From](/docs/en/support/refund-funding-source-faq) | Balances, insufficient funds, settlement impact |
| [Batch Refunds](/docs/en/support/batch-refund-faq) | The CSV flow and batch tracking |
| **This page** | IDs, extra payee fields, notifications, timing, exports |
## Which identifiers does a refund carry, and which do I quote to whom?
A refund carries up to six identifiers, three of which are yours and three of
which are Waffo's:
| Field | Who generates it | What it identifies |
| - | - | - |
| `refundRequestId` | **You** (max 32) | This refund **request**. It is the idempotency key — reuse it to retry the same refund safely |
| `merchantRefundOrderId` | **You** (max 64) | Your own refund order number, for your books |
| `acquiringRefundOrderId` | **Waffo** | The refund order. **This is the one to quote to Waffo support** |
| `acquiringOrderId` | **Waffo** | The **original payment** being refunded |
| `origPaymentRequestId` | **You**, on the original payment | Your request id for the original payment — how a refund is tied back to your own payment record |
| Refund Batch ID | **Waffo** | Present only for refunds created from a batch file — see [Batch Refunds](/docs/en/support/batch-refund-faq) |
Choosing between them:
* **Talking to Waffo support** → `acquiringRefundOrderId`, plus
`acquiringOrderId` if the question involves the original payment.
* **Reconciling your own books** → `merchantRefundOrderId` and
`origPaymentRequestId`. These are the only two that mean anything inside your
system.
* **Retrying a refund** → `refundRequestId`, unchanged. A new one creates a new
refund; that is the whole point of the field.
* **Talking to the customer** → none of the above. The customer sees the
*original payment's* reference in their own payment history, which comes back
on the original order as `payMethodRefId` inside `payMethodResponse`. If you
need to point a third party at the money, that is the reference they will
recognise — see
[Which Amount and Currency Fields Do I Check?](/docs/en/support/order-amount-and-currency-fields-faq).
Both `merchantRefundOrderId` and `refundRequestId` are visible as columns in
Portal → Transactions → Refunds, so a refund raised through the API can be found
in the Portal by your own number, and one raised in the Portal can be found by
its Waffo number.
## Why does a refund ask me for the customer's bank account, name or nationality?
Because for some payment methods the money cannot simply be pushed back along the
path it came — the refund is executed as a payout to the customer, and a payout
needs payee details that a payment never required. It is a property of the
payment method, not of your account or of the individual refund.
The API models this on the refund request as an optional `userInfo` object that
becomes mandatory when the method requires it:
| Group | Fields |
| - | - |
| Bank details | `userInfo.userBankInfo.bankAccountNo`, `bankCode`, `bankName`, `bankCity`, `bankBranch` |
| Identity | `userInfo.userType` (`INDIVIDUAL` / `BUSINESS`), `userFirstName`, `userMiddleName`, `userLastName`, `nationality` |
| Contact | `userInfo.userEmail`, `userInfo.userPhone` (`+CountryCode-Number`) |
| Identification | `userInfo.userIDType`, `userIDNumber`, `userIDIssueDate`, `userIDExpiryDate` (dates as `dd/mm/yyyy`) |
**Where do you get the values?** From the customer. Waffo does not hold the
customer's bank account, and neither did the original payment — which is exactly
why the field is being asked for. In practice this means a refund on one of these
methods is not a one-click operation: you have to go back to the customer for
their account details first, so build that into your returns process rather than
discovering it at the moment you press Refund.
**You are told which fields are missing, rather than having to guess.** Both
entry points work the same way:
* **In the Portal**, opening the refund drawer asks for exactly the fields that
order needs, with a searchable bank list where a bank has to be chosen from a
fixed set and a country list for nationality. Names are capped at 60
characters, other text fields at 64, and the phone number is validated against
the payment's country dial code. Middle name is the one optional field.
* **Through the API**, a refund submitted without a required field comes back
telling you which field paths are missing, so you can collect those and
resubmit. It is normal for a second round to ask for more than the first —
supply what you are asked for and resubmit rather than pre-filling everything.
The general mechanism, and the notation used for the field paths, is in
[Conditionally Required Fields by Payment Method](/docs/en/support/conditional-required-fields-faq).
## I typed the bank account wrong and the refund failed. Can I fix that refund?
No. A refund record is not editable, and there is no "correct and resend" action
on a failed one. Raise a **new refund**, with a **new `refundRequestId`**, and the
corrected details.
Two things to check before you resubmit:
* **The refundable amount.** Use `remainingRefundAmount` from the refund response
or refund inquiry rather than assuming the failed attempt released everything —
it is the authoritative figure and it already accounts for every refund on the
order.
* **The details themselves.** A refund that failed on wrong payee details will
fail again on the same details. Confirm the account with the customer, not from
your own records.
Reusing the *same* `refundRequestId` will not help either: it is the idempotency
key, so resending it asks for the original refund back rather than for a new
attempt with new details. Idempotent-retry behaviour is covered in
[Refund FAQ](/docs/en/support/refund-faq).
## Can a customer start a refund themselves?
**No.** A refund is initiated by you — through the Portal or through the refund
API — and there is no customer-facing path to one. The Waffo checkout does not
offer a refund action, and neither does the subscription self-management page a
customer can reach: cancelling a subscription there stops future charges, it does
not reverse past ones.
`refundSource` on a refund record makes the same point in field form. Its four
values are:
| `refundSource` | Meaning |
| - | - |
| `MERCHANT` | You initiated it |
| `RDR` | Raised automatically by the chargeback alert service |
| `ETHOCA` | Raised automatically by the chargeback alert service |
| `CDRN` | Raised automatically by the chargeback alert service |
There is no customer value, because there is no customer path. What a customer
*can* do is raise a dispute with their bank, which is a different mechanism with
different money and different deadlines — see
[Chargeback FAQ](/docs/en/support/chargeback-faq).
Practical consequence for your product: if you want self-service returns, build
the request flow on your side and call the refund API when you approve it. Do not
wait for a customer-facing Waffo screen to point them at.
## I already refunded the customer myself. Will calling the refund API pay them twice?
Yes. A refund raised through Waffo is a real movement of money, and Waffo has no
knowledge of anything you settled with the customer outside the platform — a bank
transfer you made yourself, a store credit, a cash refund at a counter. Nothing
reconciles the two, so a refund call after an off-platform refund pays the
customer a second time.
The safeguard is the refund record itself, not a check Waffo can perform for you:
* **Read the order's refund history before refunding**, in the Portal or via
refund inquiry, and read `remainingRefundAmount`. That tells you what Waffo has
already sent — including refunds you did not raise.
* **Record off-platform settlements in your own system** and gate your refund
action on that. This is the only place that information exists.
* **Prefer refunding through Waffo** where the payment method allows it, precisely
so that the refund lands in the same record as the payment.
The same logic applies in reverse: an order Waffo has already refunded does not
need a second gesture from you, and paying the customer again off-platform is the
mirror-image mistake.
## Can I refund only part of the amount from the Portal?
Yes. The Portal's refund drawer opens with the full refundable amount pre-filled
and the amount field is editable: you may enter any amount **greater than zero and
not more than** the payment amount, in the order's currency and within that
currency's decimal precision. A reason is mandatory alongside it, and you confirm
the exact figure in a dialog before the refund is created.
Whether a *second* partial refund is then available on the same order is a
payment-method question, not a Portal one — some methods permit one partial refund
and no more. See
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq)
before you plan a staged refund, and note that a stablecoin order refunded from
the Portal additionally requires a receiving address on the original chain.
## The refund record says `RDR`, `ETHOCA` or `CDRN`. Can I still refund the order?
That order has already been refunded automatically, to head off a dispute the
alert service saw coming. So:
* **No further refund is available on that order.** As soon as the order carries
a non-failed refund raised by the chargeback alert service, every later refund
attempt is rejected with a dedicated error — `A0039 Order has already been
refunded by chargeback alert service` — **whatever the amount**. The check does
not look at whether the automatic refund covered the full payment, so a
`remainingRefundAmount` greater than zero on such an order does not mean you
can refund the rest through the API or the Portal. If you believe money is
still owed to the customer, that is a support case.
* **Do not refund the customer again outside Waffo** on the assumption that the
automatic refund did not happen. Read the refund record first — that is what
`refundSource` is for.
An order under an active dispute is separately blocked from refunds, with its own
error (`A0019`). The distinction matters: `A0039` means *already refunded*,
`A0019` means *locked while the dispute runs*. Neither is retryable as-is.
## When does the refund webhook fire, and where does it go?
It fires when the refund reaches its **final** state — completed or failed. There
is no intermediate "refund accepted" webhook, so you will not receive a
notification for `REFUND_IN_PROGRESS`.
Where it is delivered:
| Situation | Destination |
| - | - |
| `refundNotifyUrl` sent on the refund request | That URL |
| Not sent, but a refund notification URL is configured on your account | The configured URL |
| Neither | No webhook. Use `/api/v1/refund/inquiry` to get the outcome |
| Refunds created from a batch file | The configured account URL — batch rows carry no per-row URL |
The event type is `REFUND_NOTIFICATION`, and its body is the same structure as
the refund inquiry response, so one parser serves both. That is deliberate: an
integration that can read refund inquiry can read the webhook, and polling is a
legitimate fallback rather than a second implementation.
Two operational notes. `refundNotifyUrl` is capped at 256 characters — shorter
than the payment `notifyUrl` — and the same delivery rules apply as for payment
webhooks: reachable HTTPS on a standard port, and the expected success response
body. Delivery troubleshooting is in
[Webhook retry](/docs/en/developer-docs/webhook/retry) and
[Portal webhook log](/docs/en/support/portal-webhook-log-faq).
## Can I get the refund result by polling instead of waiting for a webhook?
Yes. `/api/v1/refund/inquiry` returns the refund's current state and is the
documented fallback when you have no notification URL configured, when signature
verification fails, or when you simply want the answer now. It returns
`refundStatus`, `refundFailedReason` when it failed, `refundCompletedAt`,
`remainingRefundAmount` and the full identifier set.
The same applies to payments — Order Inquiry is a first-class way to get a
result, not a workaround. What polling does not do is remove the need to handle
the webhook: build the webhook path, and use inquiry to close gaps and to answer
"where is it now" questions from your support team.
## How long does a refund take to reach the customer?
It depends on how they paid, and the honest range is wide. Waffo's part — moving
the refund to its final state — is what `refundStatus` reports; the last leg is
in the hands of the customer's bank, card issuer or wallet provider, and Waffo
does not commit to a delivery date for it. The Portal's support screen shows
indicative processing times per payment method; use those as the reference when
you talk to a customer. The shape of the answer is stable even if the figures are
not:
| How the customer paid | What to expect |
| - | - |
| Credit or debit card | The slowest path — the refund lands on a statement cycle set by the issuer, not on a day anyone can predict |
| Online banking or bank transfer | Working days rather than hours, varying by bank and country |
| Wallet — refunded to wallet balance | Usually fast, often the same day |
| Wallet — refunded another way | Working days, varying by wallet or bank |
| Manual transfer, ATM or cash deposit | Working days, affected by the bank and the country |
How to use this with a customer:
* **While the refund shows as in progress**, tell them the refund is moving;
there is nothing to escalate.
* **Once the refund shows as completed** and the money still has not arrived
after the indicative window for their method, that is worth reporting — with
the `acquiringRefundOrderId`.
* **Do not promise a date.** Card refunds in particular land on a statement
cycle, not on a day you can predict.
Whether a refund can be *made* at all on a given method, and the per-method
deadlines for making it, are a different question — see
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq).
## Does a refund give the customer back the fees and taxes they paid?
Not necessarily, and the API is explicit about it: on a refund, the
`finalDealAmount` — *the amount the customer receives* — may **exclude** a
service or tax fee the customer originally paid, which is why it can be smaller
than the corresponding order amount.
So there are two different amounts to keep straight when you communicate with a
customer:
| Field | What it is |
| - | - |
| `refundAmount` | What you asked to refund, in the order currency |
| `finalDealAmount` | What the customer actually receives |
Tell the customer the second one. If your policy is to make them whole including
fees, you have to price that in yourself; a refund of the order amount does not
automatically carry the fees back.
What happens to the fee **you** paid on the original payment, and to a
merchant-borne tax, is a settlement question rather than a refund-record one —
see [Refund FAQ](/docs/en/support/refund-faq) and
[Fee & Tax Treatment](/docs/en/support/fee-and-tax-treatment-faq).
## How long can the refund reason be, and does the customer see it?
`refundReason` is **mandatory** on every refund. The limit depends on where you
raise it: the API accepts up to **256** characters, and the Portal's refund form
caps the field at **128** with a live character count.
It is an internal annotation: it is carried on the refund record, appears in the
Portal and comes back on refund inquiry, and it is what your own support team
reads six months later. Write it for that reader — an order reference, a returns
ticket number, a short reason — rather than as a message to the customer.
## Where do I see my refunds, and what can I export?
Portal → Transactions → **Refunds** is the list. Its columns cover the whole
identifier set plus the state of each refund:
| Group | Columns |
| - | - |
| Identifiers | Refund order id, original order id, your refund order id, your original order id, refund request id, Refund Batch ID, sub-merchant id where you use them |
| Amount | Refund amount and currency |
| Method | Payment method, type and country, masked card number |
| State | Refund status, settlement status |
| Times | Created date and time, original payment date and time |
| Links | Related subscription id, your user id |
The list filters on the refund identifiers, status, currency, Refund Batch ID and
created datetime, and it can be exported. Exports run as background tasks with
their own span and size limits and their own status list — including the case
where an export succeeds with a non-zero failed count — all of which is in
[Export Center](/docs/en/support/export-center-faq). Which columns the transaction and
refund exports carry is in
[Payment Method Config & Limits](/docs/en/support/payment-method-config-and-limits-faq).
If a refund you raised through the API is not in the list, search by your own
`refundRequestId` or `merchantRefundOrderId` before concluding it was lost — those
are columns, and a refund is findable by your own number.
## The refund says it succeeded but the customer says the money never arrived
Work through it in this order, because three different situations produce the
same complaint:
1. **Check the indicative window for their payment method** (the Portal's
support screen shows it). A card refund that has not yet reached the next
statement cycle is not late.
2. **Check `finalDealAmount`**, not `refundAmount`. If a fee was not returned,
the customer received less than they expected and may read that as "not
arrived".
3. **Check where the money was sent.** On methods refunded to a wallet balance it
goes to the wallet, not the card behind it — a customer looking at their card
statement will not see it.
If all three check out and the money is still missing, that is a support case:
send the `acquiringRefundOrderId` and the date. Refunds that report success but
fail downstream, and refunds that end in `ORDER_REFUND_FAILED`, are covered in
[Refund Limits by Payment Method](/docs/en/support/payment-method-refund-limits-faq).
Refunding a stablecoin payment has its own required field — a receiving address
on the same chain — and its own rules about who pays the network fee; those are
in [Stablecoin exceptions](/docs/en/support/stablecoin-exceptions-faq).
***
Need a refund traced? [Contact support](mailto:support@waffo.com) with the
`acquiringRefundOrderId`.
# Report Delivery & Email Subscription
Source: https://waffo.com/docs/en/support/report-delivery-and-subscription-faq
The four ways a Waffo report file can reach you — Portal download, Waffo SFTP, your own SFTP, or email — plus how to manage settlement report email subscriptions yourself: schedule, recipients, body language, send history and when a delivery change takes effect.
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq) explains how the numbers are produced
and how to pull a report on demand. This page covers the other half: **how a report file reaches you on
a recurring basis**, and how to manage settlement report email subscriptions yourself. File layouts and
SFTP paths stay in [Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports).
## Where can my report files be delivered?
Recurring report files support four delivery targets. **Exactly one** is active per report family, and
Portal download always works on top of whichever you pick:
| Delivery target | What happens | Choose it when |
| - | - | - |
| **Portal only** | No push — the file is generated and waits in the Portal | You reconcile by hand |
| **Waffo SFTP** | Placed on the SFTP account Waffo issues you, under the documented paths | You want a pull feed with no infrastructure of your own |
| **Your own SFTP** | Waffo connects out to your SFTP host and writes the file there | Your finance system already watches an SFTP endpoint |
| **Email** | Sent as an **email attachment** to your configured recipients | A person, not a system, consumes the file |
The target is set **per report family** — transaction details, settlement, statement, invoice and the
list exports are configured separately — so settlement files can be pulled over SFTP while statements
go to a mailbox. Pointing a family at email or your own SFTP stops its push to the Waffo SFTP account.
These targets are arranged with support, not from a Portal screen: **Waffo SFTP** needs the mailbox for
the account details plus the public IP or IP range you connect from; **your own SFTP** needs host, port,
username, password and base directory; **email** needs the recipient and CC addresses. On both SFTP
targets the file keeps its original name under your base directory plus the same
`/{report family}/{date}/` sub-path, so one script works against either. Files are kept on the Waffo
SFTP account for a limited period only — the Portal copy remains.
## I changed the delivery target. When does it take effect?
From the **next export cycle**. A job already in flight finishes against the configuration it started
with, so the cycle already running still arrives at the old destination — keep the old SFTP path or
mailbox reachable until you have seen the first file land on the new one.
## Can I have settlement reports emailed on a schedule without asking support?
Yes — **Settlements → Settlement Reports → Email Subscription**. One subscription is one recurring
email: a set of reports, a date basis, a schedule, recipients and a body language. The tab is
permission-gated — if you can see it on that page, the feature is available to your account. What
you can do there:
| Action | What it does |
| - | - |
| **Subscription list** | Your subscriptions, paginated, with status and Last Sent |
| **Add / Edit** | Create a subscription, or change an existing one's reports, schedule, recipients or language |
| **Suspend / Resume / Delete** | Move a subscription between `ACTIVE` and `SUSPENDED`, or remove it |
| **Send History** | The send attempts of one subscription |
| **Preview** | A live rendering of the email body beside the form |
## Which reports, on which date basis?
**Report Type** is multi-select, at least one required: *Transaction Settlement Detail* (default),
*Daily Settlement Summary*, *Selected Period Summary* — the same three Customized Export offers.
**Time Dimension** sets the date the data window is cut on:
| Time Dimension | Meaning |
| - | - |
| `PAYMENT_DATE` (default) | Cut by transaction payment date. Includes **estimated** data for completed transactions still pending settlement |
| `SETTLEMENT_DATE` | Cut by actual settlement date, so every row is a settled figure |
Pick `SETTLEMENT_DATE` when the recipient treats the email as final accounting.
## How do I set the schedule, and in which time zone?
| Schedule Type | Schedule Detail |
| - | - |
| `DAILY` | An hour only |
| `WEEKLY` | Day of week (Monday–Sunday) + hour. Default: Monday 09:00 |
| `MONTHLY` | Day of month 1–31 + hour, where **31 means the last day of the month** — so it still fires in February |
The hour is whole, `00:00`–`23:00`, labelled **(UTC+8)** — the same day cutoff used everywhere else
in settlement. There is no minute-level setting.
## How many recipients, what subject, which language?
| Field | Rule |
| - | - |
| **Recipient (To)** | Exactly **one** address. Use CC for anyone else |
| **CC** | Up to **10** addresses; paste them separated by comma, space or semicolon |
| **Email Subject** | Required, up to 200 characters. Prefilled as `WAFFO-Settlement Report-{your MID}-{your merchant name}` |
Every address is validated before the subscription saves. Put a distribution list in **To** and keep
CC for individuals.
**Email Body Language** is per subscription — English, 中文 or にほんご — and independent of the
language you use the Portal in, so two subscriptions can serve a regional team and a local one in
their own language. A **live preview** of the body sits beside the form and re-renders as you change
the language, time dimension, schedule or subject.
## A subscription stopped, or a send failed. Where do I look?
A subscription is `ACTIVE` or `SUSPENDED`. **Suspend** stops sending but keeps the configuration,
**Resume** puts it back, **Delete** removes it for good. The **Last Sent** column is the fastest way
to spot one that quietly stopped. **Send History** then lists each attempt:
| Column | What it tells you |
| - | - |
| **Status** | `Pending` while the send is being processed, then `Success` or `Failed` |
| **Trigger Time** | When the schedule fired |
| **Data Window** | The period start and end the report actually covered |
| **Recipient (To)** | The address used for that attempt, as configured at that time |
| **Result** | The sent timestamp on success, or the failure reason on a failed attempt |
A failed attempt carries its own reason, most often the recipient rejecting the message. Fix it by
editing the subscription; the next scheduled run uses the new value. Each row keeps the recipients in
force at that attempt, so an old row shows the old address.
***
Need a report family pointed somewhere new? [Contact support](mailto:support@waffo.com) with the family and the destination.
# Reserve Accounts
Source: https://waffo.com/docs/en/support/reserve-faq
How a fixed and a rolling reserve differ, which account funds the hold, how the holding period is counted, what happens on a negative settlement period, when a hold is released, and where to reconcile every reserve movement in the Merchant Portal.
A reserve is money Waffo holds back from your settlements as cover against later
refunds and disputes. It is a contract term — if your contract has no reserve, none
of this applies to you and you will not see the menus described here.
This page is the mechanical detail: how holds are created, counted, topped up and
released. For the balance-level picture — what your reserve balance means next to
your other balances, and why your withdrawable amount can be lower than your
settlement balance — start at
[Balances & Account History](/docs/en/support/balance-and-account-history-faq).
## What is the difference between a fixed and a rolling reserve?
Both hold funds back; they differ in *how much* and *for how long*.
| | Fixed reserve | Rolling reserve |
| - | - | - |
| Amount held | A set amount from your contract | A **percentage of each settlement's net amount** |
| When a hold is created | Once your settlement balance is large enough to cover the contracted amount, in a single deduction | On every settlement, from that settlement's net amount |
| Release | The whole held amount is released on its release date | Each individual hold releases on its own release date, so funds release gradually and continuously |
| Effect over time | A single block of money set aside | A rolling window — as settlements age past the holding period they release, while newer ones keep being held |
Your contract can carry **both**. Where both apply, the amount that must be held
at any moment is the sum of the two:
```
Required hold = fixed reserve configured + rolling reserve accumulated
```
The rolling reserve's contract wording is the clearest statement of what you
agreed to, and it is reproduced on the Portal as a header note:
> Initial Rolling Reserve: \_\_% of Net Settlement Amount to be held for \_\_ Days
Your own percentage, number of days and whether those are business or calendar
days come from your contract. If you settle in more than one currency, the terms
are shown per settlement currency.
## Which of my accounts does the reserve come out of?
Your **settlement account** — the balance that has already completed settlement
and is otherwise available to withdraw. Not your pending-settlement balance.
This is a deliberate choice, and it has a consequence worth understanding: the
reserve is taken *after* settlement has been calculated, so **a reserve hold never
changes your settlement amount**. Your settlement figures, fees and taxes are
exactly what they would be with no reserve; the hold then moves money from your
settlement account into your reserve account. What it reduces is your
**withdrawable** amount, not your settled amount.
## Is the reserve collected on all my transactions?
By default, yes — every transaction on a merchant account with a reserve
configured contributes. Collection can also be switched **on or off per payment
method**, so your contract may collect the reserve on some payment methods and not
others.
Where collection is selective, the base for the rolling percentage is the sum of
the settlement amounts of only the contributing payment methods, aggregated per
settlement batch and then rounded — so one settlement batch produces one reserve
calculation, not one per payment method.
## How is the holding period counted? Which holidays affect it?
Your contract sets the holding period in one of two ways, and the release date is
computed differently for each:
| Period basis on your contract | How the release date is computed |
| - | - |
| **Calendar days** | The hold's creation date plus the contracted number of days. Weekends and holidays do not move it |
| **Business days** | Counted forward from the creation date, skipping non-business days on a **fixed business-day calendar** configured for reserves |
Where your contract counts business days, note that the reserve calendar is *not*
the one that shifts your settlement dates. Settlement dates defer against the
contract-driven holiday calendar of your related countries (see
[Settlement Cycle & Calendar](/docs/en/support/settlement-cycle-faq)); the reserve
calendar is fixed and does not vary by your transaction countries, your settlement
countries or your payment methods. Two dates derived from the "same" number of
days will therefore not line up, which is the single most common source of
confusion about reserve dates. The settlement calendar's **Reserve Release Date**
column shows the computed date so you do not have to reproduce it.
The countdown works per hold, not per merchant:
* Each hold starts with its full contracted period remaining, the day it is created
counting as day one.
* The remaining count on each hold is decremented daily.
* The amount that must stay held today is the sum of every hold that is still
within its period.
## When is my reserve hold created, and when is it released?
A hold is created on your **actual settlement date** and follows your settlement
frequency — a weekly-settling merchant gets a weekly hold, a monthly-settling
merchant a monthly one. Each hold carries an **expected release date**, which is
its creation date plus its contracted holding period (counted as described above),
and that date is what the settlement calendar shows.
Release, however, is not automatic on that date. It is evaluated against what
still needs to be held:
| Situation on the expected release date | What happens |
| - | - |
| Reserve balance **exceeds** the amount still required to be held | The excess is released — as much as is above the requirement |
| Reserve balance is **at or below** the amount still required | **Nothing is released**, and nothing is released on later days either, until the balance recovers above the requirement |
The second row is the case that generates tickets. If the reserve was drawn down
and could not be topped back up — typically because the merchant had no new
transactions to top it up from — then releases stop entirely until the balance
recovers. A hold reaching its release date is a necessary condition for release,
not a sufficient one.
## What happens when a settlement period nets out negative?
**No hold is created that period.** There is no negative amount to take a
percentage of, and nothing is charged against the reserve to make up the
shortfall.
The hold is not skipped permanently, though — it is **added on the next settlement
period with a positive net amount**, which then carries both its own hold and the
deferred one. See
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained)
for what happens to the negative settlement amount itself.
## Can Waffo take money out of my reserve automatically?
**No.** The reserve is not an automatic debit facility.
Its purpose is to cover **penalties and charges applied by manual action** —
Waffo staff raising a deduction against a specific, documented case. There is no
process that automatically draws on the reserve for routine events. In
particular, a negative settlement period does not touch it (see above).
Every deduction from the reserve is therefore traceable to a manual voucher, and
you see it as a movement in your Reserve Account with a reference number, an
amount, the time it was approved and the free-text description entered when the
deduction was raised.
## If the reserve is used, do I have to put it back?
Yes, but not by any action of yours — it is replenished automatically from your
settlement account.
| Rule | Detail |
| - | - |
| How much | Exactly what was used. Not a proportion, not a re-based calculation — used 500, replenish 500. |
| From where | Your settlement account |
| When | On the next processing cycle after the deduction — near-real-time, not on your next settlement date |
| If the settlement account is short | It takes what is available and keeps trying; the outstanding part is replenished as further funds settle |
The replenishment movement appears in Account History as business type
`RESERVE_ADJUSTMENT` with the remark *Debit from settlement account*, so you can
tie a reduction in your settlement balance to a specific reserve top-up.
The interaction with release is worth spelling out, because it is where merchants
lose track: when a hold reaches its release date while the reserve is still short,
the released amount goes to closing the shortfall first. So a release can appear
in your reserve movements without a corresponding increase in your withdrawable
balance.
## How much can I withdraw when a reserve applies?
| Reserve state | Withdrawable amount |
| - | - |
| The reserve balance covers what is required to be held | Your full settlement account balance |
| The reserve balance is short of what is required | Your settlement account balance **minus the shortfall** |
The shortfall is held back — not deducted — and becomes withdrawable again once
the reserve is whole.
## Where do I reconcile reserve movements in the Portal?
Reserve movements are kept **out of** your settlement ledger, in a dedicated
**Reserve Account** area with three views:
| View | What it shows |
| - | - |
| **Reserve balance** | Your total reserve balance, stated as `Reserve balance = Reserve per contract + Reserve adjustment` |
| **Reserve per Contract** | The holds and releases made under your contract terms, and that account's balance |
| **Reserve adjustment** | Manual deductions and the automatic top-ups that follow them, and that account's balance |
Two related changes elsewhere in the Portal:
* The settlement ledger is now labelled **Settlement Account**, and **Account
History no longer mixes reserve holds and releases into it**. If you built a
reconciliation against Account History expecting to find reserve holds there,
they are in *Reserve per Contract* instead. The one reserve-related row that
does remain in Account History is the `RESERVE_ADJUSTMENT` top-up, because that
is genuinely a debit from your settlement account.
* The **Settlement Batch** list adds, for merchants with a rolling reserve, the
settlement amount that the reserve percentage was calculated on, the reserve
order identifier and the reserve amount — so you can verify a hold against the
batch that produced it. Merchants without a rolling reserve see the list
unchanged.
## Does the reserve show up in my settlement files?
**No.** The Settlement Details and Settlement Summary files carry no reserve
column, and a reserve hold never changes any figure in them — the reserve is taken
from your settlement account *after* settlement has been calculated (see above), so
the files describe the settlement exactly as it would be with no reserve.
Where the reserve does appear:
| Surface | What you see |
| - | - |
| **Settlement Batch** list | For merchants with a rolling reserve: the settlement amount the percentage was applied to, the reserve order identifier and the reserve amount |
| **Reserve Account** module | Every hold, release, deduction and top-up, with balances |
| **Account History** | The `RESERVE_WITHHOLDING` / `RESERVE_RELEASING` movements on the reserve account and the `RESERVE_ADJUSTMENT` top-up debit on the settlement account |
| **Settlement calendar** | The **Reserve Release Date**, once a rolling reserve is configured and the settlement has completed successfully |
If you reconcile settlement files against money received, expect the difference
between the file's `Settlement Amount` and your withdrawable increase to be the
reserve hold for that batch — read it off the Settlement Batch row.
## My hold does not equal my percentage times my settlement amount. Why?
Four things sit between the two figures, and any of them can account for the gap:
| Cause | Effect on the hold |
| - | - |
| **Selective collection** | If the reserve is collected on some payment methods and not others, the base is only the contributing methods' settlement amounts — not your whole settlement |
| **Batch-level aggregation** | The percentage is applied once per settlement batch, to the aggregated base, not per transaction. Applying it per transaction and summing gives a different answer |
| **Rounding** | The result is rounded once, at the batch level, after aggregation |
| **A fixed reserve on top** | If your contract carries both types, the amount required to be held is the fixed amount **plus** the rolling accumulation — a movement you attribute to the rolling percentage may be the fixed component |
To reconcile a specific hold, work from the **Settlement Batch** list rather than
from your settlement total: it shows the settlement amount the percentage was
actually applied to, next to the reserve order identifier and the resulting reserve
amount, for exactly that batch.
## Where do I see my reserve terms?
On the **Payin contract page** in the Merchant Portal, which shows your reserve
ratio and reserve period alongside your other contract terms. The rolling reserve
percentage and holding period are also restated as a header note above the
Settlement Batch reserve columns, so the terms sit next to the figures they
produced.
# Risk Declines & 3D Secure
Source: https://waffo.com/docs/en/support/risk-declines-and-3ds-faq
What a risk decline returns, how to tell it apart from a payment method decline, whether you can force or skip 3D Secure and when your instruction is ignored, how a challenge reaches the shopper, and what you can see about declines, chargeback and fraud rates in the Portal.
Risk sits between your order request and the payment method, and it shows up in three places: a
code on the response, an authorization step for the shopper, and numbers in the Portal. This page
covers all three. For the full code list see
[Error codes](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes); for which code
belongs to which *limit*, see
[Error Codes & Limits Supplement](/docs/en/support/error-codes-and-limits-supplement).
## What does the API return when a payment is declined by risk?
Two codes, both returned on the order request itself with HTTP `406`:
| Code | Meaning |
| - | - |
| `D0001` | Risk rejection |
| `D0002` | Risk transaction limit |
They are the risk decision surfacing directly. Treat both as **final for that order** — they are a
decline, not a retryable transport failure and not a review queue you can wait on. `D0001` also
exists on the payout, card, and point-topup surfaces with the same meaning, so a single handler can
cover it; `D0002` belongs to the payin surface.
## How do I tell a risk decline from a payment method decline in my code?
It depends which surface you are reading, and this is the part that catches people out.
| Where you read it | Can you tell? |
| - | - |
| The **synchronous response** to order create | **Yes.** `D0001` / `D0002` (HTTP 406) for risk; a payment channel rejection carries its own code and HTTP status |
| **Order inquiry** and the **payment notification** | **Not from the code alone.** Both a risk decline and a payment-method-reported risk decline are reported under the same generic failure code, and only the accompanying description distinguishes them |
Concretely, on the asynchronous surfaces the failure arrives as `orderFailedReason`, a JSON value
with `orderFailedCode` and `orderFailedDescription`. `orderFailedCode` is a **category**, not the
specific code you saw on the synchronous response, and `orderStatus` is the ordinary closed-order
status — there is no risk-specific order status.
Two practical rules follow:
1. **Record the synchronous code when you get one.** It is the only place the distinction is
machine-readable. Persist it against your own order.
2. **Do not branch on the description string.** It is free text intended for a human reading a
support ticket, and it is not a stable interface.
Redirects are never proof of anything either way: the authoritative result is the notification or an
order inquiry — see [Payment results](/docs/en/support/payment-results-faq).
## My payment was refused for a limit. Which limit, and can I change it?
Limits come from several places and they use **different codes from the risk codes above**:
| Code | Limit |
| - | - |
| `A0005` | Merchant limit exceeded |
| `A0008` | Payment amount outside the permitted range |
| `A0020` | Payment amount exceeds the single-transaction limit |
| `A0021` | Accumulated amount exceeds the daily limit |
| `B0002` | User limit exceeded |
| `D0002` | Risk transaction limit — the risk decision, not one of the above |
Which of these you can influence, how the daily figure is counted, and when headroom is released
are covered in [Error Codes & Limits Supplement](/docs/en/support/error-codes-and-limits-supplement) and
[Payin Agreements & Limits](/docs/en/support/payin-agreements-and-limits-faq). The short version: the
amount range and the ceilings come from what your account is set up with, so changing them is a
conversation, not a Portal setting — with one exception, below.
## Can I set my own per-card or per-user restrictions?
Yes, through the transaction limit rule API, which is the one risk-adjacent control you operate
yourself. It is API-only — there is no Portal screen for it. Endpoints, request shape, what a rule
can key on, and how to read or stop one are in
[Transaction Limit Rules](/docs/en/support/transaction-limit-api-faq).
Three things worth knowing before you arm one:
* **A triggered rule declines the payment as a risk decision** — the shopper's payment fails with
the risk codes above, not with a distinct "your rule fired" code. So on the asynchronous surfaces
you cannot attribute a decline to your own rule; keep your own record of what you restricted.
* **A rule can affect an order that already exists.** Risk is consulted when the shopper actually
pays, not when you created the order, so an order created before you armed the rule but paid after
will be evaluated against it. Only payments that already succeeded are untouched.
* **It only ever adds restriction.** There is no rule direction that exempts a card or a user from
screening.
## When does 3D Secure happen, and can I decide it?
You can express a preference on the order, using `threeDsDecision` inside the card information:
| Value | Your instruction |
| - | - |
| `3DS_FORCE` | Put this payment through 3D Secure |
| `NO_3DS` | Skip 3D Secure for this payment |
Any other value is rejected as invalid card information. But the field is a **preference, not a
switch**, and three things sit above it:
1. **The risk decision.** Your value is one input to risk, which returns the decision that is
actually used.
2. **What the payment method can do.** The decision has to be satisfiable by the route the payment
takes. Where nothing can satisfy it, the order fails rather than silently downgrading.
3. **The issuer.** Even on a 3D Secure payment, whether the shopper is *challenged* or passes
without interaction is the issuer's call, not ours and not yours.
So `3DS_FORCE` is best read as "do not skip this", and `NO_3DS` as "skip it if that is allowed".
## I sent `NO_3DS` and the shopper was still asked to verify. Did I send it wrong?
Probably not. Given the order above, an authentication step can still appear because the risk
decision or the payment route required it. The field is validated, so a genuinely malformed value
would have failed the request with an invalid-card-information error rather than being ignored — if
your order was accepted, the value was understood.
The other case to rule out is scope: the field is only effective for **card, Apple Pay and Google
Pay** payments where the shopper is present. On any other payment method it does nothing.
## Can I set a 3D Secure decision on a subscription renewal or another merchant-initiated charge?
No, and this is why it looks like it is being ignored: the value is **accepted and validated but not
applied** to merchant-initiated card transactions. Subscription renewals are merchant-initiated, so
a `threeDsDecision` on one has no effect and produces no error.
Where authentication for a recurring series happens is at the point the shopper is present — the
first payment, or the mandate setup. See
[Subscriptions](/docs/en/support/subscription-faq) and
[Tokenization](/docs/en/developer-docs/integration/tokenization/overview).
## How does a challenge reach the shopper, and what must my own front end handle?
Whenever an extra step is needed, the order comes back with the status that means authorization is
required, and an `orderAction` describing where to send the shopper:
| `actionType` | What to do |
| - | - |
| `WEB` | Send the shopper to `webUrl` |
| `DEEPLINK` | Send the shopper to `deeplinkUrl` |
If you use the Waffo-hosted checkout, this is handled for you — the shopper is taken to the
authentication page and brought back. If you build your own front end, you must:
1. Detect the authorization-required status on the create response (and on inquiry).
2. Parse `orderAction` and branch on `actionType`.
3. Perform a **full-page** navigation. Do not attempt it inside a small frame: authentication pages
commonly refuse to render framed, and the shopper is left staring at an empty box.
4. On return, do not treat the return itself as the result. Poll an order inquiry, or wait for the
payment notification.
The action envelope, including what else `actionData` can carry for methods that need a code, a QR
or bank details, is in [Order create field guide](/docs/en/support/order-create-field-guide) and
[Redirects & action types](/docs/en/support/cashier-redirect-and-action-type-faq).
## Is risk evaluated on every transaction in real time?
Yes. The evaluation happens **inside the payment request**, before the payment is attempted, and the
outcome is applied to that order immediately. There is no batch pass and no later re-scoring of a
payment that already went through.
That also explains a pattern that looks inconsistent: a shopper's first payment succeeding and a
second one being declined is entirely normal. Each payment is assessed on its own, in its own
context — amount, method, history, what happened in between — so the outcome can differ between two
payments from the same person minutes apart.
## Where can I see risk-declined transactions in the Portal?
Per order, not as a category:
| Screen | What it gives you |
| - | - |
| Payments list | The order and its status. There is **no failure-reason filter** — you cannot list "everything declined by risk" |
| Payment detail | `Order Failure Code` and `Order Failure Description` for that order |
| Payments Analytics → failure analysis | Declines **grouped and counted** by category and description |
So the workflow is: find the volume in failure analysis, then use your own records to pull the
individual orders. The filters the payment list does offer are in
[Portal List Filters & Date Ranges](/docs/en/support/portal-list-filters-and-date-ranges-faq).
## What does the failure analysis tell me, and can I drill down to the orders?
It tells you **how many** and **why**, grouped as: failures attributable to the payment method side,
to the customer side, and to your side, each expanding to individual failure descriptions with their
counts and share.
It does **not** drill down to orders. The request behind the panel is parameterised by product,
subscription stage, countries, payment methods and the interval — never by order, and the response
carries counts rather than order identifiers. There is no link that carries a failure reason into the
payment list as a filter.
What the panel counts, and why analytics figures do not tie exactly to a list count, is in
[Portal Dashboards & Analytics](/docs/en/support/portal-analytics-faq).
## Do declined transactions count against my success rate?
Yes. The success rate is **successful orders divided by orders created**, so every order that was
created and then declined — for any reason, risk included — stays in the denominator. The Portal
shows this alongside two narrower ratios: how many created orders reached the payment step, and how
many of those succeeded.
Because a risk decline happens at the payment step, it lands in the denominator of the narrower
ratio too, not only in the overall one. Practical consequence: pre-qualifying traffic before you
create an order improves the ratio; declining it after you create one does not.
## Can I see my own chargeback rate and fraud-report rate?
Yes, on Chargeback Analytics, where they are reported **per card scheme** rather than as one blended
number, because the schemes define them differently:
| Indicator | How it is composed |
| - | - |
| Chargeback rate (one scheme) | Fraud reports plus chargebacks received in the period, over successful payments in the period |
| Fraud rate (that scheme) | Fraud reports **plus chargebacks carrying that scheme's fraud reason code**, received in the period, over successful payments in the period |
| Chargeback rate (the other scheme) | First chargebacks in the period, over successful payments in the period |
| Fraud rate (that scheme) | Fraud reports over successful payments |
Note both numerators are **received in the period**, not "belonging to payments made in the period" —
a chargeback lands months after the payment, so the ratio is a period measure, not a cohort measure.
The screen also gives chargeback counts and amounts, a trend, a reason-code distribution, and cases
approaching their deadline. Data is a day behind, and the period you can select is bounded.
What each ratio card measures and which period it reports on is also in
[Portal Dashboards & Analytics](/docs/en/support/portal-analytics-faq). What to do about a case is in
[Chargebacks](/docs/en/support/chargeback-faq) and
[Chargeback Evidence](/docs/en/support/chargeback-evidence-guide).
## Are fraud reports the same thing as chargebacks, and can I query them separately?
They are not the same thing: a fraud report is the issuer telling the scheme a transaction was
fraudulent, and it may arrive without a chargeback ever following — which is why it appears in its
own ratio above.
Individual fraud reports **can** be queried separately, through the Reported Fraud list API
(`/api/v1/fraud-report/list`, filtered by report date, scheme or a single order). There is no
Portal list page for them — the Portal shows only the aggregate inside Chargeback Analytics. Field
by field, see [Reason codes & Reported Fraud](/docs/en/support/chargeback-reason-codes-and-reported-fraud).
## A payment was declined and I think it should not have been. Can I appeal, or have a card allowlisted?
There is **no self-serve mechanism** for either — no appeal endpoint, no allowlist or exemption for a
card, a shopper or a range, and no Portal screen for it. The only merchant-operated risk control is
the limit rule API, and it only adds restriction.
So the route is your support contact, with the order identifiers. Do not rebuild the payment as a
retry loop: repeated attempts on a declined instrument are themselves a risk signal.
The IP allowlist is a different feature and *is* self-serve — it restricts which of your servers may
call the API, and has nothing to do with screening shoppers. See
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).
## Can I test a risk decline or a 3D Secure challenge in the sandbox?
Not directly. The sandbox simulator drives outcomes by **order amount**, and the outcomes it can
produce are payment failure, system error and unknown status on create, cancel and refund paths —
listed in
[Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator). None
of them produces a risk-decline code, and none forces an authentication challenge.
What you can and should do instead:
* **Exercise your handling, not the trigger.** Your code paths for a declined order and for an
authorization-required order are the same regardless of which reason produced them, so simulate a
failure and an authorization step and prove your handling.
* **Handle the risk codes defensively in production from day one**, since you cannot rehearse them.
If you need a specific branch reproduced before going live, ask integration support rather than
hunting for an amount that triggers it.
# Sandbox Environment & Access FAQ
Source: https://waffo.com/docs/en/support/sandbox-environment-and-access-faq
How the Waffo sandbox is provisioned, which URLs and merchant ID belong to it, how the sandbox setup wizard works and what each answer changes, where sandbox keys come from, and exactly how the sandbox is isolated from production.
Four pages cover sandbox work, and they answer different questions:
| Question | Page |
| - | - |
| Which URL, which test card, how do I simulate a result? | [Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox) and [Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator) |
| How do I get in, what is my sandbox merchant ID, what does the setup wizard change, what is isolated from production? | **This page** |
| What can and cannot be simulated, and which reserved amounts do what? | [Sandbox Simulation Limits FAQ](/docs/en/support/sandbox-simulation-limits-faq) |
| What must I finish before going live? | [Go-live checklist](/docs/en/developer-docs/getting-started/go-live) and [Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria) |
## Do I need to ask Waffo to open a sandbox for me?
No. The sandbox is self-serve and is created for you automatically. When you
register and submit your business details, a default sandbox is provisioned for
your account without any further request, and without Waffo staff involvement.
There is no form to fill in to *ask* for one.
What you do next is optional refinement: the setup wizard lets you tell Waffo
which products, pricing model, markets and payment methods you want your sandbox
to look like, so that the sandbox reflects the integration you are actually
building.
## Which URLs are the sandbox, and which are production?
They are separate environments on separate hostnames. Nothing in the Portal
toggles between them — you sign in to one or the other.
| Surface | Sandbox | Production |
| - | - | - |
| Merchant Portal | `https://dashboard-sandbox.waffo.com` | `https://dashboard.waffo.com` |
| API | `https://api-sandbox.waffo.com` | `https://api.waffo.com` |
| Hosted checkout | `https://checkout-sandbox.waffo.com` | `https://checkout.waffo.com` |
| Cashier / subscription management pages | `https://cashier-sandbox.waffo.com` | `https://cashier.waffo.com` |
| Static assets | `https://cdn.waffo.com` | `https://cdn.waffo.com` |
The API address for the environment you are signed in to is also shown on
**Merchant Portal → Integration**, so you can always confirm it from inside the
Portal rather than from a document.
If you use a Waffo SDK, set `Environment.SANDBOX` or `Environment.PRODUCTION`
instead of editing URLs by hand.
## Which domains do I need to allow in my firewall or CSP?
For your own pages and your own network policy, the domains above are the
complete customer-facing set. In practice:
| What you are configuring | What to allow |
| - | - |
| Server-side outbound calls to Waffo | The API host for that environment |
| A page that opens or frames Waffo checkout | The checkout and cashier hosts for that environment |
| Images and logos rendered from Waffo | The static asset host |
Two cautions worth building in from the start:
* **Allow the hostname, not an IP.** The go-live checklist recommends a DNS TTL
of 60 seconds and explicitly warns against pinning Waffo IP addresses; they
are not stable and are not published.
* **Sandbox and production hostnames differ**, so a policy written against one
environment will silently block the other. Parameterise the host by
environment rather than hard-coding it.
## Is my sandbox the same merchant ID as production?
No — the sandbox has its own merchant ID, and your user account is attached to
that sandbox merchant ID while you are in the sandbox environment.
You never have to guess which one you are looking at. **Merchant Portal →
Integration** shows the merchant ID of the environment you are signed in to,
right above the credential table, together with that environment's API address.
A sandbox Portal session also carries a persistent banner stating that you are
in the sandbox and that all data is test data.
The consequence for your integration: a sandbox credential can only act on
sandbox data, and a production credential only on production data. Requests
authenticated with one environment's API key against the other environment's
host fail on the credential check, not on your business logic — see
[Authentication & Signature FAQ](/docs/en/support/auth-signature-faq) for
the exact error codes.
## What does the sandbox setup wizard actually change?
It is six steps, and each answer shapes what your sandbox looks like rather than
what it is allowed to do:
| Step | What you enter | What it affects |
| - | - | - |
| 1 | Legal company name, official website, business type, country of incorporation | Merchant identity shown in your sandbox, including the merchant name rendered on the checkout page |
| 2 | Products: one-time payment, subscription, or both | Which product flows your sandbox is configured for |
| 3 | Pricing strategy: `GLOBAL_USD` or `LOCAL_CURRENCY` | Whether your sandbox is set up to present a single settlement currency or local currency pricing per market |
| 4 | Target markets | Which countries the candidate payment method list is drawn from |
| 5 | Payment methods | Which payment methods are enabled in your sandbox |
| 6 | Sandbox ready | Shows your sandbox API key and key pair |
Steps 3 and 4 together drive step 5: the candidate list of payment methods is
derived from the products, the pricing strategy and the markets you selected. If
a method you expected is not offered in step 5, the cause is almost always a
market or pricing choice made two steps earlier, not a missing entitlement.
## Can I change my sandbox configuration afterwards, and does an edit add or replace?
You can change it, and an edit **replaces the whole selected set** — it is not
additive. When you resubmit, the selection you send becomes the complete
configuration: anything you left out is no longer part of it.
Practical consequence: before editing, open the current configuration and start
from what is already selected, rather than sending only the delta you had in
mind. Sending "just the new method" removes everything else.
## I pressed "Clear all" or "Reset". Can I undo it?
Yes — neither touches your saved configuration until you save.
| Action | What it does |
| - | - |
| **Clear all** | Deselects every payment method for the market you are currently looking at, in the edit you have in progress |
| **Reset** | Discards the unsaved market and payment-method changes and restores the configuration you last saved |
So `Reset` *is* the undo for `Clear all`, and leaving the screen without saving
also leaves the saved configuration untouched. Both actions ask for confirmation
first, because within an unsaved edit they are destructive of your in-progress
selections.
Two related behaviours in the same wizard, which surprise people more than the
buttons do: changing the **products** or the **pricing strategy** clears the
payment-method and currency selections underneath, because the previous
selection may not be valid under the new choice; and a market with no enabled
method is dropped from your market list rather than kept as an empty market.
## My sandbox status is stuck on PROCESSING. What does each status mean?
The configuration moves through three states, and only the last one means the
sandbox is live for you:
| Status | Meaning |
| - | - |
| `PROCESSING` | Submitted; Waffo is applying it asynchronously |
| `COMPLETED` | Applied; your account is attached to the sandbox merchant ID and key generation is finishing |
| `ACTIVE` | In effect; your Portal session is operating in the sandbox |
Because it is applied asynchronously, a short `PROCESSING` period is normal, and
the final step of the wizard polls until your keys exist rather than asking you
to refresh. If it does not progress, the configuration record carries an error
message that the Portal surfaces, and the failure also raises an alert on
Waffo's side — so the useful action is to contact integration support with your
sandbox merchant ID rather than resubmitting repeatedly.
## Where do my sandbox keys come from, and do I submit a public key?
Waffo generates the sandbox key pair for you. The final wizard step and
**Merchant Portal → Integration** show your sandbox API key, public key and
private key, so you can start signing immediately.
This is deliberately different from production:
| | Sandbox | Production |
| - | - | - |
| Key pair | Generated by Waffo and shown to you, private key included | You generate it; Waffo never holds your private key |
| Submitting a public key | Not available — there is nothing to submit | Submitted by you from the Integration page, then reviewed |
| Key rotation and version history | Not applicable | Available on the Integration page |
Two rules follow from this, and both are in the
[go-live checklist](/docs/en/developer-docs/getting-started/go-live):
* **Never carry sandbox key material into production.** Generate a fresh
production pair. A key whose private half was displayed in a browser is not a
production secret.
* **Switch the host and the credential together.** They are a set; changing one
without the other produces a signature failure that looks like a code bug.
Production key submission, the exact format the field accepts and how rotation
works are covered in
[Production Public Keys & API Operation Types](/docs/en/support/production-api-key-and-public-key-faq).
## Are sandbox rates, settlement cycles, withdrawal thresholds and limits my real commercial terms?
No. Sandbox configuration is provisioned from defaults so that the flows work
end to end; it is not a copy of your commercial agreement, and no figure you see
in the sandbox should be read as a quotation or as the number that will apply in
production. Treat sandbox amounts, cycles and thresholds as scaffolding for
testing the *mechanics* — the field is populated, the calculation runs, the
status transitions — and confirm the actual values against your production
configuration once you are live.
## Does the sandbox connect to real payment channels?
No. The sandbox does not connect to real payment channels, and no real money
moves. Instead of a real payment method selection, the sandbox checkout page
gives you simulation controls that let you decide the outcome directly. The
simulation flow is the same for every payment method.
That is the single most important thing to internalise about sandbox results:
the sandbox proves that **your** integration handles each outcome correctly. It
does not prove anything about how a specific payment method behaves for a real
end user. What can and cannot be reproduced is enumerated in
[Sandbox Simulation Limits FAQ](/docs/en/support/sandbox-simulation-limits-faq).
## Does enabling a payment method in the sandbox enable it in production?
No, and neither direction syncs. Sandbox configuration and production
configuration are independent: the sandbox setup wizard exists only in the
sandbox Portal and cannot be reached at all from production, and changes you
make to your production configuration do not flow back into the sandbox.
So a sandbox that can transact with a given method tells you your code path
works — it is not an entitlement. Production availability follows your
agreement, and enabling a method for production is a separate step; see
[Payment method enrollment](/docs/en/support/payment-enrollment-faq).
## Will my sandbox transactions show up in real settlement, billing or reconciliation files?
No. Sandbox is a separate environment with its own data; sandbox activity
produces only sandbox-side records, and sandbox exports are written to a
sandbox-scoped location. Nothing you do in the sandbox reaches production
settlement batches, production statements or production reconciliation files.
The mirror of that isolation is also worth planning for: sandbox settlement
records exist for testing your file parsing and your reporting, and they carry
sandbox amounts derived from defaults rather than from your agreement.
## Can I use real customer email addresses and phone numbers in the sandbox?
Do not. The acceptance criteria already require realistic-looking parameter
values rather than `test` placeholders, and the recommended form for a customer
email is a `userId@examples.com`-style address that is unique per test user —
which satisfies both requirements at once without ever addressing a real inbox.
See the parameter requirements in
[Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria).
Two reasons this matters beyond politeness: sandbox flows do generate
notifications, and an address you do not control cannot be inspected when you
need to prove a notification was produced. Use addresses you own.
## How long does it take for a credential or configuration change to become effective?
Different surfaces, different windows — and neither is instant:
| Change | When it takes effect |
| - | - |
| A public key becoming `Active` | Shortly after activation; only `Active` keys verify signatures, and the lookup used on the request path is refreshed on a short interval rather than instantly |
| An IP allowlist edit | Almost immediately; saving also invalidates the cached lookup |
So a test run immediately after a credential change can legitimately fail on the
old configuration. If a request fails right after you rotated or activated
something, wait out the window above and retry the identical request before
debugging your signing code.
## Who on my team can see the sandbox credentials?
The Integration page — API key, Waffo public key, and in the sandbox the
generated private key — requires the **Dev** or **Admin** role. Production key
material on that page is masked with a show/hide control and a copy action that
works without unmasking; use the copy action rather than revealing a value on a
shared screen or a recorded call.
Because sandbox private key material is displayed by design, treat sandbox
credentials as shared team test material and production credentials as secrets
with a different handling standard. They are not the same class of thing.
***
Ready to test outcomes rather than access? Continue to
[Sandbox Simulation Limits FAQ](/docs/en/support/sandbox-simulation-limits-faq) for
what can be reproduced, and to
[Sandbox Testing FAQ](/docs/en/support/sandbox-testing-faq) for the simulator
walkthrough.
# Sandbox Simulation Limits FAQ
Source: https://waffo.com/docs/en/support/sandbox-simulation-limits-faq
Exactly what the Waffo sandbox can and cannot reproduce: which reserved amounts trigger which error, why the refund amounts do nothing, how to simulate a wallet-side cancellation, why a simulated failed renewal never retries, and which scenarios have no sandbox path at all.
Getting in, and what the setup wizard changes, is on
[Sandbox Environment & Access FAQ](/docs/en/support/sandbox-environment-and-access-faq).
The simulator walkthrough is on
[Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator)
and [Sandbox Testing FAQ](/docs/en/support/sandbox-testing-faq).
**This page is the boundary map**: which scenarios the sandbox can actually
produce, which it cannot, and where the reserved amounts do and do not apply. Read
it before you plan an acceptance run, so you do not spend a day trying to
reproduce something that has no sandbox path.
## What can and cannot be simulated? Give me the whole list.
| Scenario | Available in the sandbox? |
| - | - |
| Payment success | Yes — the simulation button on the checkout page |
| Payment failure / decline | Yes — the simulation button on the checkout page |
| Channel rejection (`C0005`) | Yes — a reserved order amount |
| System error (`C0001`) | Yes — a reserved amount, on create and on cancel |
| Unknown status (`E0001`) | Yes — a reserved amount, on create and on cancel |
| Idempotency conflict (`A0011`) | Yes — reuse a request ID with a different amount or currency |
| Refund amount exceeding the original (`A0003`) | Yes — request more than was paid |
| Order cancellation before payment | Yes — the cancel API |
| Subscription first-charge success and failure | Yes — the checkout page |
| Subscription renewal, success or failure | Yes — the subscription management page |
| Wallet-side (customer-initiated) cancellation | Yes, for supported payment methods — see below |
| Refund failure | Yes, synchronously — a create-family reserved amount on the refund request returns an error and leaves a `REFUND_FAILED` refund order; see below |
| Refund that first goes in progress and then fails | **No** — no simulation path |
| Chargeback / dispute | **No** — no simulation path |
| Channel timeout or no response | **No** — not directly; `E0001` is the closest analogue |
| Payout, withdrawal or settlement outcomes | **No** — the APIs exist, but there is no control to force an outcome |
| Time travel (advancing a trial end or an expiry date) | **No** — no such capability exists |
| 3DS challenge | **No** — no control forces a challenge, and the test card list has no 3DS card |
Everything in the "No" rows is genuinely absent rather than undocumented, so plan
around it: prove those code paths with unit tests against recorded payloads, and
verify the live behaviour in production once you are live.
## Which reserved amounts trigger which error, and on which endpoints?
The sandbox intercepts specific order amounts and returns the matching error
instead of processing normally. The mapping:
| Reserved amounts | Error returned | Applies to |
| - | - | - |
| `90`, `990`, `1990`, `19990` | `C0005` Payment Channel Rejection | Order create, subscription create |
| `9.1`, `91`, `991`, `1991`, `19991` | `C0001` System Error | Order create, subscription create |
| `9.2`, `92`, `992`, `1992`, `19992` | `E0001` Unknown Status | Order create, subscription create |
| `9.3`, `93`, `993`, `1993`, `19993` | `C0001` System Error | Order cancel, subscription cancel |
| `9.4`, `94`, `994`, `1994`, `19994` | `E0001` Unknown Status | Order cancel, subscription cancel |
Read the "applies to" column carefully, because it is where the mistakes happen:
* On **create**, the amount is taken from the request — the order amount on an
order create, the amount on a subscription create.
* On **cancel**, you do not pass an amount. The amount matched is the one **stored
on the order or subscription you are cancelling**. So to test a cancel error you
must create the order at `9.3` first, then cancel it.
* These amounts are a **sandbox-only rule**. Nothing like this exists in
production, where the same amounts are ordinary amounts.
## Why do the reserved refund amounts not work?
Because the refund path does not use the refund-specific amounts. The `9.5` and
`9.6` families listed for the refund API on the published
[Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator)
page do **not** take effect — a refund of `995` is an ordinary refund.
What the refund path *does* react to are the **create** families: a
`refundAmount` equal to one of the `90` / `9.1` / `9.2` family values (the same
amounts that trigger `C0005`, `C0001` and `E0001` on order create) makes the
refund request fail at request time with an error of the matching class — a
channel or system error for the `90` and `9.1` families, an unknown-status error
for the `9.2` family. The refund order that was opened for the request finishes
as `REFUND_FAILED`, and a refund notification reporting the failure is sent to
your refund notify URL. That lets you prove your handling of a rejected refund
request and of a failed refund record; what it cannot give you is a refund that
first goes in progress and fails later, and there is no other way to force one in
the sandbox. What you *can* still prove on the refund path:
| Refund case | How |
| - | - |
| Successful refund, full and partial | Refund a paid sandbox order |
| Refund exceeding the original amount → `A0003` | Request more than was paid |
| Refund idempotency conflict → `A0011` | Reuse `refundRequestId` with a different amount or currency |
| Refund notification handling | The refund notification fires for the cases above, including the synchronous failure |
For a genuine refund *failure*, test your handler against a recorded payload
rather than trying to provoke one.
## My real prices land on the reserved amounts and my sandbox orders keep failing
This is a known situation and it has a fix that is not "change your prices":
**a sandbox merchant ID can be exempted from amount simulation.** Contact
integration support with your sandbox merchant ID and ask for the exemption; once
applied, reserved amounts behave as ordinary amounts for your sandbox.
Until then, the workaround is to avoid the five families above. In practice that
means avoiding amounts ending in `90`, and the `9.x` / `x91`–`x96` patterns —
adding a cent (for example `90.01` instead of `90`) is enough, because the match
is exact.
## Does the sandbox really send webhooks?
Yes. Sandbox webhook delivery follows the same contract as production — the same
signature, the same accepted response body, the same retry schedule. Everything
in [Webhook Delivery Contract FAQ](/docs/en/support/webhook-delivery-contract-faq)
applies unchanged.
Two things that are *not* different but get blamed for looking different:
* **Your `notifyUrl` must be publicly reachable.** The sandbox cannot reach
`localhost` or a private address, and your TLS certificate must be publicly
trusted. Use a tunnelling tool for local development.
* **Perceived sandbox delay is usually the retry schedule.** If a first delivery
fails, the next attempts follow a growing back-off — see
[Webhook retry](/docs/en/developer-docs/webhook/retry) for the schedule.
A notification that "took minutes to arrive" almost always means the first
attempts were rejected — check the Portal webhook log for the earlier failed
attempts on the same record before concluding the sandbox is slow.
## Which test card numbers should I use?
Use the **quick-fill buttons on the sandbox checkout page** when a flow asks you
to enter a card. They populate the card number, an expiry and a CVV for a success
or a decline in one click, per card brand, for the brands your sandbox has
enabled — which removes both the transcription errors and the guesswork about
which value that environment expects.
If you need the values written down, the Portal's own list is at
**Merchant Portal → Integration → Test Cards**, with a copy action per card and
two scenario categories: **successful** and **failed**.
The table in [Sandbox Testing FAQ](/docs/en/support/sandbox-testing-faq) matches the
Portal list. The older published simulator page does not agree for every scheme,
so **prefer the in-product source over any document**, and prefer the checkout
quick-fill over both.
## What do I enter for expiry and CVV? Are they validated?
Any future expiry date, and any CVV of the right length — three digits, four for
the schemes that use four. The quick-fill on the checkout page supplies a valid
combination for you.
What this tells you about coverage: because expiry and CVV are not meaningfully
validated, the sandbox cannot prove your handling of an expiry-date rejection or
a CVV mismatch. Those belong to the "test against a recorded payload" list.
## The Portal test card page shows only success and failure. Where are the 3DS, insufficient-funds, CVC-error and risk-decline cards?
They do not exist. The Portal test card list has exactly two scenario categories,
successful and failed, and there is no card number that maps to a specific
decline reason.
The consequence for acceptance planning: the sandbox proves that your code takes
the right branch on success and on failure. It does not let you enumerate decline
reasons. Where your product behaves differently per decline reason — a retry
prompt for insufficient funds versus a "use another card" prompt for a hard
decline — drive those branches from `orderFailedReason` in unit tests, using the
codes in
[Error codes](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes).
## How do I simulate a renewal, and can I chain periods?
From the subscription management page, which you obtain by calling
`POST /api/v1/subscription/manage` and opening the returned `managementUrl`. It
offers a simulated successful next payment and a simulated failed next payment,
and each click advances one period — so you can chain them: period 2 succeeds,
period 3 fails.
Preconditions, because these are what make the buttons unavailable:
| Requirement | Why the button is missing without it |
| - | - |
| The subscription is `ACTIVE` | Simulation is refused for any other status |
| The subscription is on a Waffo-managed billing schedule | A subscription whose billing is driven from the payment method's side simulates through a different path |
| You are in the sandbox | The simulation entrypoints do nothing in production |
If the page renders but the simulation controls are absent, check the
subscription's status first — an unactivated or already-cancelled subscription is
by far the most common cause.
## Will a simulated failed period then retry on the normal schedule?
**No — and this is the most important thing on this page.** Simulating a failed
renewal **sets the renewal retry allowance to zero first**, so the failed period
goes straight to its terminal state instead of entering the retry schedule.
Two consequences to plan around:
* **You cannot observe renewal retry behaviour in the sandbox** by simulating a
failure. The retry schedule that runs in production is bypassed by the
simulation itself.
* **The change persists for that sandbox subscription's configuration.** Renewal
retries stay disabled afterwards, so a subscription you used to simulate a
failure is no longer a clean subject for retry testing. Create a fresh
subscription for anything retry-related.
To test your handling of the *outcome* of an exhausted retry sequence, the
simulated failure is exactly right — it produces the same terminal period state
and the same notification. To test the sequence itself, you need production
behaviour or a recorded set of payloads.
## Can I simulate the customer cancelling inside their wallet app?
Yes, for the payment methods where it is supported. The simulation produces the
same `SUBSCRIPTION_STATUS_NOTIFICATION` with `USER_CANCELLED` that a real
customer-side cancellation produces, so your handler sees the real thing.
Two gates decide whether it works for a given subscription:
1. **The subscription must be `ACTIVE`.**
2. **The payment method must support it.** Wallet-side cancellation is simulated
by reproducing the message that payment method actually sends on
cancellation, so it exists only for methods where that message is modelled.
Where it is not, the call fails with a dedicated "this payment method does not
support channel cancel" error rather than silently doing nothing — which is
itself the answer to whether your method is covered: try it and read the
error.
If your method is not covered, test the handler directly against a
`USER_CANCELLED` payload. The notification shape does not vary by payment method.
## Can I test a chargeback in the sandbox?
No. There is no chargeback or dispute simulation in the sandbox — no reserved
amount, no button, no endpoint.
What to do instead:
| Goal | Approach |
| - | - |
| Verify your chargeback webhook handler | Test against a recorded `CHARGEBACK_NOTIFICATION` payload; the field list is in [Webhook Payload Field FAQ](/docs/en/support/webhook-payload-field-faq) |
| Verify your evidence submission flow | Walk the API contract in [Chargeback field reference](/docs/en/support/chargeback-field-reference) |
| Understand what will happen in production | [Chargeback FAQ](/docs/en/support/chargeback-faq) and [Chargeback evidence guide](/docs/en/support/chargeback-evidence-guide) |
Also worth knowing before you plan around it: the chargeback notification is only
delivered if the Chargeback Notify URL is configured in the Portal, and it is
Portal-configured only — there is no request parameter. So the first thing to
verify is that the URL is set, in both environments.
## An order is stuck in an unknown state. How do I drive it to a final result?
`E0001` is returned at create time by a reserved amount, and the request is
rejected **before any order is created** — an inquiry on that request id comes
back as "order does not exist". That is the point: an unknown-status response
tells you nothing about whether an order exists, so the behaviour to prove is
your recovery logic — **treat the result as undetermined, inquire, and act on what
the inquiry returns; never mark the order failed or close it yourself**. When the
inquiry confirms nothing exists, re-submit with identical parameters (your
`paymentRequestId` makes that safe).
There is no sandbox control that produces an order which then sits in an
unknown state and flips to a final result later. So the behaviour to demonstrate
is the inquire-then-decide loop and the "did not self-close" property, not a
final state you produced by hand. The acceptance criteria phrase this as: do not
close the order, retry with identical parameters, and wait for the inquiry result
or the notification. See
[Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria).
## Do I need a network exit in a particular country to test?
Not for the sandbox. Nothing in the sandbox payment path checks where your
request or your browser comes from, and because the sandbox does not connect to
real payment channels there is no channel-side geographic restriction to satisfy
either. A sandbox run from anywhere is a valid sandbox run.
Where geography does matter is production verification of methods that genuinely
depend on the customer's region — device or account region settings for the
mobile wallet products, and the supported-country lists those products publish.
Those checks are listed in
[Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox) under the
per-payment-method sections, and they are production checks, not sandbox ones.
## Which things can only be verified in production?
Keep this list next to your go-live plan, because each item is a place where a
green sandbox run proves nothing:
| Only verifiable in production | Why |
| - | - |
| Real payment method behaviour and its own screens | The sandbox shows a simulator, not the real payment page |
| Decline reasons and their distribution | The sandbox has success and failure only |
| Renewal retry behaviour | Simulating a failure disables retries |
| Refund failures, chargebacks | No sandbox path |
| Payout, withdrawal and settlement outcomes | No outcome controls in the sandbox |
| Mobile wallet device, account and region requirements | Depend on real devices and accounts |
| Production access approvals for the mobile wallet products | Granted by the wallet provider, not by Waffo |
| Your production credentials, host and callback URL | Different values from the sandbox by design |
The mobile wallet checks in particular have their own prerequisite lists —
accounts, cards, device regions, in-app browser configuration, and a production
access request that must be approved before launch. Work through
[Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox) and
[Payment method integration notes](/docs/en/developer-docs/tools-and-references/references/payment-method-integration-notes)
rather than discovering them at cutover.
## Do I have to test every contracted payment method individually?
No. The acceptance criteria set out representative coverage rather than
exhaustive coverage: at least one card, at least one per country per type, at
least one virtual account, every method with special required parameters, and
manual device testing for the mobile wallets. The full rule set is in
[Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria).
Two follow-ups that come up constantly:
* **A method your agreement does not allow refunds on** does not need a refund
case. Run the conditional cases only for the APIs and behaviours your
integration actually includes — the templates mark them as conditional for that
reason.
* **A second integration is not a full re-run.** Once you have passed acceptance
at least once, a subsequent one-time-payment integration uses the additional
integration template, which covers only the delta. Your first integration
always uses the standard template.
***
Next: [Go-live checklist](/docs/en/developer-docs/getting-started/go-live) for the
switch itself, and
[Sandbox Environment & Access FAQ](/docs/en/support/sandbox-environment-and-access-faq)
for what does and does not carry over from the sandbox.
# Sandbox Testing FAQ
Source: https://waffo.com/docs/en/support/sandbox-testing-faq
How to get sandbox credentials, simulate payment success and failure, use test card numbers, receive webhooks locally, and understand the differences between sandbox and production.
## How do I get access to the sandbox?
The sandbox is available to every Waffo merchant account. You need:
1. A Waffo Merchant account.
2. An API Key — view it on **Merchant Portal → Integration** (requires the Dev or Admin role).
3. An RSA key pair for request signing — the sandbox provides pre-generated keys; see [Authentication and signing](/docs/en/developer-docs/integration/api/overview).
No real charges are ever made in the sandbox.
## How do I point my integration at the sandbox?
| Environment | Base URL |
| - | - |
| Sandbox | `https://api-sandbox.waffo.com` |
| Production | `https://api.waffo.com` |
If you use a Waffo SDK, set `Environment.SANDBOX` / `Environment.PRODUCTION` — there is no need to modify URLs manually.
## How do I simulate a successful or failed payment?
The sandbox does not connect to real payment channels. Instead, the checkout page shows simulation buttons:
1. Call `POST /api/v1/order/create` and retrieve the checkout URL from `orderAction`.
2. Open the checkout page in your browser.
3. Click **Payment succeeded** (order status becomes `PAY_SUCCESS`) or **Payment failed** (order status becomes `ORDER_CLOSE`).
After clicking, Waffo automatically sends a `PAYMENT_NOTIFICATION` webhook to your `notifyUrl`. The simulation flow is the same for all payment methods — no real channel is called. Where a card flow shows a card form, use the quick-fill buttons or the test cards below.
## How do I test subscription renewals without waiting for a billing cycle?
1. Create and activate a subscription (`POST /api/v1/subscription/create`, then simulate the first payment on the checkout page — the status changes to `ACTIVE`).
2. Call `POST /api/v1/subscription/manage` to get the `managementUrl` and open it in your browser.
3. Click **Simulate next payment succeeded** (sends `PAYMENT_NOTIFICATION` and `SUBSCRIPTION_PERIOD_CHANGED_NOTIFICATION`) or **Simulate next payment failed** (sends a failed `PAYMENT_NOTIFICATION`).
Each click simulates one renewal period, so you can chain multi-period scenarios (period 2 succeeds, period 3 fails). The management page also supports cancellation, which sends a `SUBSCRIPTION_STATUS_NOTIFICATION` with status `USER_CANCELLED`. See [Sandbox simulator](/docs/en/developer-docs/tools-and-references/developer-tools/sandbox-simulator) for details.
## Which test card numbers can I use?
For card-entry flows, use these sandbox card numbers (they match the Portal's **Test Cards** list; none of them triggers a 3DS challenge — the sandbox has no 3DS simulation):
| payMethodName | Payment succeeds | Payment fails |
| - | - | - |
| CC\_VISA | `4576750000000110` | `4576750000000220` |
| CC\_MASTERCARD | `2226900000000110` | `2226900000000220` |
| CC\_JCB | `3528000000000214` | `3528000000120006` |
| CC\_AMEX | `3799960000000110` | `3799960000000220` |
| DC\_VISA | `4001700000000110` | `4001700000000220` |
| DC\_MASTERCARD | `2226930000000110` | `2226930000000220` |
| DC\_JCB | `3088850000000116` | `3088850000200004` |
| DC\_AMEX | `3421560000000110` | `3421560000000220` |
Expiration date: any future date. CVV: any 3 digits (AMEX uses 4 digits). For most scenarios, the checkout simulation buttons are sufficient and no card number is needed.
When a flow does require you to type a card, prefer the **quick-fill buttons on the sandbox checkout page**, or the list at **Merchant Portal → Integration → Test Cards** — the in-product source is the one to trust if any document disagrees with it. See [Sandbox Simulation Limits FAQ](/docs/en/support/sandbox-simulation-limits-faq).
## How do I trigger specific error codes in the sandbox?
The sandbox reserves special order amounts that intentionally return matching error codes — for example, amounts `90`, `990`, `1990`, `19990` trigger `C0005 Payment Channel Rejection` on order or subscription creation, and other reserved amounts trigger `C0001 System Error` or `E0001 Unknown Status`. The reserved amounts apply to **order create, order cancel, subscription create and subscription cancel**; on the refund API, a `refundAmount` equal to one of the create-family amounts makes the refund request fail at request time and leaves a `REFUND_FAILED` refund order, while the refund-specific amounts on the published simulator page do not take effect. Avoid these amounts when you only want the normal success/failure flow — and note this is a sandbox-only rule, not production behavior. The full mapping, and what to do if your real prices collide with a reserved amount, are in [Sandbox Simulation Limits FAQ](/docs/en/support/sandbox-simulation-limits-faq).
## Why am I not receiving webhooks from the sandbox?
The sandbox sends real webhook callbacks, but your `notifyUrl` must be publicly accessible — Waffo cannot reach `localhost` or a private address. For local development, use an intranet tunneling tool (such as ngrok or cloudflared) and set the tunnel's public URL as your `notifyUrl`. See [Webhook overview](/docs/en/developer-docs/webhook/overview) for the callback contract.
## What is different between sandbox and production?
| Aspect | Sandbox | Production |
| - | - | - |
| Base URL / SDK | `https://api-sandbox.waffo.com` / `Environment.SANDBOX` | `https://api.waffo.com` / `Environment.PRODUCTION` |
| Funds | No real charges | Real transactions |
| Payment channels | Simulator buttons; no real channel connection | Real payment channels |
| API Key | Sandbox-specific | Separate production API Key |
| RSA keys | Pre-generated sandbox keys | Generate a new production key pair — do not reuse sandbox keys |
Octopus, Apple Pay, and Google Pay require additional dedicated checks beyond regular sandbox testing — see [Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox). Before switching to production, complete every item in the [Go-live checklist](/docs/en/developer-docs/getting-started/go-live), including integration acceptance submitted to the Waffo technical support group.
# What Can I Change Myself in the Merchant Portal?
Source: https://waffo.com/docs/en/support/self-service-boundaries-faq
For every onboarding and go-live setting a merchant asks about — payment methods, currencies, bank accounts, IP allowlist, domains, keys, order timeout, checkout layout — whether the Merchant Portal lets you change it yourself, or whether it is set on Waffo's side, and what you can read yourself instead of asking.
Most onboarding questions are really one question: *is this mine to change, or yours?* This page
answers that for every setting merchants ask about, so you can stop guessing which screen to hunt
for. Where the answer is "yours", it links to the page that explains the mechanics; where it is
"Waffo's", it says what to ask for.
The stages themselves — sign-up, the sandbox setup wizard, account verification and payment
enrollment — are in
[Self-Serve Sign-up, Sandbox & Account Verification](/docs/en/support/getting-started-self-serve-faq) and
[Payment Enrollment](/docs/en/support/payment-enrollment-faq). The pre-production checklist is the
published [Go-live checklist](/docs/en/developer-docs/getting-started/go-live).
## Which settings can I change myself?
| Setting | Yours to change? | Where |
| - | - | - |
| Sandbox configuration — products, pricing strategy, markets, which methods you test with | **Yes** | Settings → Sandbox Config |
| API key — view it | **Yes** (read) | Settings → Integration |
| Your public key — add and rotate | **Yes** | Settings → Integration |
| IP allowlist for API access | **Yes** | Settings → IP Whitelist |
| Refund and chargeback notification URLs | **Yes** | Settings → Integration |
| Checkout look — logo, colours, payment-method order | **Yes** | Checkout → Cashier Customization |
| Receipt content and layout | **Yes** | Receipt configuration |
| Payment links | **Yes** | Transactions → Payment Link |
| Teammates and their roles | **Yes** | Settings → team management |
| Transaction limit rules of your own | **Yes** (API) | Transaction limit API |
| Which payment methods and countries your **live** account accepts | No — Waffo's side | Read on Service → Payin |
| Amount limits, fees, settlement cycle, refund rules per method | No — Waffo's side | Read on Service → Payin |
| Order expiry per payment method | No — Waffo's side | Read on Service → Payin; you pass a per-order expiry instead |
| Settlement bank accounts | No — Waffo's side | Read on Settings → Bank Accounts |
| Domain registration and wallet domain verification | No — Waffo's side | Ask technical support |
| Checkout layout forms beyond ordering (flat display, standalone display) | No — Waffo's side | Ask your account manager |
The pattern behind the table: **anything that is part of your commercial agreement or a
channel-side registration is configured by Waffo and read by you; anything that is your own
integration surface is yours.** The sandbox is the exception that catches people out — it is fully
self-serve, which makes it easy to assume production is too.
## Can I enable a payment method or a country myself?
Not for live payments. Enabling a method in the sandbox setup wizard configures what you can *test*
against; what you can *accept* is a payin agreement, created on Waffo's side, and it is the only
thing that governs a real payment.
So the two lists can differ, and that is the single most common go-live surprise:
| Where you look | What it tells you |
| - | - |
| Settings → Sandbox Config | What your sandbox is set up to simulate |
| **Service → Payin** | What your live account is contracted to accept, with limits and refund rules |
| `/api/v1/paymethodconfig/inquiry` | The same contracted set, from code, for your own integration logic |
To add a method or a country to your live account, ask your Waffo contact. What that involves and
how the request progresses is in [Payment Enrollment](/docs/en/support/payment-enrollment-faq).
## Can I add or change my settlement bank account myself?
No. **Settings → Bank Accounts** is a read-only list of the accounts already set up for you, showing
the account currency, the account name and number, the bank name, the SWIFT code and the bank
address. There is no add, edit or delete control, so a new or corrected account goes through your
Waffo contact.
Check that page first when a withdrawal is going somewhere unexpected — what it shows is what
withdrawals use. Which account a withdrawal targets and why the button may be disabled are in
[Withdrawals & Batch Payouts](/docs/en/support/withdrawal-and-batch-payout-faq).
## Can I restrict API access to my own servers myself?
Yes, and this is one of the few security controls that is fully self-serve. **Settings → IP
Whitelist** lets you add entries, enable and disable them individually, and turn the whole feature
on or off. The check runs **after** signature verification, so a request with a good signature from
an unlisted address is still refused.
Treat the first entry with care: once the allowlist is enabled, requests from any address not on it
are blocked immediately. Formats, entry limits, the enforcement modes and what happens when the
source address cannot be determined are in
[Portal Security Configuration](/docs/en/support/portal-security-config-faq).
## Can I add or rotate my keys myself?
Yes. Your API key is visible on **Settings → Integration**, and you submit and rotate your own
public key from the same place. In the sandbox the private key is shown to you as well, because the
sandbox key pair is pre-generated; in production it never is — you generate the pair and keep the
private half.
The full mechanics — what the field accepts, why submission asks you to sign a challenge string, the
three operation types, and how to rotate without downtime — are in
[Production API Keys & Public Keys](/docs/en/support/production-api-key-and-public-key-faq). Generating a
production pair is in the published
[Go-live checklist](/docs/en/developer-docs/getting-started/go-live).
## Can I register a domain myself?
There is no domain screen in the Portal, so no. Two cases come up, and both go through technical
support:
| Case | What to do |
| - | - |
| Loading the Waffo checkout in an iframe for a wallet-native payment method | Contact technical support to complete domain verification and registration **before** you integrate. Loading it in a plain iframe does not work — see the published [Apple Pay integration options](/docs/en/developer-docs/integration/apple-pay/overview) |
| Using a wallet-native method on the Waffo checkout page | Give technical support the website domain you will take payments from, and the merchant IDs to enable — see the published [Google Pay integration](/docs/en/developer-docs/integration/google-pay/google-pay) |
If you change the domain your checkout is served from, treat it as a change with several
dependencies rather than one setting: your notification URL and redirect URLs, your IP allowlist if
your egress changed with it, and any wallet domain registration all have to follow. Raise the change
before you cut over rather than after.
## Can I change how long an order stays payable?
Partly. You pass **`orderExpiredAt`** on order creation, and it controls how long the customer has to
submit the payment on the checkout page. Once the order has gone to the payment method, the expiry
belongs to that method.
What you cannot do is edit the per-method expiry configuration: **Service → Payin** has an *Order
Expiry Customization* column that appears where the row supports one, and it is **read-only
everywhere in the Portal**. Only a small number of payment methods accept a channel-side expiry at
all — read that column rather than assuming. The field rules are in the published
[FAQ](/docs/en/developer-docs/tools-and-references/references/faq), and how long an unpaid order actually
stays open is in [Payment Timeouts & Finality](/docs/en/support/payment-timeout-and-finality-faq).
## Can I turn 3DS off?
There is no account-level 3DS switch and no Portal screen for it. What exists is a **per-request
preference** you send on order creation, `threeDsDecision` inside the card information, and it is a
preference rather than a switch — risk and the issuer sit above it, and it does nothing on payment
methods other than cards and card-backed wallets, or on merchant-initiated charges.
Full treatment — the accepted values, the order of precedence above them, and why an authentication
step can still appear after you asked to skip it — is in
[Risk Declines & 3D Secure](/docs/en/support/risk-declines-and-3ds-faq). What it is *not* is a lever on
approval outcomes; declines and what to do about each class are in
[A card was declined. Should I retry?](/docs/en/support/order-amount-precision-and-decline-faq).
## Can I permanently hide a payment method from the checkout?
There is no account-level "hide" switch. What you have is two different levers:
| Lever | Effect |
| - | - |
| Cashier ranking, on Checkout → Cashier Customization | Reorders methods and groups, per country or across all countries. It reorders; it does not remove |
| `payMethodType` / `payMethodName` / `payMethodCountry` on order creation | Narrows what that **one order's** checkout offers |
Per-order narrowing has an asymmetry worth knowing before you rely on it: passing a country hides
other countries' **local** methods but keeps globally available ones. Both levers, and the layout
forms that need an account manager, are covered in
[Cashier Display Rules](/docs/en/support/cashier-display-rules-faq) and
[Cashier Customization](/docs/en/support/cashier-customization-faq).
## Can I take a payment without writing any code?
Yes — create a payment link in **Transactions → Payment Link** and send it to your customer. You
choose the product, country, order currency and amount, and optionally an expiry; the link's paid
state is tracked in the same list. Details, including the default expiry and how the resulting
transactions appear, are in [Payment Links](/docs/en/support/payment-link-faq).
## What can I check myself instead of asking?
Most "is it me or is it you?" questions have a self-serve answer:
| Question | Read this |
| - | - |
| Am I contracted for this method, in this country, in this currency? | Service → Payin, or `/api/v1/paymethodconfig/inquiry` |
| What are this method's amount limits and refund rules? | Same two places |
| Is this method available right now, or in a maintenance window? | The availability fields on `/api/v1/paymethodconfig/inquiry` — see [Payment Method Configuration & Limits](/docs/en/support/payment-method-config-and-limits-faq) |
| Did my webhook actually get delivered? | The Portal's webhook log — see [Webhook Log](/docs/en/support/portal-webhook-log-faq) |
| Which environment are these credentials for? | The Integration page names the environment's merchant ID and API address — see [Sandbox Environment & Access](/docs/en/support/sandbox-environment-and-access-faq) |
| Why was this order rejected? | The error code — see [Errors & Troubleshooting](/docs/en/support/errors-troubleshooting-guide) |
| Where is my export? | [Exports & Report Downloads](/docs/en/support/export-center-faq) |
There is **no separate public status page**. The availability fields on the payment-method
configuration endpoint are the self-serve equivalent, and order creation is always the final
authority on whether a method can take a payment.
# Settlement Account Structure & Account History Filters
Source: https://waffo.com/docs/en/support/settlement-account-structure-faq
Why one settlement currency can show two account numbers and why one withdrawal can leave as two payments, what accountSubType and the overdraft limit fields mean, and the correct Account History filter values — six business types across both the settlement and the reserve account, including reserve withholding, release and adjustment.
[Balances & account history](/docs/en/support/balance-and-account-history-faq) explains what each
balance means and why your withdrawable amount can be lower than your settlement balance.
This page covers the shape of the accounts underneath: why a single settlement currency can
present more than one account number, why one withdrawal instruction can arrive as two
payments, and which filter values Account History actually accepts. For the withdrawal APIs
themselves see
[Withdrawals & balance API](/docs/en/support/withdrawal-and-balance-api-faq).
## Why does one settlement currency show two account numbers in the Portal?
Because a settlement account can be split into two sub-accounts, and where yours is, the
Portal shows you both. Each account carries an `accountSubType`:
| `accountSubType` | What it is |
| - | - |
| `REGULATED` | The regulated sub-account |
| `CORPORATE` | The corporate sub-account |
| `REGULATED_CORPORATE` | A **combined** account — one account number holding both, not split |
Which of the three applies to you follows from how your account was set up; it is not a
setting you change. Two consequences worth internalising:
* With `REGULATED_CORPORATE` you see **one** account number per currency and everything
behaves as a single pot.
* With separate `REGULATED` and `CORPORATE` accounts you see **two** rows for the same
currency, each with its own account number and its own available balance. Your total
position in that currency is the sum of the two — no single row shows it.
The account view returns these as a `subAccounts` list hanging off the currency-level
account, so a row with a populated `subAccounts` list is a parent and its balance detail is
in the children.
## What are `overdraftLimit` and `availableOverdraftLimit`?
Each account can carry an overdraft allowance, reported as two fields:
| Field | Meaning |
| - | - |
| `overdraftLimit` | The total overdraft allowance configured for that account |
| `availableOverdraftLimit` | How much of that allowance is still unused |
These explain how a balance can be negative without anything having gone wrong: chargebacks
and chargeback fees are attempted against your balances in a fixed order and, at the end of
that order, may draw on the overdraft, which is then cleared by later incoming funds. See
[Balances & account history](/docs/en/support/balance-and-account-history-faq) for that ordering.
`availableOverdraftLimit` is the number to watch, not `overdraftLimit`: the allowance is
consumed, so a large configured limit tells you nothing about current headroom. An
overdraft allowance is part of your account setup, not a self-service field — contact
support to discuss it.
## Why did one withdrawal request produce two payments?
Because the funds came from both sub-accounts. This happens only where your settlement
account is the **combined** (`REGULATED_CORPORATE`) type, and only where one sub-account
alone could not cover the amount:
| Situation | What leaves your account |
| - | - |
| The regulated side covers the full amount | **One** payment, drawn entirely from the regulated side |
| The regulated side has no balance | **One** payment, drawn entirely from the corporate side |
| Neither side alone covers it | **Two** payments — the regulated side is emptied first, and the corporate side covers the remainder |
The regulated side is always drawn down first. Each leg becomes its own outward payment
with its own amount, so your bank statement can show two credits for one withdrawal
request, and they may not arrive at the same moment.
**The withdrawal fee is charged once, not twice.** Where the withdrawal splits, the whole
fee sits on the larger of the two legs and the other leg carries no fee at all. So do not
reconcile a split withdrawal by expecting each leg to net out independently: one leg will
look short by the full fee, the other will look exact. Add the legs together before
comparing against what you asked for.
## Does the balance API show sub-accounts too?
No — and this is the difference that causes the confusion. `/api/v1/account/balance/inquiry`
returns one entry per currency in `accounts[]`:
| Field | Meaning |
| - | - |
| `accountNo` | The account number, globally unique |
| `currency` | Settlement currency of the entry |
| `totalBalance` | Total balance held |
| `frozenBalance` | Amount frozen because of chargeback activity |
| `reserveBalance` | The reserve account balance; can be negative when the reserve is short |
| `availableBalance` | The account's available balance: `totalBalance` − `frozenBalance`. It includes neither a reserve shortfall nor `pendingSettlementAmount` |
`availableBalance` is the available balance returned by the balance API: `totalBalance` −
`frozenBalance`. It includes neither a reserve shortfall nor the pending amount owed, so it is
not the actual withdrawable amount:
```
availableBalance = totalBalance - frozenBalance
actual withdrawable amount = availableBalance + pendingSettlementAmount - max(0, -reserveBalance)
```
A negative `reserveBalance` is a reserve shortfall. It is deducted only when calculating the
actual withdrawable amount, not from `availableBalance`; a zero or positive `reserveBalance`
does not reduce the actual withdrawable amount. `pendingSettlementAmount` is the pending amount
owed, less than or equal to `0`, and is also applied only when calculating the actual
withdrawable amount.
Omit `currency` on the request to get every settlement currency in one call.
So: the API view is per currency, the Portal view can be per sub-account. If the API shows
one account number where the Portal shows two, neither is wrong — reconcile on the currency
total, not on account numbers.
## What are the correct Account History business types?
**Six**, and this is the filter list the Portal offers:
| `businessType` | What the row records |
| - | - |
| `SETTLEMENT` | A settlement paid into the account |
| `WITHDRAWAL` | A withdrawal paid out of it |
| `ADJUSTMENT` | A manual adjustment |
| `RESERVE_WITHHOLDING` | An amount withheld into the reserve |
| `RESERVE_RELEASING` | A reserve hold released |
| `RESERVE_ADJUSTMENT` | The top-up that restores a drawn-down reserve from your settlement account |
Note the spelling: `WITHDRAWAL`, not `WITHDRAW`.
The last three matter because they are how reserve movements appear in your ledger. If you
have been filtering Account History expecting to find reserve activity under a settlement or
adjustment type, filter on the `RESERVE_*` values instead. Older rows can still carry a type
that is no longer produced (an FX `Conversion` row from before conversion moved ahead of
withdrawal), so **query unfiltered first** when you are trying to account for every movement,
then narrow down.
## Does Account History cover my reserve account, or only the settlement account?
It depends on which surface you are reading:
| Surface | Scope |
| - | - |
| `/api/v1/account/history/inquiry` | **Both.** Every API query spans the settlement account and the reserve account; omit `accountType` and both are returned |
| Portal **Account → Balance → Balance History** | The **settlement account** — the screen queries that account |
| Portal **Reserve Account** module | The **reserve account** — holds, releases and adjustments, with their own history tabs |
The account type values are:
| `accountType` | Account |
| - | - |
| `MERCHANT_SETTLEMENT` | Your settlement account |
| `MERCHANT_RESERVE` | Your reserve account |
The same two values are accepted by `/api/v1/account/history/inquiry` on both the request
and each returned row, so `accountType` is how you tell a reserve movement from a
settlement movement in a mixed result set. Rows also carry `accountNo`, `businessId` (the
transaction that caused the movement), `amount`, `currency`, `accountDirection`
(`DEBIT` / `CREDIT`), `newBalance` (the balance immediately after the movement) and
`transactionTime`.
Where your settlement account is split, you can additionally narrow the Portal view by
sub-account, filtering on `REGULATED` or `CORPORATE`. The combined type is not a filter
option — a combined account is one account, so there is nothing to narrow.
## What date range can I query, and what can I export?
| Action | Limit |
| - | - |
| `/api/v1/account/history/inquiry` | `startTime` and `endTime` are both optional and default independently: omit `startTime` and it is `00:00:00` UTC today; omit `endTime` and it is `23:59:59.999999` UTC today. The **180-day** limit applies only when you send both. Sending both is recommended, not required |
| Portal Account History query | Both ends required; no server-side maximum window |
| Portal Account History export | **31 days** maximum — a wider range is rejected before the file is built |
The export is the tighter constraint, so plan reconciliation in monthly slices rather than
querying a wide range and then trying to export it. The export is a CSV named
`account_history_{merchant id}_{from}_{to}.csv`.
## When is a refund's processing fee returned to me?
Whether a refund returns any of the original payment's processing fee is a **contract
setting**, with two states:
| Refund fee strategy | Behaviour |
| - | - |
| `NO` | No fee is returned on a refund. This is the common setting. |
| `REFUND_BY_RATIO` | Fee is returned in proportion to the amount refunded. |
Under `REFUND_BY_RATIO` the returned fee is pro-rata on the refunded amount against the
original order amount, and it is **rounded down** to your settlement currency's decimal
precision. Because every partial refund is computed against the original order
independently and always rounds down, a series of partial refunds can return slightly less
in total than a single full refund would have — that difference is the rounding, not a
missing entry.
In your settlement reports the returned fee reduces the fee total for that period; see
[Settlement reports](/docs/en/support/settlement-reports-faq) for where the figure lands.
## My refund fee was not returned at all. What blocks it?
One hard rule is enforced: **the payment's fee currency and the refund's fee currency must
match.** Where they do not, the refund is not quietly given a zero fee return — the
settlement run for that batch fails and is investigated, so the movement will not appear in
your statement at all until it is resolved. The same check requires the original payment's
settlement currency to match the settlement currency of the batch it is being cleared in.
If a refund you expected a fee return on is missing from a settlement entirely, rather than
present with a zero fee return, this is the check to ask support about — quote the refund
id and the original order id.
Beyond that, the fee return simply does not apply where the strategy is `NO`.
## Is my chargeback fee always in USD?
No. The chargeback fee currency comes from **your** merchant configuration, and it is
returned on each case as `feeCurrency` next to `feeAmount`. It is not fixed to USD and it
does not have to match your settlement currency.
Where the fee currency differs from your settlement currency, the fee is converted into the
settlement currency **at settlement time, using that settlement's own rate** — not a rate
captured when the case opened. Where the two match, no conversion happens. The chargeback
principal and the chargeback fee are converted on separate rate bases, so on a
cross-currency case the amount and the fee will not appear to have used one single rate.
The result: a statement can legitimately show a chargeback fee in a currency you did not
expect, with a converted figure next to it. For how the resulting settlement lines are
labelled, see
[Chargeback field reference](/docs/en/support/chargeback-field-reference).
# Settlement Batches & Calendar
Source: https://waffo.com/docs/en/support/settlement-batch-and-calendar-faq
How transactions are grouped into settlement entries and batches, what the identifiers mean, what the settlement record's status timeline shows, what Balance History records and how to filter and export it, and how to read the settlement calendar's country dimension and holiday details.
[Settlement Cycle & Calendar](/docs/en/support/settlement-cycle-faq) explains **which day** your money
lands on. This page explains **how transactions are grouped into that payment**, what the resulting
records and identifiers mean, and how to read the ledger and the calendar around them. Report file
contents are in
[Settlement File Specification](/docs/en/support/settlement-file-specification-faq).
## How are my transactions grouped into one settlement payment?
Three levels of grouping, of which you only ever see the outer two:
| Level | Grouped by | Do you see it? |
| - | - | - |
| **Pack** | Merchant + transaction success day + payment method + order currency + settlement currency | **No.** An internal preparation unit only |
| **Settlement Entry** | Merchant + order currency + settlement currency | Yes — the accounting unit inside a batch |
| **Settlement Batch** | Merchant + settlement currency + contracting entity + clearing type | Yes — one settlement event: the money paid to you plus its reports |
Reading upward: transactions are first bundled per payment method and transaction day, those bundles
are combined per order currency, and those are combined into a batch per settlement currency.
Two dimensions of the batch key are easy to miss, and both mean you can receive **more than one
settlement on the same date in the same currency**:
* **Contracting entity.** If your transactions are acquired under more than one Waffo contracting
entity, each settles in its own batch.
* **Clearing type.** A settlement of refunds funded from a refund reserve arrangement is a separate
clearing stream and **never shares a batch with an ordinary settlement** — it also ignores the
holiday calendar. See
[Settlement cycle & calendar](/docs/en/support/settlement-cycle-faq).
That is why a batch deliberately spans payment methods, countries and order currencies. It is also
why **one settlement date with two settlement currencies gives you two batches**, each with its own
files — see [Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
The Pack layer matters to you only as an explanation: because packing happens per payment method and
transaction day, a single batch can legitimately cover **more than one transaction day**. Do not
assume a one-to-one relationship between a settlement and a transaction day.
## What do the settlement identifiers mean?
| Identifier | Shape | Meaning |
| - | - | - |
| Settlement Entry Id | `E` + merchant id + `yyyyMMdd` + sequence | One order-currency accounting unit inside a batch |
| Settlement Batch Id | `S` + merchant id + `yyyyMMdd` + sequence | One settlement event, and the value in your settlement file names |
The date inside the identifier is the date it was generated, not necessarily the date the funds
moved — use the record's own date fields for that. The `S…` value is the one to quote to support: it
is the key that resolves a settlement, its files and its ledger entry.
## What does a settlement record's status and timeline tell me?
A settlement record carries a status, and its detail page carries the **full execution timeline** —
which matters because a settlement can be attempted more than once:
| Status | Meaning |
| - | - |
| `Created` | The settlement instruction has been generated |
| `Unsettled` | An attempt could not be completed and it is waiting to be retried |
| `Succeeded` | Booked |
| `Failed` | Terminated without booking |
The timeline records each event with its own timestamp — generation, each unsuccessful attempt, and
the final outcome — so a record that ran, failed and later succeeded shows all three rather than only
the last one. Records still in their initial state are not listed.
Settlement records default to the **most recent 90 days** (UTC+8). A record can legitimately show a
settlement date earlier than today and still be open; the retry order that produces this is described
in [Settlement Cycle & Calendar](/docs/en/support/settlement-cycle-faq).
## What are Settlement Original Amount and Settlement Target Amount?
| Field | Meaning |
| - | - |
| `Settlement Original Amount` | The net settled amount summed **in the order currency** |
| `Settlement Target Amount` | The same money **after conversion** into the settlement currency |
They are identical when your settlement agreement settles an order currency into itself. They differ
when conversion is involved — and `Settlement Target Amount` is **empty until the conversion has
completed**, which is a stage, not missing data. If you are reconciling and the target amount is
blank, the settlement has been prepared but not yet converted.
## What does Balance History record, and which business types can I filter on?
Balance History is the ledger of your **settlement account**, in every settlement currency you
settle into. Six business types are filterable:
| Business Type | What it covers |
| - | - |
| `SETTLEMENT` | Funds arriving from a settlement |
| `WITHDRAWAL` | Withdrawal movements, including a withdrawal being unwound |
| `ADJUSTMENT` | A manual adjustment |
| `RESERVE_WITHHOLDING` | An amount withheld to a reserve |
| `RESERVE_RELEASING` | A reserve hold being released |
| `RESERVE_ADJUSTMENT` | The top-up that restores a drawn-down reserve from your settlement account |
Two points that surprise people:
* **Older rows can carry a type that is no longer produced.** An FX `Conversion` row from before
conversion moved ahead of withdrawal still displays with its own label. The filter is a
convenience, not a proof that a row type never existed.
* **The Portal screen shows the settlement account.** Movements booked on the reserve account
itself live on the separate **Reserve Account** page, with its own tabs for holds and releases
and for adjustments; the API endpoint `/api/v1/account/history/inquiry` returns both accounts in
one call — see [Settlement Account Structure](/docs/en/support/settlement-account-structure-faq). If
your account has a refund reserve, that has its own history tab as well.
## Why don't I see FX conversion entries in Balance History any more?
Because conversion now happens **before** funds reach your settlement account, not at withdrawal
time. Consequences, all of them intentional:
| Before | Now |
| - | - |
| Balances shown by transaction currency, pre-conversion | Balances shown **by settlement currency** |
| Conversion appeared as its own movement in the ledger | Settlement arrives already converted, so there is nothing separate to record |
| Conversion happened when you withdrew | Conversion is complete before you withdraw |
| Multiple batches per order currency, some without a rate | **One batch per settlement currency**, with the forex rate shown per transaction |
| Transaction report only | Settlement summary **and** settlement detail reports |
If you are comparing against historical records, that is why old rows show a conversion movement and
new ones do not.
## What columns does Balance History have, and how do I export it?
| Column | Meaning |
| - | - |
| `Account No` | The account the movement was booked to |
| `Account Type` | Which account it is |
| `Business Id` | **The identifier of the thing that caused the movement** — a settlement batch id for a settlement, a withdrawal order id for a withdrawal |
| `Business Type` | The category, as above |
| `Direction` | `CREDIT` (in) or `DEBIT` (out) |
| `Amount` / `Currency` | The movement |
| `Balance` | The balance **immediately after** this movement |
| `Datetime` | UTC+8 |
| `Description` | What happened, e.g. `Withdrawal Submission`, `Withdrawal Fee`, `Withdrawal Cancelled` |
`Business Id` is the join key back to the rest of the Portal — take a settlement batch id to
Settlement Batch, a withdrawal order id to Withdrawal History.
| Export | Rule |
| - | - |
| File name | `account_history_{merchant id}_{from}_{to}.csv` |
| Timestamps in the name | `YYYYMMDDHHMMSS`, UTC+8 |
| Maximum span | **31 days** — enforced in the Portal and again server-side |
The export carries the same columns as the on-screen table, so a figure never changes meaning between
the two.
## What are the default date windows?
| View | Default | Order |
| - | - | - |
| Balance History | Most recent **30 days** | Newest first; same-timestamp rows by currency A–Z |
| Settlement records | Most recent **90 days** | Most recent settlement first |
| Settlement Batch | Most recent **30 days** | By actual settlement date, most recent first |
All windows are UTC+8. Widen the filter explicitly if you are reconciling further back.
## Why is the settlement calendar organised by country rather than by currency?
Because holidays are a property of countries, not currencies. The calendar's primary dimension is
**Country/Region**, listing the countries covered by your active contracts as ISO 3166-1 alpha-3
codes, with the first one selected by default. Everything on the page follows that one selection:
* **Regions of Holidays** shows only the related countries of the selected country/region, and cannot
be changed independently.
* Transaction days and settlement dates, deferrals included, are derived for that country/region only.
* The export contains only the selected country/region.
**The most frequently consulted regions are pinned at the front of the list**, ahead of alphabetical
order.
| Field | What it is |
| - | - |
| `Transaction Period` | The transaction days a settlement covers |
| `Scheduled Settlement Date` | The date computed when the transactions were packed |
| `Scheduled Settlement Date (Deferred)` | Where that date has since moved — a holiday calendar change, or a negative period rolling forward |
| `Actual Settlement Date` | When it actually settled |
Rows are ordered by the first transaction day of the period, then the last. Export produces
`settlement_calendar_[Region]_[YYYYMM].xlsx`, with the selected country/region stated as a header
row. There is no per-date file download on the calendar — reconciliation reports belong to the
settlement batch.
## Why is a specific date not a settlement day?
Click it. The calendar lists the reasons one by one as **Related Holidays**, in *country: holiday
name* form — one line per related country that is closed that day. That replaces a bare
"Non-settlement Day" label, so you can see exactly which country closed and why.
Only **confirmed** holidays are shown. A candidate holiday still being verified does not defer your
settlement and does not appear.
The business-day calculation uses the **merged, de-duplicated holiday calendar of every related
country** on your acquiring contract for that contracted country. If any of them is closed, the date
is not a settlement day.
## Which countries' holidays affect me, and can that be changed?
Two rules govern it:
1. **Consistency.** For one merchant and one contracted country, **all payment methods must carry the
same set of related countries.** You cannot end up with one payment method deferring on a holiday
and another not, within the same contracted country. This is what makes a single merged calendar
meaningful.
2. **Configurability.** Which countries' holidays are taken into account **is configured per
merchant**. A merchant with no exposure to a market has no reason to defer on that market's
holidays, and a settlement route may require a market you do not sell in. If your deferrals do not
match your business, raise it with your account manager — it is a contract configuration, not a
fixed rule.
The calendar always reflects the configuration actually in force for you, so the merged holidays it
displays are the ones that will move your dates.
***
Comparing a batch against your own records and something does not line up?
[Contact support](mailto:support@waffo.com) with the settlement batch id — it resolves the
settlement, its entries, its files and its ledger entry.
# Settlement Calculation Explained
Source: https://waffo.com/docs/en/support/settlement-calculation-explained
Reproduce a Waffo settlement figure line by line: the four clearing steps, the 2 vs 6 decimal precision setting, which currency pair and rounding rule each currency combination uses, how minimum and maximum fee caps are applied, what a Return Fee is, and why a negative settlement period pays nothing.
This page is for the case where you are trying to **reproduce a settlement figure
yourself** and your arithmetic does not match ours. It works bottom-up: one
transaction, four steps, then the rounding and the currency pair that apply to
your particular currency combination.
For the report-level view — which column comes from where, how to export it, and
why a file may be missing — see
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq). For the dates
the money moves on, see [Settlement Cycle & Calendar](/docs/en/support/settlement-cycle-faq).
For the balances it lands in, see
[Balances & Account History](/docs/en/support/balance-and-account-history-faq).
## How does Waffo get from my order amount to my settlement amount?
Every transaction goes through the same four steps, in this order:
| Step | What happens | Result |
| - | - | - |
| 1 | **Clearing amount** — the order amount is converted into your settlement currency | `Order Amount (Settlement Currency)` |
| 2 | **Tax deducted inclusively** — transaction taxes (VAT and/or withholding tax) are computed and deducted from the clearing amount | `Tax Amount` (the report column is labelled `Tax Amount`, whichever tax it is) |
| 3 | **Fee deducted inclusively** — the processing fee is computed and deducted from the clearing amount | `Fee Amount` |
| 4 | **Net settlement** — what is left | `Settlement Amount` |
```
Settlement Amount = Clearing amount − tax deducted − fee deducted
```
Two properties of this order matter when you reconcile:
* **Taxes and fees are inclusive (deducted from the order amount), not added on
top.** The base for both the tax and the fee calculation is the order amount as
transacted, not the amount net of the other one. They are not compounded.
* **The clearing amount is produced first, and tax and fee are then each computed
through their own currency pair.** So a fee billed in a third currency is not
derived from the already-converted settlement figure — see the case tables
below.
## What decimal precision does my settlement use, and can I change it?
`Settlement Decimal Place` is set on your merchant profile when you are signed
up. It has two possible values — **2 decimal places** or **6 decimal places** —
and **it cannot be changed afterwards**.
| Value | Fee, tax and clearing amounts in the detail report | Fee rounding | Settlement Amount |
| - | - | - | - |
| 2 decimal places | 2 decimals | Rounded **up** (`ROUNDUP2`) | 2 decimals |
| 6 decimal places | 6 decimals, zero-padded | Rounded **half up** at 6 decimals | 6 decimals in the detail file; 2 decimals at batch level |
Regardless of the setting:
* **Exchange rates always carry 8 decimals**, zero-padded if the rate is shorter.
* **The settlement summary figure is always 2 decimals.** Money moves in
2 decimals.
If you are on 6 decimals, the sum of your detail rows and the batch amount agree
to the cent, because the residual between the 6-decimal sum and the 2-decimal
batch figure is tracked explicitly rather than being silently dropped:
```
6-decimal total = 2-decimal batch amount + residual
```
If you are on 2 decimals, each row is already rounded to 2 decimals before being
summed, so small-ticket transactions are where you will see the largest relative
difference from a 6-decimal calculation — this is the reason the 6-decimal option
exists.
## Which currency pair and which rounding rule apply to my transactions?
Three currencies are in play: your **order currency**, the **fee currency** your
contract bills in, and your **settlement currency**. Which pair is used, and how
many conversions happen, depends only on which of the three are equal.
The customer-facing rounding rules, as they apply on a **2-decimal** account, are:
| Rule | Meaning |
| - | - |
| `ROUND2` | Round half up to 2 decimal places |
| `ROUNDUP2` | Round **up** to 2 decimal places |
| `ROUNDDOWN2` | Round **down** to 2 decimal places |
On a **6-decimal** account the fee (and the fixed chargeback and failure fees) is
rounded **half up at 6 decimals** rather than up — the round-up rule exists to
protect a 2-decimal figure from under-collection, and at 6 decimals the residual
is tracked instead. Everywhere the case tables below say `ROUNDUP2`, read
"round half up at 6 decimals" if you are on a 6-decimal account.
### Case A — order = fee = settlement currency
No conversion. `Forex Currency Pair` and `Forex Rate` are empty.
| | Formula |
| - | - |
| `FeeAmount(SettlementCurrency)` | `ROUNDUP2(OrderAmount * feeRate)` |
| `FeeAmount` | `= FeeAmount(SettlementCurrency)` |
| `OrderAmount(SettlementCurrency)` | `= OrderAmount` |
| `PAYMENT` Settlement Amount | `OrderAmount(SettlementCurrency) − FeeAmount(SettlementCurrency) − TaxAmount(SettlementCurrency)` |
| `REFUND` Settlement Amount | `− OrderAmount(SettlementCurrency) − FeeAmount(SettlementCurrency) − TaxAmount(SettlementCurrency)` |
| `CHARGEBACK` Settlement Amount | `− OrderAmount(SettlementCurrency) − FeeAmount(SettlementCurrency)` |
Worked example with an illustrative 2% rate (not a quoted rate), all currencies USD:
| Type | Order Amount | Fee Amount | Settlement Amount |
| - | - | - | - |
| `PAYMENT` | 100.00 | 2.00 = `ROUNDUP(100.00 × 2%, 2)` | 98.00 |
| `REFUND` | 50.00 | 1.00 = `ROUNDUP(50.00 × 2%, 2)` | −51.00 |
| `CHARGEBACK` | 50.00 | 10.00 (as reported for the case) | −60.00 |
### Case B — order = fee currency, different settlement currency
`Forex Currency Pair = SettlementCurrency/OrderCurrency`
| | Formula |
| - | - |
| `FeeAmount(SettlementCurrency)` | `ROUNDUP2(OrderAmount * feeRate / forexRate)` |
| `FeeAmount` | `ROUND2(OrderAmount * feeRate)` |
| `OrderAmount(SettlementCurrency)` | `ROUND2(OrderAmount / forexRate)` |
| `CHARGEBACK` fee conversion | `FeeAmount(SettlementCurrency) = ROUNDUP2(FeeAmount / forexRate)` |
Worked example with an illustrative 2% rate, order and fee in USD, settlement in HKD, pair `HKD/USD` at
`0.12770000`:
| Type | Order Amount | Order Amount (Settlement Currency) | Fee Amount | Fee Amount (Settlement Currency) | Settlement Amount |
| - | - | - | - | - | - |
| `PAYMENT` | 100.00 | 783.09 = `ROUND(100 / 0.1277, 2)` | 2.00 | 15.67 = `ROUNDUP(100 × 2% / 0.1277, 2)` | 767.42 |
| `REFUND` | 50.00 | 391.54 | 1.00 | 7.84 | −399.38 |
| `CHARGEBACK` | 50.00 | 391.54 | 10.00 | 78.31 = `ROUNDUP(10 / 0.1277, 2)` | −469.85 |
### Case C — fee currency differs, order = settlement currency
`Forex Currency Pair = SettlementCurrency/FeeCurrency`
| | Formula |
| - | - |
| `FeeAmount(SettlementCurrency)` | `ROUNDUP2((OrderAmount * forexRate) * feeRate / forexRate)` |
| `FeeAmount` | `ROUND2((OrderAmount * forexRate) * feeRate)` |
| `OrderAmount(SettlementCurrency)` | `= OrderAmount` |
This is the case that surprises people: your order currency equals your
settlement currency, yet `Forex Currency Pair` and `Forex Rate` are populated,
because the **fee** had to be converted. `feeRate` here may also carry a minimum
or a fixed component.
Worked example with an illustrative 2% rate, order and settlement in HKD, fee in USD, pair `HKD/USD` at
`0.12770000`:
| Type | Order Amount | Fee Amount | Fee Amount (Settlement Currency) | Settlement Amount |
| - | - | - | - | - |
| `PAYMENT` | 100.00 | 0.26 = `ROUND(100 × 0.1277 × 2%, 2)` | 2.00 | 98.00 |
| `REFUND` | 50.00 | 0.13 | 1.00 | −51.00 |
| `CHARGEBACK` | 50.00 | 10.00 | 78.31 = `ROUNDUP(10.00 / 0.1277, 2)` | −128.31 |
### Case D — order currency differs, fee = settlement currency
`Forex Currency Pair = SettlementCurrency/OrderCurrency`
| | Formula |
| - | - |
| `FeeAmount(SettlementCurrency)` | `ROUNDUP2(OrderAmount / forexRate * feeRate)` |
| `FeeAmount` | `= FeeAmount(SettlementCurrency)` |
| `OrderAmount(SettlementCurrency)` | `ROUND2(OrderAmount / forexRate)` |
| `CHARGEBACK` fee conversion | none — `FeeAmount(SettlementCurrency) = FeeAmount` |
Worked example with an illustrative 2% rate, order in HKD, fee and settlement in USD, pair `USD/HKD` at
`7.83010000`:
| Type | Order Amount | Order Amount (Settlement Currency) | Fee Amount | Settlement Amount |
| - | - | - | - | - |
| `PAYMENT` | 100.00 | 12.77 = `ROUND(100 / 7.8301, 2)` | 0.26 | 12.51 |
| `REFUND` | 50.00 | 6.39 | 0.13 | −6.52 |
| `CHARGEBACK` | 50.00 | 6.39 | 10.00 | −16.39 |
### Case E — order, fee and settlement currencies are all different
Two pairs are needed: `SettlementCurrency/OrderCurrency` to produce the clearing
amount, and `SettlementCurrency/FeeCurrency` to produce the fee. Neither is
derived from the other:
```
Fee Amount (fee currency) = round[ (OrderAmount / rateOrder * rateFee) * feeRate ]
Fee Amount (settlement currency) = round[ (OrderAmount / rateOrder * rateFee) * feeRate / rateFee ]
```
Because the fee-currency rate appears in both the numerator and the denominator
of the settlement-currency figure, the **effective fee percentage against your
clearing amount stays equal to your contracted rate** — it is not eroded by the
double conversion. This is worth checking when you reconcile a three-currency
account: divide `Fee Amount (Settlement Currency)` by
`Order Amount (Settlement Currency)` and you should land on your contracted rate,
subject only to rounding.
## Why is my fee different from rate × amount? (minimum and maximum fee)
Your contract may carry a `minFee`, and either a `maxFee` (an absolute cap) or a
`maxFeeRatio` (a percentage cap). **`maxFee` and `maxFeeRatio` never both apply
to the same fee item.** The checks run in a fixed order, and a check that trips
*replaces* the computed fee rather than adjusting it:
1. **`minFee` is checked first.** If the computed fee in the fee currency is
below `minFee`, both the fee-currency and the settlement-currency figures are
recomputed from `minFee`, rounding **up**. The recomputation uses the raw
`minFee` value as its input, not the already-rounded computed fee.
2. **Then the maximum is checked** — whichever of the two your contract carries.
* `maxFee`: if the computed fee in the fee currency exceeds `maxFee`, both
figures are recomputed from `maxFee`, rounding **down**.
* `maxFeeRatio`: if `fee deducted (settlement currency) / clearing amount
(settlement currency)` exceeds `maxFeeRatio`, both figures are recomputed as
the order amount times `maxFeeRatio`, rounding **down**.
So a fee that has hit its floor rounds up, and a fee that has hit its ceiling
rounds down. `minFee` is always contracted at 2 decimal places or fewer, so it
needs no rounding of its own when it is applied.
The practical consequence for small-ticket transactions: below a certain order
amount, your fee stops being proportional and becomes flat at `minFee`, and the
effective percentage rises as the ticket falls. This is the single most common
cause of "the fee on this transaction looks wrong".
## What is a Return Fee, and how is it calculated?
A **Return Fee** returns part of the *original payment fee* to you when you
refund. It exists only if your contract includes it — the default is a refund
**without** fee return, covered in
[Refund FAQ](/docs/en/support/refund-faq).
Where it applies, the refund row's settlement amount gains it as a **positive**
component:
```
Settlement Amount = − OrderAmount(SettlementCurrency)
− FeeAmount(SettlementCurrency)
− TaxAmount(SettlementCurrency)
+ ReturnFeeAmount(SettlementCurrency)
```
Both figures are pro-rated, and they use **different rounding rules**:
```
ReturnFeeAmount = ROUNDDOWN2( RefundOrderAmount / OriginalOrderAmount × OriginalFeeAmount )
ReturnFeeAmount(SettlementCurrency) = ROUND2( ReturnFeeAmount / OriginalFeeAmount × OriginalFeeAmount(SettlementCurrency) )
```
Read the second formula carefully: the settlement-currency return is pro-rated
against the **already-rounded-down** fee-currency return, not recomputed from the
refund ratio. That is what keeps the two figures consistent when the fee was
billed in a third currency.
Worked example with illustrative rates — all currencies USD, payment fee 2%, refund fee 0%,
Return Fee pro-rata:
| Type | Order Amount | Fee Amount | Settlement Amount |
| - | - | - | - |
| `PAYMENT` | 100.00 | 2.00 | 98.00 |
| `REFUND` | 50.00 | **−1.00** = `ROUNDDOWN((50.00 / 100.00) × 2.00, 2)` | **−49.00** = `−50.00 + 1.00` |
Two things to note:
* The refund row's `Fee Amount` is **negative**. That is the Return Fee, not a
charge — a negative fee on a refund row is expected, not a data error.
* The refund's own fee (`refund fee rate`) and the Return Fee are separate
contract items. A refund can carry a refund fee *and* return part of the
payment fee.
Rounding down means the returned amount can land a cent below straight pro-rata
arithmetic. On a full refund the ratio is 1 and the entire original fee comes
back.
## Do I get my VAT back when I refund?
**No.** A refund does not return VAT. In the clearing steps above, the tax step
is simply skipped for a refund transaction — there is no VAT computation on the
reverse leg, so there is nothing to return.
This is separate from the payment fee question: the payment fee *may* come back
if your contract carries a Return Fee, but the tax does not, under any contract
configuration.
## Can the fee currency change, and what can it be?
The fee currency must be **either your settlement currency or the order
currency**, and it **cannot be changed on the same day** — the request is
rejected by validation. This is why the five cases above are exhaustive in
practice for a single transaction.
## My settlement period netted out negative. What happens?
Nothing moves that day, and nothing is written off.
* A settlement is only initiated when the net settlement amount is **greater than
zero**. A period whose refunds and chargebacks outweigh its payments produces
no payout and no funds transfer.
* The negative amount **carries forward and accumulates**. It is added to the
next period, and the next, until the running total turns positive — at which
point the whole accumulated amount settles as **one** transfer.
* The same rule applies when a chargeback is what pushed the period negative: the
chargeback is not charged against any other account, it simply waits for later
transactions.
A worked shape: four consecutive negative settlement dates followed by a positive
one do not produce four transfers and a correction. They produce a single
transfer, for the net of all five, on the day the total turns positive. All five
settlement records point at that one transfer, so you can reconcile the transfer
back to every period it covers.
Because of this, **a missing payout on a settlement date is not necessarily an
error**. See
[Settlement Cycle & Calendar](/docs/en/support/settlement-cycle-faq) for the other
reasons a settlement date can pass without money arriving.
## Why does the batch total not equal the sum of my detail rows?
It should — but only after the correct rounding at the correct level. The summary
is the detail rows summed and rounded **once, at the end**:
```
Summary Settlement Amount = ROUND2( Σ Settlement Amount in the settlement details report )
```
If you round each detail row to 2 decimals first and then add them up, you will
drift from our figure by up to half a cent per row. Sum at full precision, round
last.
# Settlement Cycle & Calendar
Source: https://waffo.com/docs/en/support/settlement-cycle-faq
Operational rules behind your settlement dates: the UTC+8 day cutoff, per-payment-method settlement agreements, the daily / weekly / half-monthly / monthly cycle types and how T+N is counted for each, business-day deferral against the merged holiday calendar, settlement batches, periods that net out negative, and the refund-reserve settlement that does not defer.
[Merchant Settlement](/docs/en/essentials/merchant-settlement) describes the cycles we offer (T+1, T+10,
weekly). This page covers the operational side: **which** transactions fall into a settlement, **which**
calendar day the funds land on, and why a settlement date can pass with no money and no file. Report
columns are defined in
[Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports).
## How is my settlement date calculated?
1. **Scope by transaction day.** Settlement preparation runs after each natural day closes and picks
up transactions whose **success time** falls in the previous day, `00:00:00`–`23:59:59` **UTC+8**.
2. **Add the contracted cycle, then land on a business day.** For a T+N cycle the settlement date is
the **first business day at or after T+N** — business days come from a holiday calendar, not a
fixed weekday rule.
Date fields on the Portal's Settlement Batch pages are labelled `(UTC+8)` for this reason.
## Why does my transaction-day total differ from yours by one day?
This is the most common reconciliation gap, and three rules explain nearly all of it:
| Rule | Effect on your figures |
| - | - |
| The day cutoff is **UTC+8**, not your local time zone | A transaction at 23:30 in a UTC-5 market belongs to the *next* UTC+8 day |
| Scope uses **success time**, not creation time | An order created on day 1 and paid on day 2 settles with day 2 |
| For refunds, success time is the **refund** success time | A refund of an old payment lands in the current period, not the original payment's period |
## Why do transactions on the same day settle on different dates?
A settlement cycle is not a single merchant-level setting. Settlement agreements are configured per
**MID + payment method** (and per contracted country), each carrying its own cycle type (day-based or
week-based) and delay count **N**, its own settlement currency, and its own settlement target
account. Two payment methods used on the same day — or the same method in two countries — can
legitimately settle on different dates and in different currencies.
## What settlement cycles can my agreement be on?
Five cycle types exist. Which one you are on, and the delay count **N** that goes with it, are set on
your settlement agreement per MID and payment method — so different payment methods can be on
different cycles.
The cycle type decides two things at once: **which transactions are grouped together**, and **which
date the delay is counted from**.
| Cycle type | Transactions grouped together | Date N is counted from |
| - | - | - |
| `DAY` | One transaction day | That transaction day |
| `WEEK` | Monday to Sunday | The Sunday closing that week |
| `HALFMONTH` | The 1st to the 15th, or the 16th to the end of the month | The 15th, or the last day of the month |
| `MONTH` | The whole calendar month | The last day of that month |
| `MONTH_1X` | The whole calendar month | The last day of the **following** month |
Read together with the rule above: **N is added to that date, and then the business-day rule moves
the result out until it lands on a business day.** So T+N on a `DAY` cycle means N days after the
transaction day itself; the same N on a `WEEK` cycle means N days after the week has closed.
Two points that cause most of the confusion:
* **`MONTH_1X` groups exactly the same transactions as `MONTH`.** The difference is only where the
delay starts: the end of the following month rather than the end of the transaction month, so with
the same N it pays out roughly a month later.
* **On anything other than `DAY`, one settlement covers a range of days.** That is why the calendar's
`Transaction Period` shows a range rather than a single date, and why your own daily totals will
not line up one-for-one against a settlement.
## Which holidays affect my settlement date?
Not one country's calendar. The business-day calculation uses the **merged, de-duplicated holiday
calendar of all related countries** configured on your acquiring contract for that contracted
country, weekends included. If any related country is closed, the date is not a settlement day and
the settlement defers.
Clicking a non-settlement day in the Portal calendar lists the reasons one by one as **Related
Holidays** (*country + holiday name*), rather than a bare "Non-settlement Day" label. Only confirmed
holidays are shown.
## How do I read and export the settlement calendar?
The calendar's primary dimension is **Country/Region** — a single-select list of the countries covered
by your active contracts, shown as ISO 3166-1 alpha-3 codes, first one selected by default.
Everything follows that selection:
* **Regions of Holidays** shows only the related countries of the selected country/region.
* Transaction days and settlement dates (including deferred ones) are derived for that
country/region only.
* Rows show **Transaction Period**, **Scheduled Settlement Date** and **Actual Settlement Date**.
* Export produces `settlement_calendar_[Region]_[YYYYMM].xlsx` with those same three columns,
covering the selected country/region only.
The calendar is a schedule view and offers no per-date file download — reconciliation reports are
attached to the settlement batch instead.
**Scheduled Settlement Date (Deferred)** exists because a scheduled date is computed when transactions
are packed, and can still move afterwards — the holiday calendar changes, or a negative period rolls
forward. It carries the deferred result, so both the original and the currently expected date stay visible.
## What exactly is one Settlement Batch?
| Unit | Granularity | Meaning for you |
| - | - | - |
| **Settlement Batch** | merchant + scheduled settlement date + settlement currency | One settlement event: the funds paid to you plus its reconciliation reports |
| **Settlement Entry** | the above + transaction currency | The accounting unit inside a batch |
A batch deliberately spans countries, payment methods and order currencies. So if one settlement date
carries **two settlement currencies**, you get **two batches**, each with its own settlement details
and summary reports. The Settlement Batch list defaults to the last 30 days; a batch's detail page
exposes its reconciliation reports and a transaction-level export.
Inside a batch each transaction clears in a fixed four-step order — relevant if you recompute net
amounts: fees and taxes billed in the **order currency** are deducted first, the remainder is converted
to the **settlement currency**, fees and taxes billed in **other currencies** come next, and what
remains is the **Net Settlement Amount**.
## A settlement date passed with no money and no file. Why?
Usually the period's net amount was **negative** — refunds and chargebacks exceeded payments. Then:
* No funds move on that date and **no settlement batch record is created**, which is also why no
settlement report is generated by default.
* The negative balance is **carried forward** and merged with following periods until the cumulative net
turns positive; that settlement is the one that pays out.
* We **do not draw on your reserve** to cover a negative period, and refunds are not funded from the
reserve either.
To tell "nothing to settle" apart from "file generation failed", ask support to enable header-only
placeholder files on every scheduled date — it is a per-merchant configuration.
## Why did a settlement of refunds from my refund reserve arrangement ignore the holiday calendar?
Because that kind of settlement is deliberately kept off the business-day rule.
Refunds that were funded from your **refund reserve account** — see
[Where Refund Money Comes From](/docs/en/support/refund-funding-source-faq) — are not netted into your
ordinary settlements. They are cleared on their own, in their own settlement batch, and on their own
date:
| | Ordinary settlement | Settlement of refunds funded from your refund reserve |
| - | - | - |
| Date is derived from | Your settlement agreement's cycle type and delay count N | Your refund reserve arrangement's **end date plus a fixed number of calendar days**, as set out in the arrangement |
| Deferred when a related country is closed | **Yes** | **No** — the calendar date stands, weekend or holiday |
| Arrives in | Your regular settlement batch for that settlement currency | A settlement batch of its own |
The reason for the exception is that this settlement closes out an arrangement rather than paying out
a trading period. Its date is fixed by the arrangement you agreed to, so letting each related
market's holidays push it around would make the close-out date unpredictable — the opposite of what
the business-day rule is there to achieve for ordinary settlements.
How to recognise one when you are reconciling:
* It is a **separate settlement batch**, so a date can carry both an ordinary settlement and one of
these.
* Its transactions are refunds, not payments.
* The `Comments` field on the settlement record, and in its settlement summary and detail files,
is filled in to mark it as a refund reserve settlement. On an ordinary settlement that field is
empty.
What the reserve account itself is, and how holds and releases on it are counted, is in
[Reserves](/docs/en/support/reserve-faq) — note that a **rolling reserve's** release date is a different
calculation, counted in business days or calendar days according to your contract.
## An older settlement is still open. In what order do retries happen?
A settlement that could not be booked because the unsettled account lacked balance is retried on
later settlement runs, in a fixed order: **oldest settlement date first**, then within the same date
**largest amount first**, until even the smallest outstanding settlement cannot be covered. Later
dates are not processed ahead of an unfinished earlier one — which is why a Portal record can show a
settlement date earlier than today and still be open.
***
Still not matching? [Contact support](mailto:support@waffo.com) with the settlement batch id or the
transaction day you are reconciling.
# Which settlement data can I actually get, and where?
Source: https://waffo.com/docs/en/support/settlement-data-availability-faq
Answers the 'can you add this field / is there an API for this' class of reconciliation question: which identifiers and dimensions exist in the transaction export, the settlement reports and the money-movement APIs, which ones do not exist at all, why three exports of the same month disagree, and which document is issued to whom.
[Settlement File Specification](/docs/en/support/settlement-file-specification-faq) is the column-by-column
contract for the three report files.
[Settlement Reports & Exports](/docs/en/support/settlement-reports-export-faq) covers how to pull a report
on demand. **This page answers the question one step before those**: for a given piece of data —
a card network reference, your customer's country, the settlement amount in your settlement
currency — does it exist at all, and if so through which channel.
Read it before asking support to add a column: several of the most-requested fields are not
"unavailable in the export", they are not held in a merchant-visible form anywhere.
## What are my three ways of getting settlement data?
| Channel | What it gives you | Shape |
| - | - | - |
| **Reconciliation report files** | Transaction Details, Settlement Details, Settlement Summary | CSV, UTF-8, per day or per settlement batch |
| **Merchant Portal exports** | Transaction list, billable-transaction list, settlement reports, withdrawal list, balance history | Background export task, CSV inside a zip |
| **API** | Order/refund/chargeback inquiry, balance inquiry, account history, withdrawal consult/create/inquiry | JSON, request/response |
There is **no API that returns a reconciliation report or a settlement batch**. Settlement-level
data reaches you as files (Portal download or SFTP delivery), never as an API response. If your
finance system needs settlement data programmatically, the integration point is the file, not an
endpoint.
## Is there an API I can use to receive my money-movement records?
Yes, for **balance and account movements** — not for settlement reports. Five merchant endpoints
exist:
| Endpoint | Purpose |
| - | - |
| `/api/v1/account/balance/inquiry` | Current balances per currency and account |
| `/api/v1/account/history/inquiry` | Account movement rows (the API equivalent of Balance History) |
| `/api/v1/withdraw/consult` | Pre-check a withdrawal: fee, receivable amount, rate |
| `/api/v1/withdraw/create` | Create a withdrawal |
| `/api/v1/withdraw/inquiry` | Withdrawal status and outcome |
See [Withdrawal & Balance API](/docs/en/support/withdrawal-and-balance-api-faq) for the request and
response contracts and the query-window limits.
So the practical split is: **account movements → API; settlement composition → file.**
## Can I get the RRN (Retrieval Reference Number, field 37) in my settlement file?
No. There is no `RRN` column in any of the three reconciliation reports, and none in the Portal
transaction export.
For card transactions the merchant-visible references are:
| Identifier | Where you get it | What it identifies |
| - | - | - |
| `Order Id` | Reports, exports, order inquiry | Waffo's identifier for the transaction |
| `Merchant Order Id` | Reports, exports, order inquiry | Your own identifier |
| `payMethodRefId` | Order inquiry, inside `payMethodResponse` | The reference your customer sees in their own payment-method statement or app |
| `schemeTransactionId` | Order inquiry, inside `payMethodResponse` | The card-scheme transaction identifier used to chain a stored-credential series |
`payMethodResponse` is a JSON string on the order inquiry response, and the object returned to you
carries a **fixed set of keys** — payment-method reference, scheme transaction id, token id, masked
card data, card metadata, the amount and currency your customer actually paid, and the rate the
payment method used. A network retrieval reference is not one of them, so it cannot be reached by
parsing that field either.
If you are trying to trace a specific transaction with a bank or an issuer, open a support case with
the `Order Id` and the payment date; that is the identifier Waffo can trace end to end.
## Can you add a column to my report — my own refund id, my customer's country, an extra reference?
The exported column set is fixed per report type. It is the same for every merchant, and it is not
extended per account. What you can control is:
| Control | Where | Effect |
| - | - | - |
| Which report types you receive | Report delivery configuration | Adds or removes whole files |
| Which fields a **Customized Export** contains | Portal, when building the export | Selects from the available field list |
| Where files land | Report delivery configuration | Portal only / Waffo SFTP / your SFTP / email |
See [Report Delivery & Email Subscription](/docs/en/support/report-delivery-and-subscription-faq).
Some fields you may be looking for are already present under a different name — check
[Settlement File Specification](/docs/en/support/settlement-file-specification-faq) before requesting
anything:
* your own reference on a payment is `Merchant Order Id`, and on a refund `Merchant Original Order Id`
identifies the payment you refunded
* the payment a refund or chargeback belongs to is `Original Order Id`
* your own user identifier, if you sent one on order create, is `Merchant User Id`
## How do I report by my customer's country? The reports only show currency.
There is **no consumer-country column**. The country dimension that exists is the country of the
**payment method**, not the location of the person paying:
| Column | Present in | Means |
| - | - | - |
| `Pay Method Country/Region` | Portal transaction export | The country/region the payment method belongs to |
| `Order Currency` | All reports and exports | The currency you charged in |
| `cardIssueCountryCode` | Order inquiry, for card payments, inside `payMethodResponse` | The card issuer's country (ISO 3166-1 alpha-3) |
Two consequences worth being explicit about:
* Grouping the transaction export by `Pay Method Country/Region` gives you **revenue by payment-method
market**, which is the closest available proxy and is what most merchants end up using. It is not
the same as your customer's residence — a customer can be anywhere and use a method registered in
one market.
* For card volume specifically, `cardIssueCountryCode` on order inquiry is a per-transaction issuer
country. It is available through the API only, not as a report column, so building an issuer-country
breakdown means enriching your own records at payment time rather than after the fact.
Portal analytics also breaks volume down by country for the payment-method dimension — see
[Portal Analytics](/docs/en/support/portal-analytics-faq) — so for a quick answer you may not need an
export at all.
## My order is in a non-USD currency. Where is the settlement-currency amount?
Not in the transaction export — that file has no settlement columns at all, because a transaction is
exported the day after it happens and its settlement may not exist yet.
| Looking for | Look in |
| - | - |
| The order amount converted to your settlement currency | `Order Amount (Settlement Currency)` in Settlement Details, and in the settlement report export |
| The rate used | `Forex Rate` / `Forex Currency Pair` in Settlement Details |
| The net amount per transaction type | Settlement Summary |
| An estimate before the settlement exists | Settlement estimation export, which carries `Est Forex Rate` and `Est Order Amount (Settlement Currency)` |
The conversion itself, including which pair and which rounding rule applies, is in
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
## Is there an API that gives me the settlement amount in my settlement currency?
No. This is the single most-asked variant of the previous question, and the answer is the file:
`Order Amount (Settlement Currency)` in Settlement Details, keyed by `Order Id`, is the row you join
to your own ledger.
If you need this as a feed rather than a download, configure delivery to your own SFTP so the file
is pushed to you and your job picks it up — see
[Report Delivery & Email Subscription](/docs/en/support/report-delivery-and-subscription-faq).
## Can I see my funds broken down per channel instead of one account balance?
No. Your money is presented to you as **accounts**, not as channel positions:
| What you see | Where |
| - | - |
| Pending settlement balance, settlement account balance, reserve balance, withdrawable amount | Portal → Account → Balance, and `/api/v1/account/balance/inquiry` |
| Every movement that changed those balances | Portal → Balance History, and `/api/v1/account/history/inquiry` |
There is no per-channel funds screen in the Merchant Portal and no per-channel breakdown in the
balance API. If you cannot find it, it is not a permission problem — the dimension does not exist on
the merchant side. See [Balance & Account History](/docs/en/support/balance-and-account-history-faq) for
what each balance means and
[Settlement Account Structure](/docs/en/support/settlement-account-structure-faq) for why one currency can
show more than one account.
## Three exports of the same month disagree. Which one is authoritative?
They disagree because they answer three different questions. None of them is wrong:
| Export | Row basis | Date basis | Use it for |
| - | - | - | - |
| Transaction list export | Every transaction, any status | Payment date | Operational volume, order-level lookups |
| Billable-transaction export | Transactions that carried a fee, including **failed** ones | Payment date | Explaining a fee you did not expect |
| Settlement report export | Transactions inside a settlement batch | Settlement date | What Waffo actually paid you |
For "what did I get paid", the **settlement report** is authoritative — it is the same data the money
movement was built from. For "what did I sell", the transaction list is authoritative.
The classic mismatch has one cause: a transaction that happened in the last days of a month settles
in the next month, so it is in one month's transaction export and the next month's settlement report.
See [Should I export by Payment Date or by Settlement Date?](/docs/en/support/settlement-reports-export-faq)
and [Settlement Cycle](/docs/en/support/settlement-cycle-faq).
## Why can't I find an order from last month's settlement report in my transaction list for that month?
Because you filtered the transaction list on a **payment date** in that month, and the order's payment
date is in the previous one. A settlement report covering a settlement date in November routinely
contains transactions paid in October.
To find it: search the transaction list by `Order Id` with no date filter, rather than narrowing by
month.
## Does filtering the transaction list on "payment successful" give me my revenue for the month?
No, for three reasons, in order of how often they bite:
1. **It is gross, not net.** Fees, taxes and reserve holds are applied in settlement, not on the
transaction. See [Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
2. **Refunds and chargebacks are separate rows** with their own dates. A payment succeeded in the
month and was refunded in the month both count once each.
3. **Successful is not settled.** Some of those transactions have not entered a settlement batch yet.
For a revenue figure, start from the settlement reports. For an accrual figure, the settlement
estimation export gives you the not-yet-settled part with `Est` columns marked as estimates.
## What does the Settlement Status column mean, and do UNSETTLED orders count as this month's revenue?
`Settlement Status` on a settlement report export tells you whether the transaction has entered a
settlement batch:
| Value | Meaning |
| - | - |
| `SETTLED` | The transaction is in a settlement batch; its settlement amounts are final |
| `UNSETTLED` | The transaction has not entered a batch; any settlement amount shown for it is an estimate |
Whether an `UNSETTLED` transaction belongs in this month's revenue is an **accounting-policy question**,
not a Waffo one: on a cash basis it does not, on an accrual basis it does and the estimate columns are
what you would accrue against. Waffo does not restate the month once those rows settle — they simply
appear as `SETTLED` in a later report.
More on the column and the export filter in
[Settlement Reports & Exports](/docs/en/support/settlement-reports-export-faq).
## What is the difference between Settlements → Transaction and the Transactions page?
They look similar and answer different questions:
| Screen | One row is | Money view |
| - | - | - |
| **Transactions** | A payment, refund or chargeback you or your customer created | What happened |
| **Settlements → Transaction** | A transaction as it entered settlement, with its fee, rate and settlement amounts | What it was worth to you |
A transaction appears on Transactions immediately and on Settlements → Transaction only once
settlement has processed it. A transaction that was billed but carried no order amount — a failed
transaction that still incurred a fee — appears on the settlement side and looks odd on the
transaction side; that case is covered in
[Failed Transaction Billing](/docs/en/support/failed-transaction-billing-faq).
## Where do I download settlement batch details now?
Portal → Settlements → Reports. Batch-level detail, Customized Export and the settlement estimation
export are all **background tasks**: you request one, it is built, and it appears in the export
history on the same screen as a downloadable file — it does not stream to your browser.
If the only export type you can produce is a narrow one, the export types available to you depend on
your account's report configuration. The full list of types and their date-span ceilings is in
[Settlement Reports & Exports](/docs/en/support/settlement-reports-export-faq); task statuses and download
link lifetimes are in [Export Center](/docs/en/support/export-center-faq).
## Which identifier ties a report row to the money that arrived in my bank?
Depends which end you start from:
| You have | Join on |
| - | - |
| A settlement report row | `Settlement Batch Id` — every row in one batch was paid as one movement |
| A credit on your bank statement | The settlement batch for that settlement date and settlement currency; a Settlement Statement PDF exists per batch |
| A withdrawal on your bank statement | The withdrawal order id, and `bankRefId` from withdrawal inquiry |
| An individual transaction | `Order Id`, which appears in both the transaction and the settlement reports |
One settlement date can produce more than one batch — see
[Settlement Batch & Calendar](/docs/en/support/settlement-batch-and-calendar-faq).
## Can I issue an invoice to my own customer from the Portal?
No. The two documents Waffo produces have fixed directions:
| Document | Issued by | Issued to | Covers |
| - | - | - | - |
| **Invoice** | Waffo | You | Waffo's fees, grouped by fee currency |
| **Settlement Statement** | Waffo | You | One settlement batch |
| **Receipt** | You, through Waffo | Your customer | One transaction |
So the Invoice in your Portal is Waffo's invoice **to you** for fees. It is not a document you can pass
to your customer, and there is no feature that issues a merchant invoice to an end customer. The
customer-facing document is the transaction **Receipt** — content, configuration and delivery are in
[Receipt Configuration & Download](/docs/en/support/receipt-configuration-and-download-faq) and
[Portal Receipts](/docs/en/support/portal-receipt-faq).
Invoice numbering, why several attachments arrive at once, and what is excluded from the Invoice are
in [Settlement File Specification](/docs/en/support/settlement-file-specification-faq).
## Can I get an invoice for a transaction that is refunded, or not yet settled?
The Invoice covers **fees on settled transactions**, and its date is derived from the latest actual
settlement date in the set it covers. A transaction that has not settled has not contributed a fee to
any invoice yet, so it is not on one. It appears on the invoice covering the period in which its
settlement lands.
***
Still stuck on a specific field? [Contact support](mailto:support@waffo.com) with the report file name
and the column you are looking for — naming the file lets support tell you whether the field exists
under another name or not at all.
# Settlement File Specification
Source: https://waffo.com/docs/en/support/settlement-file-specification-faq
Column-by-column reference for the three reconciliation report files plus the Settlement Statement and Invoice PDFs: every field's meaning, decimal precision, when it is empty, the file naming rules, where files are delivered, and how to bulk-download a date range.
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq) explains how the amounts are
calculated and how to pull a report on demand.
[Report Delivery & Email Subscription](/docs/en/support/report-delivery-and-subscription-faq) covers how
files reach you on a schedule. **This page is the field-level contract**: what every column means,
how many decimals it carries, when it is legitimately empty, and how each file is named.
## Which report files do I get, and when is each one produced?
| Report | Covers | Produced |
| - | - | - |
| **Transaction Details** | Every transaction on transaction day T, regardless of settlement | On T+1 |
| **Settlement Details** | One row per transaction inside one settlement period | The day after the settlement bank receipt is received |
| **Settlement Summary** | Totals per transaction type for one settlement currency in one settlement period | The day after the settlement bank receipt is received |
All three are **CSV, UTF-8 encoded**. The two settlement reports are keyed to a settlement batch;
the transaction report is keyed to a transaction day and is independent of your settlement cycle.
Two behaviours follow from that split and cause most "my file is missing" tickets:
* **A day with no transactions still produces a Transaction Details file** — containing the header
row only.
* **A settlement date with no net settlement produces no settlement files at all**, because no
settlement batch exists. If you need a placeholder on every scheduled date instead, that is a
per-merchant configuration — see
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
## What are the exact file names?
| File | Name |
| - | - |
| Transaction Details | `transaction_details_{merchant id}_{transaction date}.csv` |
| Settlement Details | `settlement_details_{merchant id}_{settlement batch id}_{settlement date}_{settlement currency}.csv` |
| Settlement Summary | `settlement_summary_{merchant id}_{settlement batch id}_{settlement date}_{settlement currency}.csv` |
| Settlement Statement (PDF) | `settlement_statement_{merchant id}_{currency}_{date}.pdf` |
| Withdrawal Statement (PDF) | `{merchant id}-{withdraw order id}_{account type}.pdf` — a timestamp is appended when the statement is regenerated; downloadable again at any time from **Withdrawal History** |
| Invoice (PDF) | `invoice_{currency}_{date1}_{date2}.pdf` |
* `transaction date` and `settlement date` are `YYYYMMDD` in **UTC+8**; PDF dates are `YYYYMMDD`.
* `settlement currency` is part of the settlement file names precisely because a batch is packed by
settlement currency. If one settlement date carries two settlement currencies you receive **two
sets of files**, distinguishable by that last component.
* A file that has to be regenerated is **not overwritten**: it is written again under a new version
suffix (`..._v2.0_1.csv` follows `..._v1.0_1.csv`), so an existing download you already picked up
never changes underneath you.
## Where are the files delivered, and how long do they stay there?
Files are available in the Merchant Portal and, if you use SFTP delivery, under the documented
transaction and settlement directories — see
[Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports)
for the exact paths.
**Reconciliation reports are kept on the SFTP server for a limited period only. They remain
downloadable from the Merchant Portal.** If your pull job has been offline for a while, recover the
gap from the Portal rather than from SFTP.
SFTP access supports two login methods, **username and password** or **key-based authentication**.
Key-based authentication is the recommended one.
## Transaction Details — field dictionary
| Column | Meaning | Format / precision | When empty |
| - | - | - | - |
| `Merchant Id` | The identifier Waffo assigns to you | String, up to 64 | Never |
| `Sub Merchant Id` | The identifier Waffo assigns to a secondary merchant that supplies the goods or services directly | String, up to 64 | When you do not use sub-merchants |
| `Order Id` | Waffo's identifier for the transaction — the payment id, refund id or chargeback id depending on `Transaction Type` | String, up to 32 | Never |
| `Merchant Order Id` | Your own identifier for the transaction | String, up to 32 | When you did not send one |
| `Payment Request Id` | Your own identifier for the payment request | String, up to 32 | When you did not send one |
| `Original Order Id` | The original payment transaction id | String, up to 32 | **Always empty when `Transaction Type` is `PAYMENT`**; populated for `REFUND` and `CHARGEBACK` |
| `Order Amount` | Transaction amount in the order currency | Decimals of the order currency | Never |
| `Order Currency` | Currency of `Order Amount` | ISO 4217 3-letter code | Never |
| `Transaction Type` | `PAYMENT`, `REFUND` or `CHARGEBACK` | Enum | Never |
| `Payment Method` | The payment method used | String, up to 32; same values as the API | Never |
| `Payment Option` | The sub-method under the payment method — for example paying from a wallet balance rather than a linked card | String, up to 32 | When the method has no sub-method |
| `Payment Request Datetime` | When Waffo received the payment request from you | `YYYY-MM-DD hh:mm:ss`, UTC+8 | Never |
| `Payment Date` | Date the transaction reached success | `YYYY-MM-DD`, UTC+8 | Never |
| `Payment Time` | Time of day the transaction reached success | `hh:mm:ss`, UTC+8 | Never |
| `Order Description` | Your description of the transaction | String, up to 128 | When you did not send one |
| `Product Name` | The contracted payment product, e.g. `ONE_TIME_PAYMENT` | Enum | Never |
| `Merchant User Id` | Your own identifier for your end user | String, up to 64 | When you did not send one |
`Payment Date` and `Payment Time` are two separate columns on purpose: the date column is the one
that determines which transaction day — and therefore which settlement — a transaction belongs to,
so it can be used as a grouping key without parsing a timestamp.
## Settlement Details — field dictionary
Same row grain as Transaction Details, plus the settlement result for each transaction.
| Column | Meaning | Format / precision | When empty |
| - | - | - | - |
| `Merchant Id` | Your merchant identifier | String, up to 64 | Never |
| `Sub Merchant Id` | Sub-merchant identifier | String, up to 64 | When you do not use sub-merchants |
| `Settlement Batch Id` | Waffo's identifier for this settlement within the period | String, up to 64 | Never (an all-zero value marks a placeholder file — see below) |
| `Settlement Date` | Date the settlement file is generated | `YYYY-MM-DD`, UTC+8 | Never |
| `Order Id` | Payment, refund or chargeback id, per `Transaction Type` | String, up to 32 | Never |
| `Merchant Order Id` | Your own transaction identifier | String, up to 32 | When you did not send one |
| `Payment Request Id` | Your own payment-request identifier | String, up to 32 | **Always empty when `Transaction Type` is `CHARGEBACK`**, and when you did not send one |
| `Original Order Id` | The original payment transaction id | String, up to 32 | **Always empty for `PAYMENT`**; populated for `REFUND` and `CHARGEBACK` |
| `Order Amount` | Transaction amount in the order currency | Decimals of the order currency | Never |
| `Order Amount (Settlement Currency)` | The same amount converted into the settlement currency | Your settlement decimal place — 2 or 6, zero-padded | Never |
| `Order Currency` | Currency of `Order Amount` | ISO 4217 | Never |
| `Transaction Type` | `PAYMENT`, `REFUND`, `CHARGEBACK` or `CHARGEBACK_ALERT` | Enum | Never |
| `Event Type` | `PAY_SUCCESS`, or the billed failure stage on a failed-attempt row — see [Billing for Failed Card Transactions](/docs/en/support/failed-transaction-billing-faq) | Enum | Never |
| `Payment Method Type` | The category of the payment method | String, up to 32 | Never |
| `Payment Method` | The payment method used | String, up to 32 | Never |
| `Payment Option` | Sub-method under the payment method | String, up to 32 | When the method has no sub-method |
| `Payment Request Datetime` | When Waffo received your payment request | `YYYY-MM-DD hh:mm:ss`, UTC+8 | Never |
| `Payment Date` | Date the transaction succeeded | `YYYY-MM-DD`, UTC+8 | Never |
| `Payment Time` | Time the transaction succeeded | `hh:mm:ss`, UTC+8 | Never |
| `Product Name` | Contracted payment product | Enum | Never |
| `Forex Currency Pair` | The pair used for the FX step in this row, as `AAA/BBB` | ISO 4217 codes, slash-separated | When no FX was involved — see the priority table below |
| `Forex Rate` | Rate for `Forex Currency Pair`, supplied by the foreign exchange partner | 8 decimals, zero-padded | Whenever `Forex Currency Pair` is empty |
| `Fee Amount` | Waffo's processing fee, in the fee currency | 6 decimals, zero-padded | Never |
| `Fee Amount (Settlement Currency)` | The same fee converted into the settlement currency | Settlement decimal place, zero-padded | Never |
| `Fee Currency` | Currency of `Fee Amount` | ISO 4217 | Never |
| `Tax Amount` | Transaction tax (VAT and/or withholding tax) on this transaction, in the tax currency | 6 decimals, zero-padded | When no transaction tax applies to you |
| `Tax Amount (Settlement Currency)` | The same tax converted into the settlement currency | Settlement decimal place | When no transaction tax applies |
| `Tax Currency` | Currency of `Tax Amount` | ISO 4217 | When no transaction tax applies |
| `Settlement Amount` | Net settlement for this row | Settlement decimal place, zero-padded; **negative for `REFUND` and `CHARGEBACK`** | Never |
| `Settlement Currency` | Currency of `Settlement Amount` | ISO 4217 | Never |
| `Comments` | A marker on rows that belong to a refund-reserve settlement | String | On an ordinary settlement |
Tax is reported as a single `Tax Amount` column whichever tax it is; there is no separate VAT or
withholding-tax column — see [Fee & Tax Treatment](/docs/en/support/fee-and-tax-treatment-faq).
## Settlement Summary — field dictionary
One row per transaction type, plus a `TOTAL` row, for a single settlement currency.
| Column | Meaning | Format / precision | When empty |
| - | - | - | - |
| `Merchant Id` | Your merchant identifier | String, up to 64 | Never |
| `Settlement Batch Id` | Identifier of this settlement | String, up to 64 | Never |
| `Settlement Date` | Date the file is generated | `YYYY-MM-DD`, UTC+8 | Never |
| `Transaction Type` | `TOTAL` (all transactions), `PAYMENT`, `REFUND`, `CHARGEBACK`, `CHARGEBACK_ALERT` | Enum | Never |
| `Transaction Count` | Number of transactions of that type | Integer | Never |
| `Fee Amount (Settlement Currency)` | Processing fee for that type, in the settlement currency | Settlement decimal place | When that type carried no fee |
| `Tax Amount (Settlement Currency)` | Transaction tax for that type, in the settlement currency | Settlement decimal place | When no transaction tax applies |
| `Settlement Amount` | Net settlement total for that type | Settlement decimal place; negative for `REFUND` and `CHARGEBACK` | Never |
| `Settlement Currency` | Currency of the amounts | ISO 4217 | Never |
| `Comments` | Marker for a refund-reserve settlement | String | On an ordinary settlement |
There is **no `Order Amount` column in the summary**. Every monetary column is already expressed in
the settlement currency, which is what makes the rows addable — amounts in different order, fee and
tax currencies are never summed in their original currencies.
## Why does my file show six decimal places when the currency has two?
Because settlement precision is a per-merchant configuration. Your account is set to either **2 or 6
decimal places**, and it is fixed once configured. On a 6-decimal account, settlement-currency
amounts are written with six decimals and **zero-padded** to that width, so a whole number appears as
`99.000000`. `Order Amount` in the *original* order currency keeps that currency's own decimals and
is not padded.
Fixed widths, always zero-padded when the value is shorter:
| Value | Decimals |
| - | - |
| Forex rate | 8 |
| Fee and tax amounts | 6 |
| Settlement-currency amounts | your settlement decimal place (2 or 6) |
On a 6-decimal account this is what makes the detail rows tie exactly to the batch: the sum of the
detail `Settlement Amount` values equals the batch amount without a residual.
## When does Forex Currency Pair have a value, and which pair?
Fees and taxes can be billed in a currency that is neither your order currency nor your settlement
currency, so the pair is resolved by priority — **the first condition that matches wins**:
| Order of evaluation | Condition | Pair |
| - | - | - |
| 1 | Settlement Currency ≠ Order Currency | `Settlement/Order` |
| 2 | Settlement Currency ≠ Fee Currency | `Settlement/Fee` |
| 3 | Settlement Currency ≠ Tax Currency | `Settlement/Tax` |
| — | None of the above | empty — no FX was involved |
Rows that carry no order amount — a billed failed attempt, or a chargeback alert — evaluate the fee
currency only; a chargeback that was won evaluates the order currency only.
So a row can carry a pair even when your order currency already equals your settlement currency: the
FX step in that row was for the fee or the tax, not for the order. `Forex Rate` is empty exactly when
the pair is empty.
## I received a settlement file with a batch id of all zeros. What is it?
That is a **placeholder**, produced only if your account is configured to generate a file on every
scheduled settlement date rather than only when a settlement happened:
```
settlement_details_{merchant id}_S000...000_{settlement date}_{settlement currency}.csv
settlement_summary_{merchant id}_S000...000_{settlement date}_{settlement currency}.csv
```
It contains the header row and nothing else. The all-zero batch id is deliberate — no settlement
batch exists, so there is no real id to put there, and **no Settlement Batch record appears in the
Merchant Portal**. Treat it as a delivery heartbeat that separates "there was nothing to settle" from
"the file did not arrive", not as a settlement event.
## Can I download a whole month of settlement details in one go?
Yes, from the Settlement Batch list. Select a range of **Actual Settlement Date** and download the
batches together:
| Rule | Value |
| - | - |
| Maximum span per request | **1 month** — a longer selection is rejected before the task is created |
| Row order | By settlement date, **ascending** |
| Rows per file | **60,000** data rows, excluding the header |
| One file result | `settlement_details_{merchant id}_{min settlement date}_{max settlement date}.csv`, no sequence suffix, downloaded directly |
| Multiple file result | `settlement_details_{merchant id}_{min settlement date}_{max settlement date}.zip`, containing `..._001.csv`, `..._002.csv`, … |
`min settlement date` and `max settlement date` are the earliest and latest settlement dates in your
selection, `YYYYMMDD` UTC+8. The request runs asynchronously — collect the result from the Reports
module, as described in [Exports & Report Downloads](/docs/en/support/export-center-faq).
For a range you want to slice differently — by payment date, by order currency, or as a daily summary
rather than transaction by transaction — use Customized Export instead; its options are listed in
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq).
## What formatting conventions do the PDF documents follow?
The Settlement Statement, Withdrawal Statement and Invoice share one layout and one set of
conventions, so a value means the same thing in all three:
| Convention | Rule |
| - | - |
| Amounts | Thousands separator, **2 decimals** |
| Currency position | **Before** the amount: `{Currency} {Amount}` |
| Country/Region | **ISO 3166-1 alpha-3** — `USA`, `CHN`, `HKG` |
| Labels | Each word capitalised |
| Totals | `Total` is **not** a row inside the table; it is stated separately below it |
| Rate column name | Always **`Forex Rate`** — never `Fx` |
| Rate with no conversion | Shown as `--`; the column is kept rather than dropped |
The page order is fixed as well: the issuing Waffo entity's name and address, then the document block
(date and number), then your company name and address, then the business detail table.
## What is in the Settlement Statement, and how is its rate calculated?
Columns are **Country/Region, Transaction Amount, Fee Amount, Tax Amount, Avg. Forex Rate and
Settlement Amount**, with the totals stated below the table. `Tax Amount` is formatted like every
other amount, `{Tax Currency} {Tax Amount}`.
`Avg. Forex Rate` is a **weighted average, not a simple mean**. Each component rate is weighted by
its own settlement amount:
```
Avg. Forex Rate = Σ (settlement amount ᵢ × forex rate ᵢ) / Σ (settlement amount ᵢ)
```
carried to 8 decimals. Dividing total original amount by total settlement amount gives a slightly
different figure, which is why recomputing it that way will not reproduce the statement.
## What is in the Invoice, and why are there several attachments?
The Invoice covers **fees**, grouped by fee currency:
| Field | Rule |
| - | - |
| `Invoice No` | Your Merchant ID + the latest actual settlement time in the covered set + the fee currency |
| `Invoice Date` | The **latest actual settlement date** among the settlements covered |
| `Commission` | The fee amount with its currency |
| Multiple fee currencies | **One attachment per fee currency** — the amounts are never merged across currencies |
Statement and Invoice emails are sent from a Waffo finance mailbox; reply to
[support](mailto:support@waffo.com) rather than to the sender if you have a question about one.
**IOF is not on the Invoice.** Where IOF applies to a transaction, Waffo cannot issue an invoice for
it, so it is excluded from the invoice document. It still appears in the settlement calculation.
***
Reconciling a specific file and something still does not tie?
[Contact support](mailto:support@waffo.com) with the file name — the settlement batch id and
settlement currency in it are enough to locate the settlement.
# Settlement Reports Module
Source: https://waffo.com/docs/en/support/settlement-reports-export-faq
How the Settlement Reports module in the Merchant Portal works: choosing a payment-date or settlement-date dimension, the export types and their date-span limits, which currency selector applies, how Order Amount and Fee Amount are defined in a summary, when an export splits into a zip, and how to read the export task list.
[Settlement Reports & Exports](/docs/en/support/settlement-reports-faq) explains how the amounts are
calculated. [Settlement File Specification](/docs/en/support/settlement-file-specification-faq) is the
column-by-column contract for the delivered files. **This page is about the module itself** — where it
lives, which options it offers, and which limits stop an export before it starts. For scheduled email
delivery of the same reports, see
[Report Delivery & Email Subscription](/docs/en/support/report-delivery-and-subscription-faq).
## Where do I generate a settlement report?
**Settlement → Settlement Reports.** One page carries both halves of the job:
| On the page | What it does |
| - | - |
| **Customized Export** button | Opens the export form: dimension, date range, export type, currency, language |
| The list below it | Every export task you have started, with its status and a download link |
| **Email Subscription** tab | Recurring email delivery of the same reports, where enabled for your account |
You do not need a settlement batch to start from. Batch-by-batch downloads still exist on each
Settlement Batch detail page, and the transaction list has its own export — the Settlement Reports
module is the one to use when your reconciliation period does not line up with a single batch.
## Should I export by Payment Date or by Settlement Date?
This is the single most consequential choice on the form, and it decides **which transactions can
appear at all**:
| Dimension | Scope | Use it when |
| - | - | - |
| **Payment Date** | Transactions by the date they were paid — **including transactions that have not settled yet** | You are closing a calendar period and need everything transacted in it, settled or not |
| **Settlement Date** | Only what has actually settled, by the date it settled | You are reconciling against money received in your settlement account |
The form says so explicitly when you pick Payment Date: *Includes estimated data for completed
transactions pending settlement.*
This is the answer to the classic month-end problem. With a settlement cycle of T+N, transactions from
the last days of a month have not settled when you close the books on the 1st. Exporting by
**Settlement Date** will legitimately not show them. Exporting by **Payment Date** will.
## Which export types are there, and what is the date-span limit for each?
| Export type | Grain | Maximum span |
| - | - | - |
| **Daily Settlement Summary** | One row per date and transaction currency | **31 days** |
| **Transaction Settlement Detail** | One row per order, with settled and pending data together | **3 months** |
| **Selected Period Summary** | Totals for the whole selected range | **12 months** |
Selecting a longer range is refused when you submit, before any task is created — so a rejected export
is not a failed export, and there is nothing to retry. Shorten the range and submit again. Future
dates cannot be selected at all.
## Which currency does the export filter on?
It depends on the export type, and the label on the field tells you which:
| Export type | Field label | Meaning |
| - | - | - |
| Daily Settlement Summary | **Order Currency** | Filter by the currency your customers paid in |
| Transaction Settlement Detail / Selected Period Summary | **Settlement Currency** | Filter by the currency you were settled in |
Leaving it as **All Currencies** is valid in both cases. Changing the export type clears whatever you
had selected, because the option list is a different one.
## What language are the report headers in?
The export form offers a **Report Language** — `English`, `中文`, `にほんご` — displayed in each
language's own script rather than translated into your interface language. It applies to the report
you are generating, not to the Portal, and it is independent of the interface language you use.
| Export type | Header language |
| - | - |
| **Transaction Settlement Detail** | The Report Language you selected |
| **Selected Period Summary** | The Report Language you selected |
| **Daily Settlement Summary** | English — the selector is not shown for this type |
Column **values** — currency codes, status values, identifiers — are the same in every language;
only the header row and the section labels change. If you parse the file by header name, pin the
language on your subscription or export rather than matching on translated text.
## What is in the Transaction Settlement Detail report?
One row per transaction event, **settled and not-yet-settled together**, keyed by the `Settlement
Status` column (see below). The two kinds of row populate different columns:
| Row kind | Populated | Left empty |
| - | - | - |
| `SETTLED` | `Settlement Batch Id`, `Settlement Date`, `Forex Rate`, `Fee Amount`, `Tax Amount`, `Settlement Amount` and their `(Settlement Currency)` variants | Every `Est …` column |
| `UNSETTLED` | `Est Settlement Date`, `Est Forex Rate`, `Est Order Amount (Settlement Currency)`, `Est Fee Amount`, `Est Tax Amount`, `Est Settlement Amount` and their `(Settlement Currency)` variants | The actual settlement columns |
Identifier, order, payment-method and timestamp columns (`Order Id`, `Merchant Order Id`,
`Payment Request Id`, `Original Order Id`, `Order Amount`, `Order Currency`, `Transaction Type`,
`Event Type`, `Payment Method Type`, `Payment Method`, `Payment Option`, `Payment Request Datetime`,
`Payment Date`, `Payment Time`, `Product Name`) and the currency columns (`Fee Currency`,
`Tax Currency`, `Settlement Currency`) are shared and carry no `Est` prefix in either case; a
`Comments` column marks refund-reserve settlement rows. So a single file answers both "what did I
get paid" and "what is still owed", and an `Est` figure turning into an actual figure between two
exports is the settlement running, not a data change.
## In a summary, how are Order Amount and Fee Amount defined?
These two definitions cause more reconciliation disputes than any other, because neither is a gross
figure:
```
Order Amount = payment − refund − chargeback
Fee Amount = payment fee (successful and failed transactions)
+ refund fee
+ chargeback fee
+ chargeback alert fee
```
Consequences worth stating plainly:
* **`Order Amount` is net.** Refunds and chargebacks are already subtracted. Comparing it to your own
gross sales figure will always differ by exactly your refunds and chargebacks.
* **`Fee Amount` can include fees on transactions that did not succeed**, if your contract prices
failed attempts. Those rows carry a fee with no order amount, which is correct rather than a data
error.
* Payments are positive; refunds, chargebacks and chargeback alerts are negative.
## What columns does the Daily Settlement Summary have?
Ten, in this order:
| # | Column |
| - | - |
| 1 | `Payment Date` |
| 2 | `Currency` |
| 3 | `Order Amount` |
| 4 | `Fee Amount` |
| 5 | `Tax Amount` |
| 6 | `Order Amount (Settlement Currency)` |
| 7 | `Fee Amount (Settlement Currency)` |
| 8 | `Tax Amount (Settlement Currency)` |
| 9 | `Settlement Currency` |
| 10 | `Settlement Amount` |
Every original-currency column is paired with its settlement-currency equivalent, and tax is one
column whichever tax it is — there is no separate VAT or withholding-tax column. Only the
settlement-currency columns are addable across rows — amounts in different order, fee and tax
currencies are never summed in their original currencies.
## Why does one date produce several rows in a daily summary?
Because a day is only one row when the transaction currency and the fee currency are the same. When
they differ, or when several fee currencies were involved on that day, the day splits into **one row
per fee currency**. VAT and withholding tax currencies are folded into the fee currency rather than
splitting the row further.
So a day with orders in one currency but fees billed in two produces two rows for that date, whose
settlement-currency columns add up to the day's total.
## Why did my export come back as a zip?
Two independent reasons, and either alone is enough:
| Trigger | Result |
| - | - |
| The result spans **more than one settlement currency** | One file per settlement currency, packed into a zip. Summarising requires a single settlement currency, so they cannot share a file |
| The result exceeds **60,000 data rows** | Split into sequentially numbered files (`_001`, `_002`, …), packed into a zip |
A single-currency result under the row limit downloads as one file with no zip and no sequence suffix.
## What do the export task statuses mean?
| Status | Meaning |
| - | - |
| **Pending** | The task is queued or the file is still being built |
| **Success** | Ready — the Report column becomes a download link |
| **Failed** | Generation did not complete. Start the export again |
Exports are asynchronous by design: you start one, leave the page, and collect the file later. The
list shows the export date, the status, the export type and the file. General behaviour of the export
queue, including link expiry, is covered in
[Exports & Report Downloads](/docs/en/support/export-center-faq).
## What does the Settlement Status column on an export mean?
It tells you whether a given transaction had settled as at the export, and it has **four** values —
not two:
| Value | Meaning |
| - | - |
| `SETTLED` | The transaction has settled. Actual settlement figures are populated |
| `UNSETTLED` | The transaction has not settled yet |
| `UNDEFINED` | The transaction is mid-flight — for example a refund still in progress — so neither answer is meaningful yet |
| `NOT_APPLICABLE` | Settlement does not apply: the order was closed, a refund failed, or the record is a customer dispute |
Reading `UNDEFINED` or `NOT_APPLICABLE` as "not settled" will overstate what is still owed to you.
Filter on `UNSETTLED` explicitly rather than on "not `SETTLED`".
## I still see a Settlement Estimation menu. Which one should I use?
If your account still has it, Settlement Estimation shows pre-settlement estimates on their own, with
their own 31-day export. It is superseded in practice by exporting from **Settlement Reports with the
Payment Date dimension**, which returns settled and not-yet-settled transactions in one file so you
do not have to export twice and merge.
Which menus your account sees is set per merchant. If you would rather work from the single unified
export, ask your account manager.
***
Numbers not matching between two exports of the same period?
[Contact support](mailto:support@waffo.com) with both export dates and the dimension you chose —
Payment Date versus Settlement Date explains most of these.
# Settlement Reports & Exports
Source: https://waffo.com/docs/en/support/settlement-reports-faq
How Waffo calculates every amount in your settlement reports, which rounding rule applies to each column, how to use Settlement History, Settlement Batch and Customized Export, and why a settlement file may be missing or empty.
This page explains **how the numbers in your settlement reports are produced** and
how to get the data out of the Merchant Portal. Field definitions, CSV layouts and
SFTP paths live in [Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports).
## How is each amount calculated, and why is my fee one cent off?
Each detail row is calculated per transaction, in the settlement currency:
| Transaction type | Settlement Amount |
| - | - |
| `PAYMENT` | Order Amount (Settlement Currency) − Fee − Tax |
| `REFUND` | −Order Amount (Settlement Currency) − Fee − Tax (+ returned fee, if your contract has one) |
| `CHARGEBACK` | −Chargeback Amount (Settlement Currency) − Chargeback Fee |
The one-cent gap comes from the rounding rules, which differ by column:
| Rule | Meaning | Applied to |
| - | - | - |
| `ROUND2` | Round half up, 2 decimals | Amount conversion into the settlement currency, and the summary `TOTAL` |
| `ROUNDUP2` | Round **up**, 2 decimals | Fee amount, tax amount |
| `ROUNDDOWN2` | Round **down**, 2 decimals | The returned fee on a refund row |
On a 2-decimal account fees and taxes round up, so a figure you compute with
ordinary half-up rounding can land a cent below the report. On a **6-decimal**
account the fee is rounded **half up at 6 decimals** instead — see
[Settlement Calculation Explained](/docs/en/support/settlement-calculation-explained).
A chargeback fee is taken as reported
for that case, not recalculated from your fee rate, so it never matches a
rate × amount formula. The summary `TOTAL` is the detail rows summed and rounded
once at the end: `TOTAL = ROUND2(Σ detail Settlement Amount)`.
## Do I get the original payment fee back when I refund?
If your contract includes a returned fee, the refund row carries it back as a
**positive** component of that row's Settlement Amount, pro-rated against the
original transaction:
```
Returned fee = ROUNDDOWN2( refund amount / original order amount × original fee amount )
```
The settlement-currency figure is pro-rated the same way, using `ROUND2`. A
partial refund returns only the matching share; a full refund returns all of it.
With no returned fee in your contract, the row has no such component.
## Why does Forex Currency Pair have a value when my order currency matches the settlement currency?
Fees and taxes can be billed in a third currency. The pair is resolved by
priority — the first condition that matches wins:
| Order of evaluation | Condition | Pair |
| - | - | - |
| 1 | Settlement Currency ≠ Order Currency | `Settlement/Order` |
| 2 | Settlement Currency ≠ Fee Currency | `Settlement/Fee` |
| 3 | Settlement Currency ≠ Tax Currency | `Settlement/Tax` |
| — | None of the above | empty (no FX involved) |
`Forex Rate` is empty whenever `Forex Currency Pair` is empty.
## Settlement History or Settlement Batch — which one should I use?
| Menu | Unit | Default window | Use it for |
| - | - | - | - |
| **Settlement History** | One settlement instruction | Most recent 90 days (UTC+8) | Status (`Succeeded` / `Failed` / `Unsettled`) and the execution timeline, including retries |
| **Settlement Batch** | Merchant + actual settlement date + settlement currency | Most recent 30 days | Reconciling what landed in your settlement account, and downloading batch transaction details |
Instructions still in their initial state are not listed. Batches are packed by
**settlement currency**, so one batch and one set of files cover every order
currency inside it, with the forex rate shown per transaction; date fields are
labelled `(UTC+8)`. `Settlement Original Amount` is the net amount summed in the
**order currency**; `Settlement Target Amount` is the same money **after
conversion** — identical when both currencies match, and empty until conversion
completes rather than missing data.
## What can Customized Export give me, and where do I download it?
| Option | Choices |
| - | - |
| Time dimension | Payment Date or Settlement Date |
| Currency | One currency, or all currencies — order currency for the daily summary, settlement currency for the other two types |
| Export type | *Transaction Settlement Detail*, *Daily Settlement Summary* or *Selected Period Summary* |
| Report Language | English, 中文 or にほんご for the detail and period summary types; the daily summary is English |
All columns of the chosen type are emitted; there is no per-column selection. In a
daily summary, `Order Amount` is **payment − refund − chargeback**, not gross
payments. A day is one row when the transaction currency equals the fee currency;
when they differ, or several fee currencies exist, the day splits into one row per
fee currency, with the tax currency folded into the fee currency. Summarising needs
a single settlement currency, so where several are involved the export is split into
separate files.
Detail exports are paginated at **60,000 rows** per file and run asynchronously —
start the export, then collect the file from the **Reports** module. The full
option set, span limits and column layouts are in
[Settlement Reports Module](/docs/en/support/settlement-reports-export-faq).
## What is in the batch transaction detail file?
Download it from a batch to reconcile transaction by transaction against your
settlement account entries. It is named
`SETTLEMENT_{MID}_{settlement success date YYYYMMDD}_{SETTLEMENT_REFERENCE_ID}.csv`,
Its columns are built to join against your own records: the settlement reference
and date, your own order / payment-request / refund-order identifiers, the Waffo
order and refund identifiers, the transaction type (payment or refund), the
transaction success time, and the order, fee, tax and settlement amount/currency
pairs. For the exact column headers and their formats, always read
[Reconciliation reports](/docs/en/developer-docs/tools-and-references/references/reconciliation-reports)
and [Settlement file specification](/docs/en/support/settlement-file-specification-faq)
rather than hard-coding names from this page.
## There is no settlement file today — did settlement fail, or did the file fail?
By default, no settlement means no file: `GENERATE_ON_VALID_SETTLEMENT` produces
details and summary files only when a valid settlement occurred. To always
receive a file, so "nothing to settle" is never confused with "file generation
problem", ask support to switch your account to `GENERATE_ALWAYS`. You then get a
**header-only file on every scheduled settlement date**, deferred ones included,
named with an all-zero batch id placeholder:
```
settlement_details_{merchant id}_S000...000_{settlement date}_{settlement currency}.csv
settlement_summary_{merchant id}_S000...000_{settlement date}_{settlement currency}.csv
```
Those placeholders are a delivery signal only — no Settlement Batch record is
created in the Merchant Portal, which is how you tell them apart from a real
settlement.
# Stablecoin Exceptions & Refunds
Source: https://waffo.com/docs/en/support/stablecoin-exceptions-faq
What happens when a shopper overpays, underpays, pays late, or pays on the wrong chain or with the wrong token — where you can and cannot see those cases, the exact address format a USDC/USDT refund must use, and why a stablecoin refund is all-or-nothing.
What happens when a stablecoin payment does not arrive as expected, and what you
must send when *you* refund a USDC or USDT order. Order fields, chain selection
and confirmation behaviour are in [Stablecoin Payments](/docs/en/support/stablecoin-payments-faq);
rules common to all payment methods are in the [Refund FAQ](/docs/en/support/refund-faq).
## What are the exception cases, and does the order still succeed?
Waffo classifies an off-spec transfer into one of these cases — the label is what support uses when you raise a ticket.
| Case | What happened | Order succeeds? | Where the funds go |
| - | - | - | - |
| `OVERPAID` | Received amount above the order amount | Yes | You are settled on the order amount; the excess goes back to the paying address |
| `UNDERPAID` | Received amount below the order amount | No | Returned to the paying address |
| `LATE_PAID` | Transfer arrives after the payment window closed | No | Returned to the paying address |
| `WRONG_TOKEN` | A token other than the order's USDC/USDT was sent | No | Held for manual handling |
| `WRONG_CHAIN` | Sent on a chain other than the order's | No | Held for manual handling |
| `NO_MATCH_ORDER` | The transfer cannot be tied to any order | No | Held for manual handling |
| `UNSUPPORTED_WALLET_ADDRESS` | The paying address cannot receive an automatic return | As per the case above | Held for manual handling |
Two rules run through the table: settlement is always based on the **order
amount**, never on what was transferred; and a return of funds to the shopper is
not a refund on your account — no refund order, nothing in your refund reports.
## The shopper overpaid — do I get the extra?
No, the order completes at the order amount and the surplus is not yours. A small
excess — within a tolerance of the order amount, or smaller than the cost of
sending it back — is not returned and is not credited to you either; a larger
excess goes back to the paying address, less that transfer cost. So never
reconcile a stablecoin order against the on-chain amount.
## The shopper underpaid or paid too late — can I still fulfil?
No, neither case pays the order, so do not release goods on the strength of an
on-chain transfer you observed yourself. Waffo sends the received amount back to
the paying address less the transfer cost; if it does not cover that cost, nothing
is sent back. Guide shoppers to pay the full order amount in one transfer, inside
the payment window on the payment page.
## Wrong chain, wrong token, or an old address — will it auto-resolve?
No. `WRONG_TOKEN`, `WRONG_CHAIN` and `NO_MATCH_ORDER` are never handled
automatically: the order does not succeed and the funds do not go back on their
own. Raise a support ticket with the transaction hash, chain, token and order id.
The usual cause of `NO_MATCH_ORDER` is a reused receiving address — addresses are
issued per order, so if you cache one or let a shopper pay in twice, the second
transfer matches no order.
## Why is a return slow when the shopper paid from an exchange?
Automatic returns only go to a self-custody wallet (an EOA). Funds sent from an
exchange withdrawal address or a contract address cannot safely be returned
there — the address is not the shopper's own — so the case goes to manual
handling and takes considerably longer. Where you can, tell shoppers to pay from
their own wallet rather than straight from an exchange account.
## Where can I see these exception cases myself?
Nowhere in the Portal, and this is the honest answer rather than a missing
screen: no merchant-facing view carries on-chain detail. The transaction list and
the transaction detail have no chain column, no transaction hash, no confirmation
count and no received-amount-versus-order-amount comparison, so an overpay, an
underpay or a wrong-chain transfer is not something you can look up.
What you can see is the **order**: it either reached a final success state or it
did not. Everything above is a statement about where the funds went, not about
the order, and none of these cases creates a refund order on your account — so
they never appear in your refund list or refund reports either.
That leaves one route when a shopper insists they paid: raise the order id with
support, together with the transaction hash, chain and token the shopper can give
you. Those four facts are what the case is identified by.
The one exception is the x402 signature path, where a successful order returns an
on-chain settlement receipt on the API, transaction hash included — see
[x402 stablecoin acquiring](/docs/en/developer-docs/use-cases/x402-stablecoin-acquiring).
## What must I send when I refund a stablecoin order?
A refund destination address is mandatory for every USDC and USDT refund. Send it
in `extendInfo` on `/api/v1/order/refund`. The outer `extendInfo` value is a string
containing JSON, not a nested JSON object.
Here is a complete request body. Replace each placeholder with the value for your
order and customer:
```json theme={null}
{
"refundRequestId": "refund_20261008_001",
"acquiringOrderId": "",
"merchantId": "",
"requestedAt": "2026-10-08T14:30:00.000Z",
"refundAmount": "10.00",
"refundReason": "Customer requested a refund",
"extendInfo": "{\"refundAddress\":\"\"}"
}
```
Set `refundAmount` to the full refundable order amount. The refund address must be
valid for the original payment chain. Omit it and the request fails parameter
validation up front with `A0003` and the message
`Parameter validation failed ( missing refund optional field )`.
`msgParams.missingFields` contains `extendInfo.refundAddress`. **No refund order is
created**, so there is nothing to inquire about afterwards; on fiat orders it is
unnecessary and ignored if sent. Treat this as an integration change:
if you already call the refund API and then add stablecoin acceptance, update your
refund path first. Collect the address from the shopper yourself — Waffo cannot
contact your shopper to ask for one.
## Can I refund to a different chain, or to any wallet?
No to both. The chain is read from the original payment and locked; there is no
chain parameter on the refund request and any attempt to supply one is ignored.
The chain determines which address format is accepted, and the address is
validated against that chain's format when the refund is processed:
| Address family | Format accepted | Also rejected |
| - | - | - |
| EVM chains (Ethereum, Polygon, Arbitrum, Base, Optimism, Avalanche, BNB Smart Chain) | `0x` followed by 40 hexadecimal characters | The all-zero address. On Avalanche, X-Chain and P-Chain addresses — a C-Chain EVM address is required |
| Solana | 32–44 Base58 characters, case-sensitive | — |
| Tron | `T` followed by 33 Base58 characters, checksummed | An address whose checksum does not verify |
Two format details worth building into your own input validation, because they
are checked and they surprise people:
* **Mixed-case EVM addresses are checksum-verified (EIP-55).** An address that is
entirely lower-case or entirely upper-case is accepted as-is; one with mixed
case must have a valid EIP-55 checksum. So an address that has been "tidied up"
by hand — one letter re-cased — fails, while the same address all in lower case
passes.
* **Tron addresses are Base58Check-decoded**, not just pattern-matched. A
single mistyped character fails the checksum rather than being accepted and
failing later on-chain.
A rejected address fails with a message beginning
`INVALID_REFUND_ADDRESS_FORMAT`, followed by the specific reason. Unlike a
*missing* address, a *malformed* one is caught one step later — after the refund
order has already been created — so the outcome is a **refund order in
`ORDER_REFUND_FAILED`**, visible on refund inquiry and in the Portal's Refunds
list with that reason. Nothing moved on-chain. Because the failed record already
exists, retrying with a corrected address is a **fresh request with a new
`refundRequestId`**: resending the old id returns the failed record, not a new
attempt. Check `remainingRefundAmount` before you resend.
Use a **self-custody (EOA) wallet address**. A contract-based wallet, including a
multi-signature wallet, is not supported as a refund destination, and the Portal
states the same rule in the refund drawer.
The Portal applies the same constraints: in the refund drawer the chain is a
read-only label for the original payment chain, noted as *"Refund must use the
same chain as the original payment,"* the address field is validated against that
chain's format as you type, and the detail page then shows the address used. No
Portal view surfaces on-chain detail — no transaction hash, confirmation count or
network fee.
## Can I issue a partial refund on a stablecoin order?
No. The published
[payment method catalogue](/docs/en/essentials/payment-methods) lists `USDC` and
`USDT` as **full refund only**, and the refund-shape check enforces it: a refund
for less than the full amount is refused, and so is a second refund after a full
one. Confirm the shape that applies to your own agreement on the `Refundable`
column of **Service → Payin**, or `refundAllowed` on your configuration response
— the four shapes and their codes are explained in
[Reading Your Payment Method Configuration](/docs/en/support/payment-method-config-and-limits-faq).
So plan a stablecoin refund as one all-or-nothing action per order. If your
business needs partial value returned, that is a commercial arrangement outside
the refund API, not a partial refund.
## Who pays the network fee on a refund?
Waffo. The on-chain cost of a merchant-initiated refund is not deducted from your
settlement balance and not deducted from what the shopper receives — the shopper
gets the full refund amount. That is the opposite of the exception cases above,
where the return to the shopper is net of the cost of sending it.
# Stablecoin Payments
Source: https://waffo.com/docs/en/support/stablecoin-payments-faq
Accepting USDC and USDT: which of the two integration paths to pick, the exact chain values to send, how the receiving address works in each path, amount and decimal rules, the payment window, what a shopper's completed transfer does and does not mean, fiat settlement, and where a stablecoin order shows up in the Portal.
This page covers stablecoin acceptance through the normal wallet / hosted-cashier
path. The AI-Agent path built on the x402 protocol has its own scope, request
fields and error behaviour, and is documented in
[x402 stablecoin acquiring](/docs/en/developer-docs/use-cases/x402-stablecoin-acquiring).
Exception cases and refunds are in
[Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq).
Acceptance is enabled per merchant contract — ask the integration team to
provision the CRYPTO payment product and your chain set before you build.
## How many ways are there to accept stablecoins, and which do I pick?
Two, and they are not variants of one integration — they differ in who the payer
is and in what your server does:
| | Wallet / hosted-cashier path | x402 signature path |
| - | - | - |
| The payer is | A person with a crypto wallet | Typically an AI Agent |
| Documented on | This page | [x402 stablecoin acquiring](/docs/en/developer-docs/use-cases/x402-stablecoin-acquiring) |
| Currencies | `USDC` and `USDT` | `USDC` only |
| Chains | Your contracted chain set | Base only |
| How the payer pays | Transfers to a receiving address | Signs an on-chain authorization |
| Receiving address | Issued per order | A merchant-level address returned by `wallet/inquiry` |
| Payment window | The order's expiry | The timeout in the 402 challenge |
| Refund | `refundAddress` on `order/refund` | Same |
Pick on the payer, not on preference: a human with a wallet cannot pay an x402
challenge, and an Agent has no browser to render a checkout in. If you sell to
both, you integrate both.
## What do I send on order creation to accept stablecoins?
Three things identify a stablecoin order on the wallet path:
| Field | Value |
| - | - |
| `payMethodType` | `CRYPTO` |
| `payMethodName` | `USDC` or `USDT` |
| `payMethodProperties.cryptoChain` | The chain the shopper will pay on — [values below](#what-exactly-do-i-put-in-the-chain-field) |
The method is catalogued under the global region code `GLB` rather than a single
country, so it is not filtered by the shopper's country. Pricing works like any
other cross-currency order: keep `orderCurrency` and `orderAmount` in your
pricing currency (typically `USD`), and set `userCurrency` to `USDC` or `USDT` —
the currency the shopper actually pays in. See
[Pricing currency strategy](/docs/en/developer-docs/use-cases/pricing-currency).
## Do I have to make the shopper pick a chain myself?
Only if you run your own checkout page.
* **Waffo-hosted cashier** — omit `payMethodProperties.cryptoChain`. The
consultation step returns it as a missing field together with the chain
options available for the order, and the cashier collects the shopper's choice
before the order is finalized.
* **Your own checkout page** — present the chain choice yourself and send the
selected value back in `payMethodProperties.cryptoChain`.
Treat that returned option list as authoritative: it reflects what is available
for your account and that stablecoin, which can be narrower than the
platform-wide list below.
## Can I offer only one chain, so the shopper cannot pick the wrong one?
Yes — that is what the option list is for. The chains offered for an order come
from your account's configuration, so a single-chain configuration produces a
single-chain option list and, on the hosted cashier, no chain selector worth
speaking of. Ask your integration contact for the chain set you want rather than
filtering the list client-side: a client-side filter still leaves the wider set
acceptable on `order/create`.
Note what this does **not** prevent. Narrowing the chains removes the choice of
*which order* the shopper pays; it cannot stop them sending funds on some other
chain from their wallet, which is the `WRONG_CHAIN` case in
[Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq).
## What exactly do I put in the chain field?
The chain's **code**, as a string — `BASE`, not `Base`, and not a numeric chain
ID. Matching is case-insensitive, and each chain's short name is accepted as an
alternative to its code, but send the code: it is the value that comes back on
the order and the value the refund path reads.
| Chain | Code to send | Short name also accepted | Address family |
| - | - | - | - |
| Ethereum | `ETHEREUM` | `ETH` | EVM |
| Polygon | `POLYGON` | `MATIC` | EVM |
| Arbitrum One | `ARBITRUM` | `ARB` | EVM |
| Base | `BASE` | `BASE` | EVM |
| Optimism | `OPTIMISM` | `OP` | EVM |
| Avalanche | `AVALANCHE` | `AVAX` | EVM |
| BNB Smart Chain | `BSC` | `BSC` | EVM |
| Solana | `SOLANA` | `SOL` | Solana |
| Tron | `TRON` | `TRX` | Tron |
Testnets are separate codes, usable where your sandbox is configured for them:
| Testnet | Code | Mainnet counterpart |
| - | - | - |
| Ethereum Sepolia | `ETH-SEPOLIA` | `ETHEREUM` |
| Polygon Amoy | `AMOY` | `POLYGON` |
| Arbitrum Sepolia | `ARBITRUM_SEPOLIA` | `ARBITRUM` |
| Base Sepolia | `BASE_SEPOLIA` | `BASE` |
| Optimism Sepolia | `OPTIMISM_SEPOLIA` | `OPTIMISM` |
| Avalanche Fuji | `FUJI` | `AVALANCHE` |
| Solana Testnet | `SOLANA_TEST` | `SOLANA` |
Tron and BNB Smart Chain have no testnet code, so those two cannot be exercised
against a testnet.
Two naming points that come up often:
* **`BSC` is BNB Smart Chain.** The chain's code is `BSC` and its network is BNB
Smart Chain; "BNB" on its own is the network's token, not a chain code. Send
`BSC`.
* **Avalanche means the C-Chain.** Only C-Chain (EVM) addresses are valid;
X-Chain and P-Chain addresses are rejected outright, including on refunds.
The **address family** column is the one to build against: it determines the
address format the chain accepts, which matters most on refunds — see
[Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq).
## Is the chain field mandatory? What happens if I leave it out?
It is not mandatory on the request, but the order cannot be paid without a
chain, so leaving it out has one of two outcomes and never a default:
* On the **hosted cashier**, consultation reports it as a missing field with the
available options, and the shopper's choice supplies it.
* On **your own checkout**, you get the same missing-field response and it is
your job to collect the choice and re-send.
There is no fallback chain. Do not assume the cheapest or the most popular chain
is picked for you.
## Which stablecoins are supported?
`USDC` and `USDT` on the wallet path; `USDC` only on the x402 path. No other
tokens — no non-stablecoin tokens, no small-cap tokens, and no merchant-issued
tokens.
Anything else sent to a receiving address is not a valid payment: a transfer of
the chain's native token (ETH, for example) or of any other token does not pay
the order, and neither does a transfer on a chain other than the order's.
## Is there a minimum or maximum amount?
Yes, and it is on your agreement like any other payment method, not a special
stablecoin rule. The published
[payment method catalogue](/docs/en/essentials/payment-methods) carries the
catalogue-level range for `USDC` and `USDT`; the range that actually applies to
you is `orderAmountMin` / `orderAmountMax` on your own configuration entry for
the method, readable from `/api/v1/paymethodconfig/inquiry` or from **Service →
Payin** in the Portal.
Check it before you create the order. An amount outside the range is rejected at
order creation with `A0008` — see
[Limits, Currency & Configuration Errors](/docs/en/support/error-codes-and-limits-supplement)
for how that differs from the two account-level limits.
## How many decimal places do stablecoin amounts use?
Two, on every Waffo API field, exactly like a 2-decimal fiat currency — see
[Currency and amount](/docs/en/developer-docs/core-concepts/currency). On-chain the
tokens use 6-decimal atomic units; Waffo handles that conversion, so neither your
requests nor your reports carry atomic units, and a block explorer shows the
amount in a different unit than the one you sent.
The token's on-chain precision therefore does **not** change what you send: the
same `orderAmount` string is correct on every chain, and you never scale it
yourself. The one place atomic units are yours to construct is the x402
signature path, where the challenge and the signature are both in atomic units —
that page spells out the conversion.
## Is the receiving address mine, per order, or fixed?
It depends on the path, and the two answers are genuinely different:
| Path | Address | Can I cache it? |
| - | - | - |
| Wallet / hosted cashier | Issued **per order** | **No** |
| x402 signature path | A **merchant-level** receiving address returned by `wallet/inquiry` | No — fetch it per order and use it verbatim |
On the wallet path an address is bound to one order and released when that order
finishes, which means it can later be issued for a **different** order. That is
the reason the rule is absolute rather than a preference: a transfer to an
address you cached does not pay the order you cached it for, and once the address
has moved on it cannot be attributed to it at all.
Neither path gives you an address of your own to publish, print on an invoice, or
hard-code. In both, the address is Waffo's; Waffo receives on-chain and settles
to you per your contract.
## The shopper paid to a recycled address. What happens?
Nothing good, and this is worth designing against rather than handling. Once an
order's address has been released, a late transfer to it lands in one of the
unmatched cases in
[Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq): it does
not pay the original order, and it does not pay whichever order holds the address
now, because the amount and the order will not correspond.
Practical guards: never render a stablecoin address outside the payment page for
that order, never let a shopper "reuse the address from last time", and never
show a QR code from an expired order.
## Does the payment QR code carry the amount?
Do not assume it does. The reliable instruction to a shopper is: **send exactly
the order amount, in the order's token, on the order's chain, before the window
closes** — all four, from the payment page. A wallet that pre-fills the amount is
a convenience, not a guarantee, and an amount the shopper edits produces an
overpay or underpay case rather than a paid order.
## How long does the shopper have to pay?
The order's expiry, which is the same mechanism as every other payment method:
the contract default unless you send `orderExpiredAt` on `order/create`. See
[Payment Timeouts & Finality](/docs/en/support/payment-timeout-and-finality-faq).
Two stablecoin-specific things to plan for:
* The window has to cover **the shopper's action plus the chain's confirmation
time**, not just the shopper's action. A window that would be generous for a
wallet redirect can be tight on a chain that confirms slowly.
* A transfer that arrives after the window closes does not pay the order. It
becomes the `LATE_PAID` case, and there is no way to attach it to the closed
order afterwards.
## The shopper's wallet says the transfer succeeded — why is the order still processing?
Because the order advances to success only once the transfer meets the
confirmation requirement on-chain, and that wait is a property of the chain, not
of Waffo. A wallet showing "sent" is not the same event as the payment being
final. How long the wait is varies substantially between chains:
* Chains with near-instant deterministic finality (for example Avalanche,
Polygon) confirm quickest.
* Ethereum mainnet waits for a block-confirmation depth, so it is noticeably
slower.
* Optimistic-rollup L2s (Arbitrum, Base, Optimism) are slowest: the sequencer
accepts the transfer in seconds, but confirmation waits for the batch to be
confirmed on Ethereum.
So show the shopper a "waiting for network confirmation" state, and treat the
payment-result webhook or an order inquiry as the only source of truth for
success — never the wallet's own confirmation screen, and never a block explorer
you checked yourself.
## The order has been processing for a while. Is it still waiting, or has it failed?
Read the order, not the chain. An order in a non-final state is still in play;
the state machine is the same as for every other payment method, so the same
rules apply about which states are final and which are not — see
[Payment lifecycle](/docs/en/developer-docs/core-concepts/payment-lifecycle) and
[Payment Timeouts & Finality](/docs/en/support/payment-timeout-and-finality-faq).
What you must not do is infer failure from the chain being slow, or success from
having found the transfer on a block explorer. If the order has not reached a
final state by its expiry, it closes; if funds arrived off-spec, it becomes one
of the exception cases, which are visible to support with the order id and the
transaction hash.
## Do I receive stablecoins or fiat?
Fiat. The USDC or USDT the shopper pays is converted at payment success through
the `USD/USDC` and `USD/USDT` currency pairs, and everything downstream — your
unsettled balance, settlement batches and reports, and withdrawals — stays in
your settlement currency. You never hold the stablecoin, so you carry no token
price exposure and no on-chain fee exposure.
Because conversion happens **at payment success**, not at order creation, the
rate is the rate at that moment. Stablecoin orders use the same cross-currency
machinery as any other cross-currency order, so the rate mechanics, and whether a
quote can be held, are covered in
[Exchange Rates](/docs/en/support/exchange-rates-faq) and
[Pricing currency strategy](/docs/en/developer-docs/use-cases/pricing-currency)
rather than being special here.
## Where do stablecoin orders show up in the Portal?
In the ordinary transaction list, filtered like anything else. Two filters get
you there:
| Filter | Value |
| - | - |
| Payment method type | `CRYPTO` |
| Payment method | `USDC` or `USDT` |
What the Portal does **not** give you is any on-chain detail: there is no chain
column, no chain filter, no transaction hash, no confirmation count and no
network-fee figure on the transaction list or the transaction detail. The one
place a chain appears is the refund drawer, where it is shown read-only as the
chain the original payment used.
So if your reconciliation needs the chain or the transaction hash per order, do
not plan to get it from a Portal export. On the x402 path the hash is available
on the API — the settlement receipt returned on success carries it, and that page
documents its shape. On the wallet path, raise the order id with support.
## What do I need in order to be enabled for stablecoins?
Two provisioning steps, both arranged with your integration contact: the CRYPTO
payment product on your contract, and your chain set. Until both are in place,
stablecoin order creation is refused as not permitted by your contract (`A0010`).
Enrollment itself follows the normal path, including where you watch its status —
see [Payment Method Enrollment](/docs/en/support/payment-enrollment-faq). Adding a
chain later is the same kind of request as adding a payment method: it is a
configuration change on the Waffo side, not something you can switch on yourself.
## Where do I look next?
* [Stablecoin Exceptions & Refunds](/docs/en/support/stablecoin-exceptions-faq) —
overpay, underpay, late, wrong chain, wrong token, and what a refund must carry
* [x402 stablecoin acquiring](/docs/en/developer-docs/use-cases/x402-stablecoin-acquiring) —
the AI-Agent path end to end
* [Payment Timeouts & Finality](/docs/en/support/payment-timeout-and-finality-faq) —
order expiry and which states are final
* [Reading Your Payment Method Configuration](/docs/en/support/payment-method-config-and-limits-faq) —
your own amount range and availability for `USDC` / `USDT`
* [Settlement Reports](/docs/en/support/settlement-reports-faq) — how a converted
stablecoin payment appears in settlement
# Subscription Amounts & Per-Period Scheduling FAQ
Source: https://waffo.com/docs/en/support/subscription-amount-scheduling-faq
What scheduledAmounts means, how Waffo picks the amount for each billing period, what /subscription/update can and cannot change (it lowers amounts; raising needs a plan change or a seat add-on top-up), the amount-update webhook, and why there is no proration field.
This page covers **how much is charged in each billing period**: configuring
different amounts per period, changing amounts on a live subscription, and what
Waffo does (and does not) do about mid-period price changes.
For creation-time validation and status values see
[Subscription Creation & Validation FAQ](/docs/en/support/subscription-creation-validation-faq).
For deferred starts, payment methods and retries see
[Subscription Lifecycle & Payment Method Management](/docs/en/support/subscription-lifecycle-management)
and the
[Subscription FAQ](/docs/en/support/subscription-faq).
## What does `scheduledAmounts` mean in the subscription request?
`scheduledAmounts` is an optional list inside `productInfo` that lets you charge
a **different amount in specific billing periods** instead of the same `amount`
every period. Each entry has exactly two fields:
| Field | Type | Meaning |
| - | - | - |
| `period` | String | Which billing period this amount applies to. A positive integer, `1` = the subscription's **first** period. |
| `amount` | String | The amount to charge in that period, in the same currency as the subscription. |
```json theme={null}
{
"amount": "99.90",
"productInfo": {
"periodType": "MONTHLY",
"periodInterval": "1",
"numberOfPeriod": "12",
"scheduledAmounts": [
{ "period": "1", "amount": "9.90" },
{ "period": "2", "amount": "49.90" }
]
}
}
```
That subscription charges 9.90 in period 1, 49.90 in period 2, and 99.90 in
every period from 3 to 12. Periods you do not list simply fall back to `amount`,
and the listed periods do **not** have to be consecutive — listing only periods
1 and 5 is valid.
You can set `scheduledAmounts` when you create the subscription and change it
later with `/api/v1/subscription/update`.
## Is `period` counted from the first period, or from the first period after the trial?
**From the first period of the subscription.** `period` is an absolute, 1-based
index over every billing period, and trial periods are included in that count.
This matters when a trial exists: if you configure a 2-period trial, then
`period: 1` and `period: 2` address the two **trial** periods, and the first
period at the regular price is `period: 3`.
| Subscription | `period: 1` addresses |
| - | - |
| No trial | The first regular charge |
| 2-period trial | The **first trial** charge |
There is no offset applied anywhere: Waffo looks up the period number of the
charge it is about to create and reads `scheduledAmounts` with that exact
number. To see where a live subscription currently is, read
`productInfo.currentPeriod` from `/api/v1/subscription/inquiry` — the stored
plan comes back in the same response as `productInfo.scheduledAmounts`, so you
can always confirm which period numbers are still in the future before you send
an update.
## How does Waffo decide the amount for each period?
Three inputs are consulted in this order; the **last** rule that matches wins:
| Priority | Rule | Applies when |
| - | - | - |
| 1 (highest) | `scheduledAmounts` entry whose `period` equals this period's number | That period is listed in the plan |
| 2 | `trialPeriodAmount` | This period's number is within `numberOfTrialPeriod` and no scheduled amount matched |
| 3 (fallback) | `amount` | Neither of the above matched |
So a `scheduledAmounts` entry **overrides the trial amount** for that period.
If you want the trial to be billed at `trialPeriodAmount`, do not list the trial
period numbers in `scheduledAmounts`.
This is the first thing to check when a charge does not match what you expected:
work out the period number of the charge, then walk the three rules in order.
## What are the exact limits on `scheduledAmounts`?
| Constraint | Rule | If violated |
| - | - | - |
| List length | At most **10** entries | Parameter validation error |
| `period` | Positive integer (`≥ 1`) | Parameter validation error |
| `period` uniqueness | No duplicates within one request | Parameter validation error, message `scheduledAmounts.period must be unique` |
| `period` upper bound (create) | Must not exceed `numberOfPeriod` | Parameter validation error naming the offending period |
| `period` upper bound (update) | Must not exceed the subscription's final period count | Parameter validation error naming the offending period |
| `period` lower bound (update) | Must be **strictly greater** than the current period | Parameter validation error naming the current period |
| `amount` | Required per entry, must not be negative | Parameter validation error |
| `amount` precision | Must match the currency's ISO 4217 scale, and any whole-unit multiple rule for that currency | Amount-decimal error |
| `amount` range | Must fall inside the amount range on your subscription agreement | Amount-out-of-range error |
Two details worth calling out because they are commonly assumed the other way
round:
* **Amounts are decimal strings in the currency's normal unit, not minor units.**
`"9.90"`, not `"990"`. Extra decimal places are **rejected**, not rounded.
* **`amount: "0"` passes field validation**, so a free period is expressible —
but the value still has to satisfy the minimum amount on your agreement, so
whether 0 is accepted depends on your contract.
## Can I raise the next period's subscription amount directly?
**No — not through `/api/v1/subscription/update`.** By default that endpoint
rejects any increase. This is enforced on all three amount fields:
| Field | Rule |
| - | - |
| `amount` | New value must be `≤` the current `amount` |
| `productInfo.trialPeriodAmount` | New value must be `≤` the current trial amount |
| `productInfo.scheduledAmounts[].amount` | Per period, new value must be `≤` the baseline for that period |
The per-period baseline is the **currently stored scheduled amount for that same
period**, or the subscription's `amount` if that period is not in the stored
plan. That closes the obvious loophole: you cannot lower a period first and then
raise it back in a second call, because the second call is compared against the
value you just wrote.
Violations come back as a parameter error whose message names what was rejected
(`amount is not allowed to increase`,
`trialPeriodAmount is not allowed to increase`, or
`scheduledAmounts.amount is not allowed to increase for period N`).
To charge **more**, either use `/api/v1/subscription/change` and move the user
to a new plan, or — where the increase is a seat add-on — send the update with
a `topupInfo` block: that path collects the difference for the current period
immediately and is allowed to raise `amount` from the next period (see the
proration table below). Plain increases through `update` are not open to
accounts generally; if you believe your integration needs them, raise it with
integration support rather than designing around it.
## How do I set prorate?
**There is no proration field, and Waffo does not calculate proration.** If you
are looking for a `prorate` / `proration_behavior` parameter, it does not exist —
neither on `/subscription/update` nor on `/subscription/change`.
Here is what actually happens in each case, so you can decide what to implement
on your side:
| What you want | What Waffo does | What you must do |
| - | - | - |
| Change the amount from the next period onward | `/subscription/update` applies the new amount **from the next billing period**. The current period is untouched, and no credit or debit is calculated for it. | Nothing extra — but do not expect a partial-period adjustment. |
| Move the user to a different plan | `/subscription/change` locks the original subscription and **creates a new subscription based on it**. You may pass a remaining value to carry over, which Waffo then draws down against the new subscription's charges. | Calculate the carry-over value yourself and pass it in the change request. Waffo does not compute it. |
| Collect a mid-period difference immediately | `/subscription/update` with a `topupInfo` block (a **seat add-on**) charges `topupAmount` immediately as an extra order inside the current period and applies the new `amount` from the next period. You compute `topupAmount`; Waffo does not prorate it. | Send `topupInfo` (`topupRequest` as the idempotency key, `topupAmount`, an optional `description` and `orderExpiredAt`). The response carries the top-up order and its status; the new amount is applied only once that charge succeeds. Alternatively collect the difference as a separate one-off order. |
In short: amount changes are **period-boundary events**, and any money that
should move because a change landed mid-period is yours to compute and yours to
collect.
## What exactly does `/subscription/update` change, and when does it take effect?
It changes amounts only, from the next billing period onward.
**It can change:** `amount`, `productInfo.trialPeriodAmount`,
`productInfo.scheduledAmounts` — individually or together. At least one of the
three must be present, otherwise the request is rejected.
**It does not change:** the plan, `periodType`, `periodInterval`, the number of
periods remaining, the billing date, or the payment method. It does not trigger
proration and it does not create a new subscription.
**Timing:** the new values apply from the next period. A charge for the current
period that has already been generated or is already in flight is **not**
affected. The response tells you the period number the change lands on:
| Response field | Meaning |
| - | - |
| `subscriptionId` / `subscriptionRequest` | Identifies the subscription |
| `previousAmount` / `newAmount` | Base amount before and after (equal if unchanged) |
| `previousTrialPeriodAmount` / `newTrialPeriodAmount` | Trial amount before and after |
| `previousScheduledAmounts` / `newScheduledAmounts` | The plan before and after; omitted when no plan was stored |
| `nextEffectivePeriod` | The **period number** from which the new amounts apply |
## When is an amount update rejected outright?
| Condition | Behavior |
| - | - |
| Neither `subscriptionRequest` nor `subscriptionId` provided | Rejected |
| None of `amount` / `trialPeriodAmount` / `scheduledAmounts` provided | Rejected |
| Subscription does not exist under your MID | Not-found error |
| Subscription is not `ACTIVE` | Rejected — status error |
| Subscription has already reached its final period | Rejected, with the final period number in the message |
| `trialPeriodAmount` sent after the trial has ended | Rejected — the trial amount is no longer modifiable |
| The subscription's schedule is driven by the payment method rather than by Waffo | Not supported for that subscription |
| Another update, or a renewal, is being processed for the same subscription | Rejected — retry shortly |
The last row means updates are **serialized per subscription**. If you are
re-pricing many subscriptions, treat a "being modified, please retry later"
response as retryable rather than as a failure.
## If I send `scheduledAmounts` in an update, does it merge with the existing plan?
**No — the list you send replaces the stored plan as a whole.** It is not a
patch: periods present in the old plan but absent from your request stop having
a scheduled amount and fall back to `amount`.
Two consequences to design for:
* Always send the **complete** set of future periods you want priced, not just
the ones you are changing.
* Because an update may only address periods **after** the current one, entries
for already-charged periods are dropped from storage when you replace the
plan. That is expected and does not change any charge that already happened.
Sending an **empty array**, or omitting the field, is treated as
*no change* — it does **not** clear the plan. There is no way to clear a
scheduled-amount plan through `/subscription/update`; to get back to a single
flat amount, list every remaining future period at the amount you want, or move
the user to a new plan with `/subscription/change`.
## What happens to `scheduledAmounts` when I upgrade or downgrade?
Nothing is inherited. `/subscription/change` creates a **new** subscription, and
that new subscription's `amount` and `scheduledAmounts` come **entirely from the
change request**. If you omit `scheduledAmounts` there, the new subscription has
no per-period plan at all and every period is billed at the new `amount`.
So if a tiered price ramp should survive an upgrade, you have to restate it in
the change request.
## Will the end user be told that the amount changed?
Yes — Waffo emails the subscriber after a successful amount update. The mail
carries the previous amount, the new amount, the previous and new per-period
plans, and the period number the change takes effect from.
Two operational caveats:
* The email is sent only if the amount-change notification is enabled for your
account and product. If it is switched off, nothing is sent to the user.
* Sending is best-effort and asynchronous: a mail failure does **not** roll back
the amount change. The API response is the record of what changed.
## Does Waffo send me a webhook when an amount update succeeds?
Yes. After a successful `/subscription/update`, Waffo pushes
**`SUBSCRIPTION_UPDATE_NOTIFICATION`** to the `notifyUrl` stored on the
subscription. For a plain amount change it is sent as soon as the update is
committed; for a seat add-on (see below) it is sent once the top-up charge
succeeds and the new amount is applied. The payload is the subscription as the
inquiry API returns it, so it already reflects the new amounts.
To keep your own records aligned:
* Treat the `/subscription/update` response as the primary record — read
`newAmount`, `newScheduledAmounts` and `nextEffectivePeriod` from it and
store them. The webhook confirms the same change asynchronously and is the
signal to use when the change was committed outside your immediate request
path (the top-up case).
* Keep listening to the per-period notification and the subscription payment
order notification for the **actual charged amount** of each period — those
reflect what was really taken, which is the number you should reconcile
against.
## Which subscriptions can use per-period amounts at all?
Per-period amounts and `/subscription/update` are available on subscriptions
where **Waffo drives the billing schedule**. For some payment methods the
payment method itself drives the schedule; those subscriptions reject
`scheduledAmounts` at creation and are not eligible for amount updates. If a
creation call comes back saying scheduled amounts are not supported for the
subscription, that is why — the fix is a different payment method, not a
different request shape.
# Chargeback accept
Source: https://waffo.com/docs/api-reference/chargeback-accept/chargeback-accept
/api-reference/openapi.json post /api/v1/chargeback/accept
Accept the chargeback. The amount and fee were already debited when the chargeback was created, so accepting does not trigger a further debit — the case is closed and the merchant is not refunded. Note: this action is irreversible — no further evidence can be submitted afterwards, and an ACCEPTED notification is pushed to the configured chargeback notify URL.
# Chargeback inquiry
Source: https://waffo.com/docs/api-reference/chargeback-inquiry/chargeback-inquiry
/api-reference/openapi.json post /api/v1/chargeback/inquiry
Query a single chargeback by chargebackId or originalOrderId. Note: when the original order has more than one chargeback, for example after escalation to Second Cycle, query by chargebackId.
# Chargeback list
Source: https://waffo.com/docs/api-reference/chargeback-list/chargeback-list
/api-reference/openapi.json post /api/v1/chargeback/list
Query chargebacks by creation time range, status and life cycle. Note: this API does not return the message and evidence objects; use Chargeback inquiry to retrieve them.
# Introduction
Source: https://waffo.com/docs/api-reference/introduction
Waffo Acquiring Order API reference
## Overview
The Waffo Acquiring Order API enables merchants to create and manage payment orders, subscriptions, refunds, and configuration queries.
#### OpenAPI Specification
View the full OpenAPI specification file
## Base URL
| Environment | URL |
| - | - |
| Sandbox | `https://api-sandbox.waffo.com` |
| Production | `https://api.waffo.com` |
## API Groups
# Merchant Config inquiry
Source: https://waffo.com/docs/api-reference/merchant-config-inquiry/merchant-config-inquiry
/api-reference/openapi.json post /api/v1/merchantconfig/inquiry
# Order cancel
Source: https://waffo.com/docs/api-reference/order-cancel/order-cancel
/api-reference/openapi.json post /api/v1/order/cancel
# Order capture
Source: https://waffo.com/docs/api-reference/order-capture/order-capture
/api-reference/openapi.json post /api/v1/order/capture
# Create new order
Source: https://waffo.com/docs/api-reference/order-create/create-new-order
/api-reference/openapi.json post /api/v1/order/create
Note: For certain Pay Methods, some optional fields are required. Kindly refer to Section Specific Pay Method Requirement
# Order inquiry
Source: https://waffo.com/docs/api-reference/order-inquiry/order-inquiry
/api-reference/openapi.json post /api/v1/order/inquiry
# Order refund
Source: https://waffo.com/docs/api-reference/order-refund/order-refund
/api-reference/openapi.json post /api/v1/order/refund
# Pay Method Config Inquiry
Source: https://waffo.com/docs/api-reference/pay-method-config-inquiry/pay-method-config-inquiry
/api-reference/openapi.json post /api/v1/paymethodconfig/inquiry
# Refund inquiry
Source: https://waffo.com/docs/api-reference/refund-inquiry/refund-inquiry
/api-reference/openapi.json post /api/v1/refund/inquiry
# Subscription cancel
Source: https://waffo.com/docs/api-reference/subscription-cancel/subscription-cancel
/api-reference/openapi.json post /api/v1/subscription/cancel
# Subscription change inquiry
Source: https://waffo.com/docs/api-reference/subscription-change-inquiry/subscription-change-inquiry
/api-reference/openapi.json post /api/v1/subscription/change/inquiry
# Subscription change
Source: https://waffo.com/docs/api-reference/subscription-change/subscription-change
/api-reference/openapi.json post /api/v1/subscription/change
# Create Subscription
Source: https://waffo.com/docs/api-reference/subscription-create/create-subscription
/api-reference/openapi.json post /api/v1/subscription/create
Note: For certain Pay Methods, some optional fields are required. Kindly refer to Section Specific Pay Method Requirement
# Subscription Inquiry
Source: https://waffo.com/docs/api-reference/subscription-inquiry/subscription-inquiry
/api-reference/openapi.json post /api/v1/subscription/inquiry
# Subscription manage
Source: https://waffo.com/docs/api-reference/subscription-manage/subscription-manage
/api-reference/openapi.json post /api/v1/subscription/manage
# Subscription resume
Source: https://waffo.com/docs/api-reference/subscription-resume/subscription-resume
/api-reference/openapi.json post /api/v1/subscription/resume
# Subscription update
Source: https://waffo.com/docs/api-reference/subscription-update/subscription-update
/api-reference/openapi.json post /api/v1/subscription/update
# Wallet inquiry
Source: https://waffo.com/docs/api-reference/wallet-inquiry/wallet-inquiry
/api-reference/openapi.json post /api/v1/wallet/inquiry
Query on-chain wallet / receiving address (network/asset/payTo) for x402 stablecoin payin
# Amounts and currencies
Source: https://waffo.com/docs/en/developer-docs/core-concepts/currency
Waffo amount formatting specifications and multi-currency payment guidance.
## Amount format
Waffo amounts are represented uniformly as the **String type**. Do not use Number, float, or double.
```
✅ "100.00"
✅ "0.99"
✅ "10000" (zero-decimal currencies such as JPY)
❌ 100.00 (Number type — floating-point precision issues)
❌ 100 (Number type)
```
> [!NOTE]
>
> String is required instead of Number to avoid floating-point precision issues. `0.1 + 0.2 = 0.30000000000000004` is not acceptable in financial contexts.
## Decimal place rules
Different currencies have different decimal place requirements.
> [!WARNING]
>
> Passing the wrong number of decimal places returns a parameter error (A0003).
### 2 decimal places (most currencies)
The following currencies require amounts expressed to exactly 2 decimal places:
```
"100.00" ✅
"9.99" ✅
"100" ❌ (missing decimal places)
"100.0" ❌ (only 1 decimal place)
```
| Region | Currencies |
| - | - |
| Asia-Pacific | `PHP` (Philippine Peso), `MYR` (Malaysian Ringgit), `THB` (Thai Baht), `KHR` (Cambodian Riel), `SGD` (Singapore Dollar), `HKD` (Hong Kong Dollar), `MOP` (Macanese Pataca), `CNY` (Chinese Yuan), `AUD` (Australian Dollar), `MNT` (Mongolian Tögrög) |
| Americas | `USD` (US Dollar), `BRL` (Brazilian Real), `MXN` (Mexican Peso), `PEN` (Peruvian Sol), `CAD` (Canadian Dollar), `AWG` (Aruban Florin) |
| Europe & Africa | `EUR` (Euro), `GBP` (British Pound), `RUB` (Russian Ruble), `TRY` (Turkish Lira), `PLN` (Polish Złoty), `SEK` (Swedish Krona), `NGN` (Nigerian Naira) |
| Crypto | `USDC`, `USDT` |
### Currencies with 0 decimal places
The following currencies use 0 decimal places. For `VND`, you can omit the decimal places or include fractional digits that are all zeros. Amount validation fails if any fractional digit is non-zero.
```text theme={null}
"10000" ✅
"10000.00" ✅ (fractional digits are all zero)
"10000.01" ❌ (contains a non-zero fractional value)
```
| Currency | Name |
| - | - |
| `JPY` | Japanese Yen |
| `KRW` | South Korean Won |
| `VND` | Vietnamese Dong |
| `CLP` | Chilean Peso |
### Special currencies (0 decimals same-currency, 2 decimals cross-currency)
The following currencies do not allow decimals for same-currency orders, but allow 2 decimal places for cross-currency orders:
| Currency | Name | Same-currency order | Cross-currency order |
| - | - | - | - |
| `IDR` | Indonesian Rupiah | `"10000"` ✅ / `"10000.50"` ❌ | `"10000.50"` ✅ |
| `COP` | Colombian Peso | `"5000"` ✅ / `"5000.00"` ❌ | `"5000.00"` ✅ |
| `KES` | Kenyan Shilling | `"100"` ✅ / `"100.50"` ❌ | `"100.50"` ✅ |
| `TWD` | New Taiwan Dollar | `"300"` ✅ / `"300.00"` ❌ | `"300.00"` ✅ |
## Same-currency vs cross-currency orders
| Scenario | Example | Description |
| - | - | - |
| **Same-currency** | Product priced in IDR, user pays with DANA (IDR) | `orderCurrency` matches the user's payment currency |
| **Cross-currency** | Product priced in USD, user pays with DANA (IDR) | `orderCurrency` differs from the user's payment currency |
### Cross-currency order parameters
```json theme={null}
{
"orderCurrency": "USD",
"orderAmount": "10.00"
}
```
In a cross-currency scenario, `userCurrency` is optional. However, if `payMethodType` or `payMethodName` is not fully specified, pass `payMethodCountry` to tell Waffo which country the user is in.
Waffo handles exchange rate conversion automatically. The user sees the local currency amount on the checkout page.
## Country and region codes
Country and region fields such as `payMethodCountry` and `userCountryCode` use **ISO 3166-1 alpha-3** three-letter codes, for example `IDN` (Indonesia), `PHL` (the Philippines), and `HKG` (Hong Kong). Do not pass two-letter alpha-2 codes such as `ID`, `PH`, or `HK`.
See the [ISO 3166-1 alpha-3 country and region code list](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3) for all codes.
## Full currency list
| Currency code | Name | Decimal places | Notes |
| - | - | - | - |
| `IDR` | Indonesian Rupiah | 0 / 2 | Same-currency: 0, cross-currency: 2 |
| `USD` | US Dollar | 2 | |
| `PHP` | Philippine Peso | 2 | |
| `BRL` | Brazilian Real | 2 | |
| `MXN` | Mexican Peso | 2 | |
| `MYR` | Malaysian Ringgit | 2 | |
| `THB` | Thai Baht | 2 | |
| `CAD` | Canadian Dollar | 2 | |
| `EUR` | Euro | 2 | |
| `AUD` | Australian Dollar | 2 | |
| `GBP` | British Pound | 2 | |
| `VND` | Vietnamese Dong | 0 | Omit decimal places or use zero-only fractional digits |
| `CLP` | Chilean Peso | 0 | No decimals |
| `COP` | Colombian Peso | 0 / 2 | Same-currency: 0, cross-currency: 2 |
| `PEN` | Peruvian Sol | 2 | |
| `RUB` | Russian Ruble | 2 | |
| `KES` | Kenyan Shilling | 0 / 2 | Same-currency: 0, cross-currency: 2 |
| `NGN` | Nigerian Naira | 2 | |
| `TWD` | New Taiwan Dollar | 0 / 2 | Same-currency: 0, cross-currency: 2 |
| `HKD` | Hong Kong Dollar | 2 | |
| `JPY` | Japanese Yen | 0 | No decimals |
| `KRW` | South Korean Won | 0 | No decimals |
| `MOP` | Macanese Pataca | 2 | |
| `SGD` | Singapore Dollar | 2 | |
| `CNY` | Chinese Yuan | 2 | |
| `PLN` | Polish Złoty | 2 | |
| `TRY` | Turkish Lira | 2 | |
| `KHR` | Cambodian Riel | 2 | |
| `SEK` | Swedish Krona | 2 | |
| `AWG` | Aruban Florin | 2 | |
| `MNT` | Mongolian Tögrög | 2 | |
| `USDC` | USD Coin | 2 | Cryptocurrency |
| `USDT` | Tether | 2 | Cryptocurrency |
# Error Handling
Source: https://waffo.com/docs/en/developer-docs/core-concepts/error-handling
A guide to Waffo API error code categories and handling Unknown Status exceptions.
## Response Codes
Waffo API response format:
```json theme={null}
{
"code": "0",
"msg": "Success",
"data": { ... }
}
```
* `code` of `"0"` indicates success
* On failure, `code` is an error code string (e.g., `"A0003"`, `"E0001"`)
* The response field is named `msg` (not `message`)
## Error Code Categories
| Prefix | Category | Description |
| - | - | - |
| 0 | Success | — |
| A | Merchant error | Invalid parameters, insufficient permissions, etc. |
| B | User error | Insufficient balance, payment timeout, etc. |
| C | System error | Channel rejection, system errors, etc. |
| D | Risk Control rejection | Transaction blocked by Risk Control |
| E | Unknown error | **Critical: payment may have succeeded** |
For the full error code list, see [Error Code Reference](/docs/en/developer-docs/tools-and-references/developer-tools/error-codes).
## Handling E0001 Unknown Status
> [!WARNING]
>
> **This is the most critical exception scenario.** When you encounter a network timeout, connection interruption, or the server returns `E0001`, the payment status is unknown.
### Principles
* **Do not assume failure**: a network timeout does not mean the payment failed
* **Do not automatically close the order**: the payment may have already completed on the channel side
* **Do not create a new order**: use the original `paymentRequestId` to query
### Error Types in the SDK
| Language | Unknown status error | Client error |
| - | - | - |
| Node.js | `WaffoUnknownStatusError` | `WaffoError` |
| Java | `WaffoUnknownStatusException` | `WaffoException` |
| Go | `*core.WaffoUnknownStatusError` | `*core.WaffoError` |
| Python | `WaffoUnknownStatusError` | `WaffoError` |
| PHP | `WaffoUnknownStatusError` | `WaffoError` |
> [!NOTE]
>
> The SDK has only two error types. `WaffoUnknownStatusError`/`Exception` means the payment may have succeeded, while `WaffoError`/`Exception` indicates a client-side error (configuration, signature, etc.).
### SDK Error Codes
| Error code | Type | Description |
| - | - | - |
| S0001 | UnknownStatus | Network error (timeout, connection failure) |
| E0001 | UnknownStatus | Server returned unknown status |
| S0002 | Error | Invalid public key |
| S0003 | Error | Signature failed |
| S0004 | Error | Response signature verification failed |
| S0005 | Error | Request serialization failed |
| S0007 | Error | Invalid private key |
### Handling Flow
```
Network timeout / E0001 / WaffoUnknownStatusError
│
▼
Query using the original paymentRequestId
│
├── PAY_SUCCESS found → Update local status to success
├── ORDER_CLOSE found → Update local status to failure
└── Still unknown → Wait for Webhook notification
```
### Code Example
```typescript theme={null}
import { WaffoUnknownStatusError, WaffoError } from '@waffo/waffo-node';
try {
const response = await waffo.order().create(params);
if (response.isSuccess()) {
// code === "0", handle success
} else {
// API returned an error code (e.g. A0003), safe to treat as failure
}
} catch (error) {
if (error instanceof WaffoUnknownStatusError) {
// Important: payment may have succeeded! Query before deciding
const inquiry = await waffo.order().inquiry({ paymentRequestId });
if (inquiry.isSuccess()) {
const status = inquiry.getData().orderStatus;
await updateOrderStatus(paymentRequestId, status);
}
// If the query also fails, wait for a Webhook
} else if (error instanceof WaffoError) {
// Client error (configuration, signature), safe to retry after fixing
console.error('Client error:', error.errorCode, error.message);
}
}
```
# Idempotency
Source: https://waffo.com/docs/en/developer-docs/core-concepts/idempotency
Use idempotency keys to prevent duplicate charges and ensure payment security.
Idempotency ensures that when the same request is executed multiple times, the result is the same as executing it only once. In payment scenarios, this prevents duplicate charges caused by retries triggered by network timeouts.
## Idempotency keys
Waffo uses `paymentRequestId` (payments) and `subscriptionRequest` (subscriptions) as idempotency keys.
### Rules
* Maximum length: 32 characters
* Generate a unique value for each new request
* **Must be persisted to the database before sending the request**
* When retrying, **reuse the original idempotency key**; do not generate a new one
## Pattern
```typescript theme={null}
// 1. Generate a unique idempotency key, 32 characters or fewer
const paymentRequestId = generateUniqueRequestId();
// 2. Persist (before sending the request!)
await db.order.create({ paymentRequestId, status: 'PENDING' });
// 3. Send the request
try {
const response = await waffo.order().create({ paymentRequestId, ... });
} catch (e) {
if (e instanceof WaffoUnknownStatusException) {
// 4. Unknown status: inquire, do not retry create
const inquiry = await waffo.order().inquiry({ paymentRequestId });
}
}
```
## Common mistakes
Incorrect approach
Correct approach
# Payment lifecycle and order statuses
Source: https://waffo.com/docs/en/developer-docs/core-concepts/payment-lifecycle
Learn how Waffo payment orders, subscriptions, and refunds transition between statuses.
## Order status transitions
```mermaid theme={null}
stateDiagram-v2
[*] --> PAY_IN_PROGRESS
PAY_IN_PROGRESS --> AUTHORIZATION_REQUIRED
PAY_IN_PROGRESS --> PAY_SUCCESS
PAY_IN_PROGRESS --> ORDER_CLOSE
AUTHORIZATION_REQUIRED --> AUTHED_WAITING_CAPTURE
AUTHORIZATION_REQUIRED --> PAY_SUCCESS
AUTHED_WAITING_CAPTURE --> PAY_SUCCESS
PAY_SUCCESS --> ORDER_PARTIALLY_REFUNDED
PAY_SUCCESS --> ORDER_FULLY_REFUNDED
ORDER_CLOSE --> [*]
PAY_SUCCESS --> [*]
```
## Order status reference
| Status | Description | Final |
| - | - | - |
| `PAY_IN_PROGRESS` | The order has been accepted and the user is in the process of paying | No |
| `AUTHORIZATION_REQUIRED` | The payment method requires user authorization (e.g., an e-wallet) | No |
| `AUTHED_WAITING_CAPTURE` | Authorized, awaiting capture confirmation (card payments; requires the merchant to confirm capture) | No |
| `PAY_SUCCESS` | Payment succeeded | Yes |
| `ORDER_CLOSE` | The order is closed (cancelled, failed, or timed out) | Yes |
## Subscription statuses
A subscription starts in `IN_PROGRESS`. It enters `AUTHORIZATION_REQUIRED` when the user must select a payment method or complete authorization. After the user completes authorization, processing continues toward `ACTIVE`; failures or timeouts move the subscription to `CLOSE`. Cancellation statuses and `EXPIRED` are final. The diagram shows only the key path through creation, authorization, activation, and completion; see the table below for each final status.
```mermaid theme={null}
stateDiagram-v2
[*] --> IN_PROGRESS
IN_PROGRESS --> AUTHORIZATION_REQUIRED: User authorization required
IN_PROGRESS --> ACTIVE: No authorization; initial payment succeeds
AUTHORIZATION_REQUIRED --> ACTIVE: Authorization and initial payment succeed
IN_PROGRESS --> CLOSE: Failure or timeout
AUTHORIZATION_REQUIRED --> CLOSE: Failure or timeout
ACTIVE --> MERCHANT_CANCELLED: Merchant cancels
ACTIVE --> USER_CANCELLED: User cancels
ACTIVE --> CHANNEL_CANCELLED: Payment channel cancels
ACTIVE --> PLATFORM_CANCELLED: Platform cancels
CLOSE --> [*]
```
| Status | Description | Action |
| - | - | - |
| `AUTHORIZATION_REQUIRED` | User authorization is required (e-wallet scenario) | Redirect to the `subscriptionAction` URL |
| `IN_PROGRESS` | The user is confirming the subscription | Wait for the Webhook |
| `ACTIVE` | The subscription is active and charging normally | Store the `subscriptionId` |
| `CLOSE` | Closed (timed out or failed) | Re-initiate if needed |
| `MERCHANT_CANCELLED` | Cancelled by the merchant | — |
| `USER_CANCELLED` | Cancelled by the user | Update local records |
| `CHANNEL_CANCELLED` | Cancelled by the channel | Notify the user |
| `PLATFORM_CANCELLED` | Cancelled by the platform | Check `cancelReason` in the subscription inquiry response |
| `EXPIRED` | The subscription has expired | Re-initiate if needed |
## Refund statuses
| Status | Description |
| - | - |
| `REFUND_IN_PROGRESS` | Refund is being processed |
| `ORDER_PARTIALLY_REFUNDED` | Partial refund completed |
| `ORDER_FULLY_REFUNDED` | Full refund completed |
| `ORDER_REFUND_FAILED` | Refund failed |
## Best practices
> [!TIP]
>
> **Use the Webhook as the only source of truth**: redirect URLs are only for user experience and should not be used to determine the payment result.
1. **Handle intermediate states**: do not automatically close orders in `PAY_IN_PROGRESS`; wait for the Webhook or query proactively
2. **Idempotent handling**: Webhooks may be delivered more than once; ensure your handling logic is idempotent. See [Idempotency](/docs/en/developer-docs/core-concepts/idempotency)
3. **Unknown status**: when you encounter error code `E0001`, query using the original `paymentRequestId` and do not automatically close the order. For the full handling flow, see [Error handling](/docs/en/developer-docs/core-concepts/error-handling)
# Account activation and team roles
Source: https://waffo.com/docs/en/developer-docs/getting-started/account-and-roles
Activate a Waffo production account, add Dashboard members, and assign roles by responsibility.
After Waffo provisions a production account, the Super Admin activates it and gives each team member a separate account with an appropriate role.
## Activate the production account
Waffo sends the activation email to the production account's Super Admin address.
**1. Open the activation link**
Open the link within 24 hours of receiving the email.
**2. Complete activation**
Set a password and complete account activation.
**3. Sign in to Dashboard**
Open [Waffo Dashboard](https://dashboard.waffo.com/auth/login) and sign in.
If the link has expired, contact Waffo technical support to request a new activation email.
## Add team members
Only an account with member-management permission can invite other members.
**1. Open member management**
Sign in as the Super Admin and open **Settings → Operator**.
**2. Enter member details**
Add a member, then enter their name and email address.
**3. Assign a role**
Select the role that matches the member's responsibilities. Assign **Dev** to project developers.
**4. Save the member**
Save the member details.
Use a separate email address for each member. Do not share the Super Admin account.
## Role overview
| Role | Intended user | Main access |
| - | - | - |
| **Super Admin** | Account owner | Activates the production account, manages members, and controls all key account settings |
| **Admin** | Administrator | Manages daily operations and members with nearly full access |
| **Read-Only** | Auditor or viewer | Views transactions, Settlement, financial, and contract information without changing business data |
| **General** | Operations or finance | Handles core operations, including transactions, Settlement, Reconciliation, and withdrawals |
| **Dispute Manager** | Dispute specialist | Views related transactions and handles Dispute and Chargeback cases |
| **Dev** | Developer or technical support | Views technical parameters and performs integration configuration and debugging |
> [!NOTE]
>
> Visible menus and actions depend on the current Dashboard configuration. If a member cannot access a required feature, ask the Super Admin to check their role.
## Role assignment recommendations
* Assign **Dev** to project developers instead of using the Super Admin account for daily integration work.
* Assign **General** or **Read-Only** to finance and operations staff according to their responsibilities.
* Assign **Dispute Manager** to members who only handle Dispute cases.
* Assign **Admin** only to members who need to manage users or key account settings.
Next: [Configure the merchant public key](/docs/en/developer-docs/getting-started/public-key-configuration).
# Production go-live
Source: https://waffo.com/docs/en/developer-docs/getting-started/go-live
Activate your production account, assign team roles, configure keys and notification URLs, and verify a production order.
After you pass sandbox acceptance and Waffo provisions your production account, complete the account, access, key, and notification setup below. Start processing normal traffic only after you verify your first production order.
## Prerequisites
* [ ] You passed the Waffo [integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria)
* [ ] You submitted the results to the Waffo technical integration group and received confirmation
* [ ] Waffo provisioned the production account and sent an activation email to the Super Admin
## Enable the production environment
**1. Activate the production account**
The Super Admin must open the activation link within 24 hours of receiving the email. If the link expires, contact Waffo technical support to request a new email.
**2. Add the developer**
The Super Admin signs in to [Waffo Dashboard](https://dashboard.waffo.com/auth/login), opens **Settings → Operator**, adds the project developer's email address, and assigns the **Dev** role.
Create a separate account for every team member who needs Dashboard access, and grant the minimum role required for their work. See [Account activation and team roles](/docs/en/developer-docs/getting-started/account-and-roles).
**3. Configure the production public key**
The developer opens **Settings → Integration**, uploads the production merchant public key, completes the signature challenge, and submits it for review. Production API requests can be verified after the key becomes **Active**.
Do not reuse sandbox-generated keys in production. See [Configure the merchant public key](/docs/en/developer-docs/getting-started/public-key-configuration).
**4. Configure notification URLs**
Payment notifications continue to use the `notifyUrl` supplied when each order is created. Configure the global `refundNotifyUrl` and `chargebackNotifyUrl` in Dashboard when your integration needs them.
See [Configure refund and Chargeback notification URLs](/docs/en/developer-docs/getting-started/notification-url-configuration).
**5. Switch to production credentials**
* [ ] If you use a Waffo SDK or AI integration tool, change `Environment.SANDBOX` to `Environment.PRODUCTION`
* [ ] If you call the API directly, use the production API domain
* [ ] Use the production API Key, merchant private key, and Waffo public key
* [ ] Store the RSA private key in a server-side key management system; never commit it or send it to Waffo
**6. Create a production order**
Create one low-value real order with the production credentials, then verify:
* [ ] The create-order API succeeds
* [ ] The customer can open Checkout and complete payment
* [ ] `notifyUrl` receives the final payment status and signature verification succeeds
* [ ] The order inquiry result matches the Webhook result
* [ ] Logs and alerts help you investigate order creation, inquiry, and Webhook handling
You can start processing normal traffic after every check passes.
## Security and infrastructure checks
* [ ] All API traffic uses TLS 1.2+
* [ ] Logs do not contain full keys or sensitive payment information
* [ ] The recommended API timeout is 15 seconds and is not lower than 8 seconds
* [ ] The recommended DNS cache duration is 60 seconds; do not pin Waffo IP addresses long term
* [ ] The HTTP Keep-Alive idle timeout is 60 seconds; configure your client connection pool to close idle connections before 60 seconds
* [ ] If your servers are in mainland China, you evaluated network latency to the Waffo API
## App checks, if applicable
* [ ] The App WebView can open external Apps and browser pages
* [ ] The App WebView supports downloads, copy, and long-press save
* [ ] Query parameters remain intact when URLs pass between the native App and WebView
* [ ] You reviewed PayPay, Google Pay, Apple Pay, JKOPAY, and other limitations in [Payment method integration notes](/docs/en/developer-docs/tools-and-references/references/payment-method-integration-notes)
## Post-provisioning message template
**Copy the message template**
```text theme={null}
Please complete the following production setup:
1. Activate the account: Waffo sent the activation email to the Super Admin address (). Open the link within 24 hours.
2. Add the developer: Sign in to Waffo Dashboard, open Settings → Operator, invite the project developer, and assign the Dev role.
3. Configure the public key: The developer signs in to Waffo Dashboard and completes production public-key configuration and verification.
4. Create a production order: Use the production credentials to create an order. Start normal traffic after order creation, payment notification, and order inquiry all work correctly.
```
# Configure refund and Chargeback notification URLs
Source: https://waffo.com/docs/en/developer-docs/getting-started/notification-url-configuration
Configure global refund and Chargeback notification URLs in Waffo Dashboard.
Waffo can send refund and Chargeback results to global notification URLs configured in Dashboard.
## How each notification address is selected
| Notification | When it is needed | Address source |
| - | - | - |
| Refund | Refunds created in Dashboard, or refund requests without their own notification address | Global Dashboard `refundNotifyUrl` |
| Chargeback | Card payments with Chargeback handling | Global Dashboard `chargebackNotifyUrl` |
If a refund API request includes `refundNotifyUrl`, Waffo sends that refund result to the request-specific address. The global Dashboard URL is not required for that request.
## Prerequisites
* Your role is **Super Admin**, **Admin**, or **Dev**.
* The receiving service is deployed at a publicly reachable production address.
* The service verifies Waffo signatures and returns the required response. See [Webhook overview](/docs/en/developer-docs/webhook/overview).
## URL requirements
| Item | Requirement |
| - | - |
| Protocol | Production must use `https://` and TLS 1.2+ |
| Format | Valid RFC 3986 URL |
| Length | No more than 512 characters |
| Characters | No spaces, Chinese characters, or other invalid characters |
| Reachability | Publicly reachable from Waffo services |
## Configure a URL
**1. Sign in to Dashboard**
Open [Waffo Dashboard](https://dashboard.waffo.com/auth/login), then go to **Settings → Integration**.
**2. Choose the notification type**
In **Merchant Sign Configuration Details**, find the notification type and click its **Edit** action.
**3. Enter and save the URL**
Paste the complete `refundNotifyUrl` or `chargebackNotifyUrl`, then click **Save**.
**4. Verify delivery**
Trigger the corresponding production flow and confirm that your service receives the notification. Do not rely only on the Dashboard success message.
## Change a notification URL
Follow the original configuration steps, enter the new address, and save. After the new address takes effect, the previous address stops receiving that notification type.
Confirm that the new endpoint can receive requests and verify signatures before switching to prevent notification loss.
## Clear a notification URL
To stop using a global address, clear the URL field and click **Save**. The previous address then stops receiving global notifications of that type.
> [!WARNING]
>
> Before clearing `refundNotifyUrl`, make sure every refund API request supplies a valid notification address. Clearing `chargebackNotifyUrl` stops Chargeback notifications to that address.
## Common error
| Error | Cause | Resolution |
| - | - | - |
| `Invalid URL: must start with http:// or https:// and be a valid address.` | The URL has no protocol, is malformed, exceeds the length limit, or contains invalid characters | Use a complete production HTTPS address and remove spaces, Chinese characters, and invalid characters |
If Dashboard saves the URL but your service receives no notification, check public reachability, the TLS certificate, service logs, and Webhook signature handling. Contact Waffo technical support if the problem continues.
Next: Return to [Production go-live](/docs/en/developer-docs/getting-started/go-live) and verify a production order.
# Integration overview
Source: https://waffo.com/docs/en/developer-docs/getting-started/overview
An overview of Waffo's core capabilities and integration flow as a one-stop global payments platform.
Waffo is a one-stop global payments platform that provides merchants with secure and convenient online payment solutions.
## Getting started
## Integration methods
## Core concepts
## Webhook
## SDK
## Tools and references
## Use cases
# Configure the merchant public key
Source: https://waffo.com/docs/en/developer-docs/getting-started/public-key-configuration
Upload a production merchant public key in Waffo Dashboard, complete the signature challenge, and track its review status.
Waffo uses two-way RSA signing to protect production API traffic. Upload your merchant public key in Waffo Dashboard (Merchant Portal), then prove that you control the matching private key.
| Key | Holder | Purpose |
| - | - | - |
| Merchant private key | Merchant | Signs API requests and Webhook responses |
| Merchant public key | Waffo | Verifies merchant signatures |
| Waffo private key | Waffo | Signs API responses and Webhooks |
| Waffo public key | Merchant | Verifies Waffo signatures |
## Prerequisites
* You activated the production account.
* Your role is **Super Admin**, **Admin**, or **Dev**.
* You generated an RSA key pair used only for production.
* You can securely access the merchant private key. Never upload the private key to Dashboard or send it to Waffo.
## Public-key requirements
| Item | Requirement |
| - | - |
| Key size | 2048 bits |
| Signature algorithm | `SHA256WithRSA` |
| Public-key format | Single-line X.509/SPKI Base64 without PEM headers or footers |
| Public-key string length | 392 characters |
## Generate the production key pair
```bash theme={null}
openssl genpkey -algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out merchant_private_key.pem
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \
-in merchant_private_key.pem \
| grep -v '^-----' \
| tr -d '\n' > merchant_private_key.base64
openssl rsa -in merchant_private_key.pem -pubout \
| grep -v '^-----' \
| tr -d '\n' > merchant_public_key.base64
```
Upload the contents of `merchant_public_key.base64`. Store `merchant_private_key.pem` and `merchant_private_key.base64` in your server-side key management system.
## Configure the key
**1. Sign in to Dashboard**
Open [Waffo Dashboard](https://dashboard.waffo.com/auth/login), then go to **Settings → Integration**.
**2. Start configuration**
In **Merchant Sign Configuration Details**, click **Configure** and set **API Operation Type** to **Payin**.
**3. Upload the public key**
Paste the complete merchant public key into the public-key field.
**4. Generate the verification signature**
Copy the verification string generated for the current Merchant. Sign it with the matching merchant private key using `SHA256WithRSA`.
```bash theme={null}
echo -n "WAFFO_VERIFY_XXXXXXXXXX" | \
openssl dgst -sha256 -sign merchant_private_key.pem | \
base64 | tr -d '\n'
```
Replace `WAFFO_VERIFY_XXXXXXXXXX` with the complete string shown in Dashboard.
**5. Submit for review**
Paste the single-line Base64 signature into the verification field. Click **Confirm**, then click **Confirm** again in the confirmation dialog.
## Check the review status
After submission, the key first appears as **Pending**. Waffo normally completes the review within one business day and sends the result by email.
| Status | Meaning |
| - | - |
| **Pending** | Submitted and waiting for Waffo review |
| **Active** | Activated and available for production API requests |
| **Rejected** | Not approved; review the reason and submit again |
> [!WARNING]
>
> Do not send production transactions with the new key before its status becomes **Active**.
## Add or rotate a key
The existing **Active** key remains valid while Waffo reviews a new key. Switch the production private key only after the new public key becomes **Active**.
If the new public key is identical to an existing **Active** key, Dashboard returns `public key already exists`. Generate a new key pair before submitting again.
## Resubmit a rejected key
1. Open **Settings → Integration**.
2. Find the version under **Historical Versions** and read its **Reject Reason**.
3. Regenerate or correct the key based on that reason.
4. Repeat public-key configuration and signature verification.
Each submission creates a new history entry and does not affect an existing **Active** key.
## Common errors
| Error | Cause | Resolution |
| - | - | - |
| `signature verification failed` | The private key does not match the public key, or the signature is incomplete or contains spaces or line breaks | Confirm the key pair and generate a complete single-line Base64 signature |
| `public key count exceeds limit` | The **API Operation Type** has reached its **Active** key limit | Contact Waffo technical support to deactivate an unused key |
| `public key record not found` | The target public-key record does not exist | Refresh the history; if the problem continues, contact support with a screenshot |
| `public key already exists` | The submitted key matches an existing **Active** key | Generate a completely new public and private key pair |
Next: Return to [Production go-live](/docs/en/developer-docs/getting-started/go-live) and verify a production order.
# Integration guide
Source: https://waffo.com/docs/en/developer-docs/getting-started/quickstart
Complete your first payment in 5 minutes and quickly integrate Waffo Payments.
## Choose an integration path
Choose one path before you start. Both paths end with sandbox testing and submitting acceptance materials to the Waffo technical support group.
### API integration
Use this path if you already have your own checkout, need full server-side control, or want to integrate manually with the API Reference.
**1. Choose the payment product**
Use one-time payment for single-charge orders, or subscription payment for recurring billing.
**2. Integrate the required APIs**
Follow the [API direct](/docs/en/developer-docs/integration/api/overview) roadmap and implement the required APIs.
**3. Check API Reference details**
Use [API Reference](/docs/api-reference/introduction) for fields, requests, responses, and signing details.
**4. Complete extra payment setup**
If you need Apple Pay or Google Pay, complete the extra testing and configuration in [Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox).
**5. Submit acceptance results**
Finish sandbox testing, download and fill in the acceptance case template, then submit the results to the Waffo technical support group.
### AI integration tool
Use this path if you want an AI coding tool to generate SDK integration code, tests, and a test report.
**1. Install waffo-integrate**
Run `npx @waffo/waffo-integrate`.
**2. Trigger the integration**
Trigger Waffo integration in Codex, Claude Code, or Cursor.
**3. Answer business questions**
Answer the AI's business questions, such as product type, refund, subscription, and whether Webhook integration is required.
**4. Review code and test report**
The AI generates code, runs integration tests, and outputs a test report.
**5. Submit the test report**
Submit the test report to the Waffo technical support group for confirmation.
## Recommended: Use the AI integration tool (waffo-integrate)
**waffo-integrate** is Waffo's official AI integration Skill. It uses an interactive wizard to generate production-ready SDK integration code, then checks the result through Step 6 verification. Version `1.7.0` supports Node.js, Java, Go, Python, and PHP projects.
### Installation
```bash Auto-detect (recommended) theme={null}
npx @waffo/waffo-integrate
```
```bash Codex theme={null}
npx @waffo/waffo-integrate --codex
```
```bash Shared agents theme={null}
npx @waffo/waffo-integrate --agents
```
```bash Claude Code theme={null}
npx @waffo/waffo-integrate --claude
```
```bash Cursor theme={null}
npx @waffo/waffo-integrate --cursor
```
### Usage
After installation, trigger it in your AI assistant:
* Type `integrate waffo`
* The Skill will guide you through: selecting a feature (payment / refund / subscription / Webhook) → detecting your language → generating code → running validation tests
### Supported languages and features
| Language | SDK | Frameworks |
| - | - | - |
| Node.js | `@waffo/waffo-node` | Express, NestJS, Fastify |
| Java | `com.waffo:waffo-java` | Spring Boot |
| Go | `github.com/waffo-com/waffo-go/v2` | Gin, Echo, Fiber, Chi |
| Python | `waffo` | FastAPI, Flask, Django |
| PHP | `waffo/waffo-php` | Laravel, Symfony, Slim, plain PHP |
All features can be integrated automatically via the Skill: payments, refunds, subscriptions (including upgrades and downgrades), Webhooks, merchant config queries, and payment method queries.
### Advantages
| Metric | Using the Skill | Manual integration |
| - | - | - |
| Eval coverage | 36 prompt scenarios / 117 assertions | Must maintain cases manually |
| Code generation rules | 33 guardrails | Must consult docs manually |
| Verification flow | Step 6 phased verification + report gate | Must organize acceptance manually |
> [!TIP]
>
> For full documentation, see [waffo-integrate on GitHub](https://github.com/waffo-com/waffo-integrate).
***
## Manual integration
If you prefer not to use an AI tool, follow these steps to integrate manually.
## Prerequisites
* Waffo Merchant account
* API Key (from the Dashboard)
* RSA key pair (see [Authentication and signing](/docs/en/developer-docs/integration/api/overview))
**1. Install the SDK**
```bash Node.js theme={null}
npm install @waffo/waffo-node
```
```xml Java theme={null}
com.waffo
waffo-java
3.1.0
```
```bash Go theme={null}
go get github.com/waffo-com/waffo-go/v2
```
```bash Python theme={null}
python -m pip install "waffo==0.5.0b0"
# Or install the latest pre-release
python -m pip install --pre waffo
```
```bash PHP theme={null}
composer require waffo/waffo-php
```
**2. Initialize the client**
```typescript Node.js theme={null}
import { Waffo, Environment } from '@waffo/waffo-node';
const { WAFFO_API_KEY, WAFFO_PRIVATE_KEY, WAFFO_PUBLIC_KEY, WAFFO_MERCHANT_ID } = process.env;
if (!WAFFO_API_KEY || !WAFFO_PRIVATE_KEY || !WAFFO_PUBLIC_KEY || !WAFFO_MERCHANT_ID) {
throw new Error('Set WAFFO_API_KEY, WAFFO_PRIVATE_KEY, WAFFO_PUBLIC_KEY, and WAFFO_MERCHANT_ID');
}
const waffo = new Waffo({
apiKey: WAFFO_API_KEY,
privateKey: WAFFO_PRIVATE_KEY,
waffoPublicKey: WAFFO_PUBLIC_KEY,
merchantId: WAFFO_MERCHANT_ID,
environment: Environment.SANDBOX,
});
```
```java Java theme={null}
import com.waffo.Waffo;
import com.waffo.types.config.WaffoConfig;
import com.waffo.types.config.Environment;
WaffoConfig config = WaffoConfig.builder()
.apiKey(System.getenv("WAFFO_API_KEY"))
.privateKey(System.getenv("WAFFO_PRIVATE_KEY"))
.waffoPublicKey(System.getenv("WAFFO_PUBLIC_KEY"))
.merchantId(System.getenv("WAFFO_MERCHANT_ID"))
.environment(Environment.SANDBOX)
.build();
Waffo waffo = new Waffo(config);
```
```go Go theme={null}
import (
"os"
waffo "github.com/waffo-com/waffo-go/v2"
"github.com/waffo-com/waffo-go/v2/config"
)
cfg, err := config.NewConfigBuilder().
APIKey(os.Getenv("WAFFO_API_KEY")).
PrivateKey(os.Getenv("WAFFO_PRIVATE_KEY")).
WaffoPublicKey(os.Getenv("WAFFO_PUBLIC_KEY")).
MerchantID(os.Getenv("WAFFO_MERCHANT_ID")).
Environment(config.Sandbox).
Build()
if err != nil {
panic(err)
}
client := waffo.New(cfg)
```
```python Python theme={null}
import os
from waffo import Environment, Waffo, WaffoConfig
config = WaffoConfig(
api_key=os.environ["WAFFO_API_KEY"],
private_key=os.environ["WAFFO_PRIVATE_KEY"],
waffo_public_key=os.environ["WAFFO_PUBLIC_KEY"],
merchant_id=os.environ["WAFFO_MERCHANT_ID"],
environment=Environment.SANDBOX,
)
waffo = Waffo(config)
```
```php PHP theme={null}
[!NOTE]
>
> Expiration date: any future dateCVV: any 3 digits (AMEX uses 4 digits)
## Sandbox simulator
The sandbox does not connect to real payment channels. Use the simulator to control payment outcomes directly:
* **One-time payment**: After creating an order, open the checkout page. The page provides "Payment Success" and "Payment Failed" buttons — click either to trigger the corresponding result and Webhook.
* **Subscription first payment**: Same as one-time payment; operate on the checkout page.
* **Subscription renewal simulation**: Call `POST /api/v1/subscription/manage` to get the management page URL → open the management page → the page provides "Simulate Next Payment Success" and "Simulate Next Payment Failed" buttons; each click simulates one renewal cycle.
Example subscription renewal management page:

## Payment method testing methods
Some payment methods require dedicated checks in addition to regular sandbox payment testing.
### Octopus testing
If you need to complete QR code payment testing with the Octopus test app, see [Octopus sandbox test payment](/docs/en/developer-docs/tools-and-references/developer-tools/octopus-sandbox-e2e).
### Google Pay testing
If your integration needs Google Pay on Checkout or your own frontend, complete these checks.
1. Sign in with a Google Pay test account.
2. Add the account to the [Google Pay test mode stub data group](https://groups.google.com/g/googlepay-test-mode-stub-data).
3. Confirm that the target country or region is in the [Google Pay supported countries list](https://support.google.com/googlepay/answer/12429287?hl=en-GB#zippy=).
4. If you load the payment page inside an Android App WebView, apply the [Google Pay Android WebView configuration](https://developers.google.com/pay/api/android/guides/recipes/using-android-webview#groovy).
5. Before production launch, Android Apps must request Google Pay API access through the [Google Pay publish guide](https://developers.google.com/pay/api/android/guides/test-and-deploy/publish-your-integration).
6. If production access is not approved, users may see `OR_BIBED_11` or a similar error.
When applying for production access, select Gateway as the gateway type. After submitting the materials, provide the Google account merchant MID and app name to the Waffo technical support group if you need Waffo to help accelerate review.
### Apple Pay testing
If your integration needs Apple Pay on Checkout or your own frontend, complete these checks.
1. We recommend testing with the merchant's own Apple Pay account.
2. The test account must have a valid Visa, Mastercard, or Amex card.
3. UnionPay cards are not supported for Apple Pay testing.
4. If you see `Service unavailable`, check the device or account region settings.
5. Web testing may require a global proxy. Mobile payment-link testing can usually work with partial proxy coverage when the Apple Pay sheet is launched from the link.
6. Before launch, confirm that the target country or region is in the [Apple Pay supported countries list](https://support.apple.com/en-gb/102775).
## Testing Webhooks
The sandbox environment supports Webhook callbacks. Make sure your `notifyUrl` is publicly accessible. For local development, it is recommended to use an intranet tunneling tool (such as ngrok or cloudflared).
> [!WARNING]
>
> Sandbox and production use different API Keys and RSA keys. Octopus, Apple Pay, and Google Pay also require separate checks for app, account, country, WebView, or production permission requirements.
# AI integration tool (waffo-integrate)
Source: https://waffo.com/docs/en/developer-docs/integration/ai-integration
Automatically complete and verify Waffo SDK integration with AI coding tools.
waffo-integrate is Waffo’s official AI integration Skill. It helps developers automatically complete SDK integration through an interactive wizard. It supports AI coding tools such as Codex, Claude Code, and Cursor. Version `1.7.0` supports Node.js, Java, Go, Python, and PHP projects.
> [!NOTE]
>
> "AI" on this page means using AI coding tools to complete SDK integration. If you want to accept stablecoin payments from AI Agents, see [x402 stablecoin acquiring](/docs/en/developer-docs/use-cases/x402-stablecoin-acquiring).
## Integration roadmap
**1. Prepare your project**
Open your project in Codex, Claude Code, or Cursor.
**2. Install the Skill**
Run `npx @waffo/waffo-integrate`.
**3. Trigger Waffo integration**
Ask your AI assistant to integrate Waffo payment.
**4. Answer business questions**
Select the capabilities you need, such as payment, refund, subscription, Webhook, merchant configuration lookup, and payment method lookup.
**5. Review and generate code**
The AI tool previews the code, then generates SDK initialization, services, Webhook handlers, and tests after confirmation.
**6. Run verification**
The AI tool runs integration tests covering payment, refund, subscription, and Webhook flows.
**7. Submit the test report**
Send the generated test report to the Waffo technical support group for confirmation.
## Why use waffo-integrate
| Metric | Using the Skill | Manual integration | Improvement |
| - | - | - | - |
| Eval coverage | 36 prompt scenarios / 117 assertions | Manual case maintenance | Automated regression |
| Code generation rules | 33 guardrails | Manual doc lookup | Automatic prevention |
| Verification flow | Step 6 phased verification + report gate | Self-organized acceptance | Evidence-based closure |
## Installation
```bash Automatic detection (recommended) theme={null}
npx @waffo/waffo-integrate
```
```bash Codex theme={null}
npx @waffo/waffo-integrate --codex
```
```bash Shared agents theme={null}
npx @waffo/waffo-integrate --agents
```
```bash Claude Code theme={null}
npx @waffo/waffo-integrate --claude
claude /install-skill waffo-com/waffo-integrate
```
```bash Cursor theme={null}
npx @waffo/waffo-integrate --cursor
```
## Integration flow
**1. Trigger the Skill**
Enter a trigger phrase in your AI assistant: `集成 Waffo 支付`, `integrate waffo`, `接入waffo`, `waffo sdk`, or `waffo payment`.
**2. Language detection**
The Skill automatically detects the project language: `package.json` → Node.js, `pom.xml` / `build.gradle` → Java, `go.mod` → Go, `pyproject.toml` / `requirements.txt` / `Pipfile` / `uv.lock` / `setup.py` → Python, and `composer.json` → PHP.
**3. Feature selection**
Interactively select the features you need (prompted one by one): payments, refunds, subscriptions, Webhook, merchant configuration lookup, payment method lookup. Smart recommendations: if you select payments, it will suggest adding refunds; if you select subscriptions, it will suggest adding Webhook.
**4. Framework selection (Webhook only)**
| Language | Recommended framework | Other options |
| - | - | - |
| Node.js | Express | NestJS, Fastify |
| Java | Spring Boot | — |
| Go | Gin | Echo, Fiber, Chi |
| Python | FastAPI | Flask, Django |
| PHP | Laravel | Symfony, Slim, plain PHP |
**5. Code preview & generation**
The Skill first displays the complete code for review, then generates it after confirmation: SDK initialization, payment/refund/subscription services, Webhook handling, test files, and `.env.example`.
**6. Integration verification**
After code generation and build pass, the Skill runs Step 6 phased acceptance tests for the features you integrate and records results and evidence. It generates a formal test report only after the required stages and evidence are complete. If evidence is missing or tests fail, it outputs `Verification Blocked` or `Failed Summary`, without marking incomplete verification as passed.
See [Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria) for requirements and case templates.
After verification passes, send the formal test report to your company and Waffo technical support group, such as the WeCom or Lark group, so Waffo can confirm the integration result.
## 33 built-in code generation rules
The Skill’s built-in rules automatically prevent common mistakes. The complete list lives in `references/code-generation-rules.md`:
| # | Rule | Prevents |
| - | - | - |
| 1 | Request ID max length is 32 characters | Values longer than the Waffo idempotency key limit |
| 2 | Subscriptions use `currency`, not `orderCurrency` | Field name confusion |
| 3 | Subscriptions use `amount`, not `orderAmount` | Field name confusion |
| 4 | Required-field checks for each operation | Missing `payMethodType`, `goodsInfo`, etc. |
| 5 | `periodType` is only DAILY/WEEKLY/MONTHLY | Invalid enums `YEARLY`, `MONTH` |
| 6 | `periodInterval` is String, not Number | Type errors |
| 7 | Subscriptions must include `payMethodType` | SDK error A0003 |
| 8 | `productName` is only ONE\_TIME\_PAYMENT / SUBSCRIPTION | Invalid product type |
| 9 | Use `isSuccess()` to check responses | Missing error handling |
| 10 | Webhook must verify the signature | Security vulnerability |
| 11 | Java uses `WaffoConfig.builder()` | SDK initialization failure |
| 12 | Timestamps are auto-injected by the SDK | Incorrect manual timestamp format |
| 13 | merchantId is auto-injected by the SDK | Duplicate setting |
| 14-33 | Webhook three-stage business handling, UnknownStatus recovery for every write operation, redirect URLs, goodsInfo, payment method, currency, refund notification URL, and report-evidence guardrails | Missing money-state handling, redirects, risk-control fields, or report evidence |
## Generated code features
* **Error handling**: distinguishes between `WaffoUnknownStatusError` (may have succeeded) and `WaffoError` (client error)
* **Security**: Webhook signature verification and credential management via environment variables
* **Best practices**: idempotent request IDs, service-layer separation, register Webhook before parsing JSON
* **Testing**: sandbox integration test stubs, test card numbers
For detailed documentation, see [waffo-integrate GitHub](https://github.com/waffo-com/waffo-integrate).
# Code examples
Source: https://waffo.com/docs/en/developer-docs/integration/api/examples
Multilingual API integration code examples for Node.js, Java, Go, Python, PHP, and raw HTTP.
The following examples use the same one-time payment payload so you can compare SDK and raw HTTP integration.
## Node.js (using the SDK)
```typescript theme={null}
import { Waffo, Environment } from '@waffo/waffo-node';
import { randomUUID } from 'crypto';
const { WAFFO_API_KEY, WAFFO_PRIVATE_KEY, WAFFO_PUBLIC_KEY, WAFFO_MERCHANT_ID } = process.env;
if (!WAFFO_API_KEY || !WAFFO_PRIVATE_KEY || !WAFFO_PUBLIC_KEY || !WAFFO_MERCHANT_ID) {
throw new Error('Set WAFFO_API_KEY, WAFFO_PRIVATE_KEY, WAFFO_PUBLIC_KEY, and WAFFO_MERCHANT_ID');
}
const waffo = new Waffo({
apiKey: WAFFO_API_KEY,
privateKey: WAFFO_PRIVATE_KEY,
waffoPublicKey: WAFFO_PUBLIC_KEY,
merchantId: WAFFO_MERCHANT_ID,
environment: Environment.SANDBOX,
});
const paymentRequestId = randomUUID().replace(/-/g, '');
const response = await waffo.order().create({
paymentRequestId,
merchantOrderId: `ORDER_${Date.now()}`,
orderCurrency: 'HKD',
orderAmount: '100.00',
orderDescription: 'Premium Plan',
notifyUrl: 'https://your-site.com/webhook/waffo',
successRedirectUrl: 'https://your-site.com/payment/success',
failedRedirectUrl: 'https://your-site.com/payment/failed',
cancelRedirectUrl: 'https://your-site.com/payment/cancel',
userInfo: {
userId: 'user_123',
userEmail: 'user@example.com',
userTerminal: 'WEB',
},
paymentInfo: { productName: 'ONE_TIME_PAYMENT' },
goodsInfo: {
goodsName: 'Premium Plan',
goodsUrl: 'https://your-site.com/product/001',
},
});
```
## Java (using the SDK)
```java theme={null}
import com.waffo.Waffo;
import com.waffo.types.config.WaffoConfig;
import com.waffo.types.config.Environment;
import com.waffo.types.ApiResponse;
import com.waffo.types.order.*;
import com.waffo.types.payment.ProductName;
import com.waffo.types.iso.CurrencyCode;
import java.util.UUID;
WaffoConfig config = WaffoConfig.builder()
.apiKey(System.getenv("WAFFO_API_KEY"))
.privateKey(System.getenv("WAFFO_PRIVATE_KEY"))
.waffoPublicKey(System.getenv("WAFFO_PUBLIC_KEY"))
.merchantId(System.getenv("WAFFO_MERCHANT_ID"))
.environment(Environment.SANDBOX)
.build();
Waffo waffo = new Waffo(config);
String paymentRequestId = UUID.randomUUID().toString().replace("-", "");
CreateOrderParams params = CreateOrderParams.builder()
.paymentRequestId(paymentRequestId)
.merchantOrderId("ORDER_" + System.currentTimeMillis())
.orderCurrency(CurrencyCode.HKD)
.orderAmount("100.00")
.orderDescription("Premium Plan")
.notifyUrl("https://your-site.com/webhook/waffo")
.successRedirectUrl("https://your-site.com/payment/success")
.failedRedirectUrl("https://your-site.com/payment/failed")
.cancelRedirectUrl("https://your-site.com/payment/cancel")
.userInfo(UserInfo.builder()
.userId("user_123")
.userEmail("user@example.com")
.userTerminal(UserTerminalType.WEB)
.build())
.paymentInfo(PaymentInfo.builder()
.productName(ProductName.ONE_TIME_PAYMENT)
.build())
.goodsInfo(GoodsInfo.builder()
.goodsName("Premium Plan")
.goodsUrl("https://your-site.com/product/001")
.build())
.build();
ApiResponse response = waffo.order().create(params);
```
## Go (using the SDK)
```go theme={null}
import (
"context"
"fmt"
"os"
"strings"
"github.com/google/uuid"
waffo "github.com/waffo-com/waffo-go/v2"
"github.com/waffo-com/waffo-go/v2/config"
"github.com/waffo-com/waffo-go/v2/types/order"
)
cfg, err := config.NewConfigBuilder().
APIKey(os.Getenv("WAFFO_API_KEY")).
PrivateKey(os.Getenv("WAFFO_PRIVATE_KEY")).
WaffoPublicKey(os.Getenv("WAFFO_PUBLIC_KEY")).
MerchantID(os.Getenv("WAFFO_MERCHANT_ID")).
Environment(config.Sandbox).
Build()
if err != nil {
panic(err)
}
client := waffo.New(cfg)
paymentRequestID := strings.ReplaceAll(uuid.New().String(), "-", "")
resp, err := client.Order().Create(context.Background(), &order.CreateOrderParams{
PaymentRequestID: paymentRequestID,
MerchantOrderID: "ORDER-" + uuid.New().String()[:8],
OrderCurrency: "HKD",
OrderAmount: "100.00",
OrderDescription: "Premium Plan",
NotifyURL: "https://your-site.com/webhook/waffo",
SuccessRedirectURL: "https://your-site.com/payment/success",
FailedRedirectURL: "https://your-site.com/payment/failed",
CancelRedirectURL: "https://your-site.com/payment/cancel",
UserInfo: &order.UserInfo{
UserID: "user_123",
UserEmail: "user@example.com",
UserTerminal: "WEB",
},
PaymentInfo: &order.PaymentInfo{
ProductName: "ONE_TIME_PAYMENT",
},
GoodsInfo: &order.GoodsInfo{
GoodsName: "Premium Plan",
GoodsURL: "https://your-site.com/product/001",
},
}, nil)
if err != nil {
panic(err)
}
if !resp.IsSuccess() {
panic(fmt.Sprintf("Order creation failed: %s %s", resp.GetCode(), resp.GetMessage()))
}
fmt.Printf("Order ID: %s\n", resp.GetData().AcquiringOrderID)
```
## Python (using the SDK)
```python theme={null}
import os
from uuid import uuid4
from waffo import Environment, Waffo, WaffoConfig
waffo = Waffo(
WaffoConfig(
api_key=os.environ["WAFFO_API_KEY"],
private_key=os.environ["WAFFO_PRIVATE_KEY"],
waffo_public_key=os.environ["WAFFO_PUBLIC_KEY"],
merchant_id=os.environ["WAFFO_MERCHANT_ID"],
environment=Environment.SANDBOX,
)
)
payment_request_id = uuid4().hex
response = waffo.order().create(
{
"paymentRequestId": payment_request_id,
"merchantOrderId": f"ORDER_{payment_request_id}",
"orderCurrency": "HKD",
"orderAmount": "100.00",
"orderDescription": "Premium Plan",
"notifyUrl": "https://your-site.com/webhook/waffo",
"successRedirectUrl": "https://your-site.com/payment/success",
"failedRedirectUrl": "https://your-site.com/payment/failed",
"cancelRedirectUrl": "https://your-site.com/payment/cancel",
"userInfo": {
"userId": "user_123",
"userEmail": "user@example.com",
"userTerminal": "WEB",
},
"paymentInfo": {"productName": "ONE_TIME_PAYMENT"},
"goodsInfo": {
"goodsName": "Premium Plan",
"goodsUrl": "https://your-site.com/product/001",
},
}
)
```
## PHP (using the SDK)
```php theme={null}
order()->create([
'paymentRequestId' => $paymentRequestId,
'merchantOrderId' => 'ORDER_' . time(),
'orderCurrency' => 'HKD',
'orderAmount' => '100.00',
'orderDescription' => 'Premium Plan',
'notifyUrl' => 'https://your-site.com/webhook/waffo',
'successRedirectUrl' => 'https://your-site.com/payment/success',
'failedRedirectUrl' => 'https://your-site.com/payment/failed',
'cancelRedirectUrl' => 'https://your-site.com/payment/cancel',
'userInfo' => [
'userId' => 'user_123',
'userEmail' => 'user@example.com',
'userTerminal' => 'WEB',
],
'paymentInfo' => ['productName' => 'ONE_TIME_PAYMENT'],
'goodsInfo' => [
'goodsName' => 'Premium Plan',
'goodsUrl' => 'https://your-site.com/product/001',
],
]);
```
## Raw HTTP
```bash theme={null}
curl -X POST https://api-sandbox.waffo.com/api/v1/order/create \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_API_KEY" \
-H "X-SIGNATURE: YOUR_RSA_SIGNATURE" \
-H "X-API-VERSION: 1.0.0" \
-d '{
"paymentRequestId": "abc123def456",
"merchantOrderId": "ORDER_001",
"orderCurrency": "HKD",
"orderAmount": "100.00",
"orderDescription": "Premium Plan",
"orderRequestedAt": "2026-08-06T00:00:00.000Z",
"notifyUrl": "https://your-site.com/webhook/waffo",
"successRedirectUrl": "https://your-site.com/payment/success",
"failedRedirectUrl": "https://your-site.com/payment/failed",
"cancelRedirectUrl": "https://your-site.com/payment/cancel",
"merchantInfo": { "merchantId": "M000001" },
"userInfo": {
"userId": "user_123",
"userEmail": "user@example.com",
"userTerminal": "WEB"
},
"paymentInfo": { "productName": "ONE_TIME_PAYMENT" },
"goodsInfo": {
"goodsName": "Premium Plan",
"goodsUrl": "https://your-site.com/product/001"
}
}'
```
# Overview
Source: https://waffo.com/docs/en/developer-docs/integration/api/overview
A manual API integration roadmap for one-time payment and subscription payment.
API integration is suitable when you need full control over the payment flow. You call the Waffo REST API directly and connect payment, subscription, refund, and Webhook handling to your own system.
## Applicable scenarios
* Server-to-server payment processing
* You already have your own hosted checkout page or payment form
* Custom integration requiring maximum flexibility
## Integration roadmap
**1. Choose the product**
Choose one-time payment for single-charge orders, or subscription payment for recurring billing.
**2. Integrate required APIs**
One-time payment requires order create, order inquiry, and payment notification. Subscription requires subscription create, subscription inquiry, subscription status notification, and subscription payment notification.
**3. Add conditional APIs as needed**
Add cancel, refund, refund inquiry, refund notification, subscription manage, or subscription change only when your business flow needs them.
**4. Use API Reference for field details**
Request fields, response fields, enums, and signing rules are maintained in [API Reference](/docs/api-reference/introduction). Do not copy field definitions from offline PDFs.
**5. Test in sandbox**
Use [Sandbox and testing](/docs/en/developer-docs/getting-started/sandbox), including Apple Pay and Google Pay checks if needed.
**6. Submit acceptance materials**
For your first integration, download the [one-time payment acceptance template](/docs/files/developer-docs/acceptance/one-time-payment-acceptance-cases.json) or [subscription acceptance template](/docs/files/developer-docs/acceptance/subscription-acceptance-cases.json). Only for a subsequent one-time payment integration, download the [one-time payment additional integration acceptance template](/docs/files/developer-docs/acceptance/additional-integration-acceptance-cases.xlsx). Fill in the executed cases and submit the result to the Waffo technical support group.
## Base URL
| Environment | URL |
| - | - |
| Sandbox | `https://api-sandbox.waffo.com` |
| Production | `https://api.waffo.com` |
## Request format
* Content-Type: `application/json` (UTF-8 encoding)
* All requests use HTTP POST
* Request headers must include `X-API-KEY` and `X-SIGNATURE`
* Raw HTTP requests must explicitly send `X-API-VERSION: 1.0.0`. SDKs manage this header automatically; do not set or override it. The `v1.3` value in API version history identifies documentation and external contract revisions and must not be used as this request header
* `subscriptionManagementUrl` is required for every `/api/v1/subscription/create` request
## Response format
```json theme={null}
{
"code": "0",
"msg": "Success",
"data": { ... }
}
```
* `code` is `"0"` indicates success
* On failure, `code` is an error code (for example `"A0003"`), and `data` is empty
* Response header `X-SIGNATURE` contains the Waffo signature and **must be verified**
## Core APIs
### One-time payment
| API | Required | Description |
| - | - | - |
| `POST /api/v1/order/create` | Required | Create a one-time payment order |
| `POST /api/v1/order/inquiry` | Required | Query order status, including redirect loss, timeout, and Unknown Status recovery |
| Payment order notification | Required | Receive terminal payment results and update the merchant order state |
| `POST /api/v1/order/cancel` | Conditional | Cancel unpaid orders when your business supports cancellation |
| `POST /api/v1/order/refund` | Conditional | Initiate refunds when your business supports refunds |
| `POST /api/v1/refund/inquiry` or refund notification | Conditional | Query or receive refund processing results |
| `POST /api/v1/order/capture` | Conditional | Required only for Auth-Capture manual capture mode |
### Subscription payment
| API | Required | Description |
| - | - | - |
| `POST /api/v1/subscription/create` | Required | Create a subscription |
| `POST /api/v1/subscription/inquiry` | Required | Query subscription status and main payment information |
| Subscription status notification | Required | Receive subscription activation, cancellation, and status changes |
| Subscription payment order notification | Required | Receive first-payment and renewal payment results |
| `POST /api/v1/subscription/cancel` | Conditional | Cancel subscriptions from the merchant side |
| `POST /api/v1/subscription/manage` | Conditional | Generate a management URL for user-side subscription management |
| `POST /api/v1/subscription/change` | Conditional | Support plan upgrades or downgrades |
| `POST /api/v1/order/inquiry` | Conditional | Query detailed failure reasons for subscription payment orders |
### Configuration lookup
| API | Required | Description |
| - | - | - |
| `POST /api/v1/merchantconfig/inquiry` | Recommended | Query merchant limits and configuration |
| `POST /api/v1/paymethodconfig/inquiry` | Recommended | Query available payment methods and payment limits |
## Acceptance templates
After API integration and sandbox testing, choose the template for your integration stage:
* First one-time payment integration: [One-time payment acceptance template](/docs/files/developer-docs/acceptance/one-time-payment-acceptance-cases.json)
* First subscription payment integration: [Subscription acceptance template](/docs/files/developer-docs/acceptance/subscription-acceptance-cases.json)
* Subsequent one-time payment integration: [One-time payment additional integration acceptance template](/docs/files/developer-docs/acceptance/additional-integration-acceptance-cases.xlsx)
> [!NOTE]
>
> This template applies to `ONE_TIME_PAYMENT`. Use it only after you have completed at least one integration acceptance and the current integration is not your first. Do not use it for your first integration.
Submit the completed acceptance result to the Waffo technical support group. See [Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria) before going live.
# Direct Apple Pay integration
Source: https://waffo.com/docs/en/developer-docs/integration/apple-pay/merchant-direct-integration
Decrypt Apple Pay tokens on your server and pass the decrypted result to Waffo in the required format.
For a direct integration, use Apple Pay JS or PassKit to obtain the Apple Pay token and send it to Waffo from your server.
You manage:
* an Apple Developer account and Merchant ID;
* a Payment Processing Certificate and its private key;
* domain verification for Apple Pay on the Web;
* the Apple Pay JS or PassKit frontend integration;
* token signature verification, decryption, replay protection, and sensitive-data compliance.
Before you begin, contact Waffo technical support to confirm the Merchant and payment method configuration.
## Currently supported: Send a decrypted token
You must currently decrypt the Apple Pay token on your server and send the decrypted result to Waffo in the format below.
> [!NOTE]
>
> The request must contain `token.decryptedPaymentData`. You may also keep `token.paymentData`, but you cannot submit it as the only pass-through result.
## Original encrypted token structure
Apple Pay JS or PassKit returns the following original payment token envelope. This object is currently the input for signature verification and decryption on the Merchant server. It is not a [`paymentTokenData`](/docs/api-reference/order-create/create-new-order#body-payment-token-data) value that you can submit directly to Waffo.
```json theme={null}
{
"billingContact": {
"countryCode": "US",
"givenName": "wz",
"familyName": "w",
"postalCode": "20001",
"addressLines": ["dk", "dh"],
"administrativeArea": "AL",
"locality": "djj"
},
"token": {
"paymentMethod": {
"network": "MasterCard",
"type": "credit",
"displayName": "MasterCard 4444"
},
"transactionIdentifier": "e392617d9e2f7938ca727c6fd063dc7915e42f546bca810ea43f7c2759c52a26",
"paymentData": {
"data": "",
"signature": "",
"header": {
"publicKeyHash": "",
"ephemeralPublicKey": "",
"transactionId": ""
},
"version": "EC_v1"
}
}
}
```
| Field | Description |
| - | - |
| `token.paymentData` | The encrypted payment data, which the Merchant currently decrypts with the certificate private key |
| `token.paymentMethod.network` | The card network, such as `MasterCard` or `Visa` |
| `billingContact` | The billing contact returned when Apple Pay JS requests `requiredBillingContactFields`; it supplies the cardholder name and billing address |
## Decrypt and validate the token
In the token returned by Apple Pay JS or PassKit, `token.paymentData` contains the encrypted payment data. On your server:
**1. Select the decryption key**
Use the private key that matches the Payment Processing Certificate.
**2. Validate the token**
Verify the token signature and certificate chain according to Apple's specification.
**3. Decrypt the payment data**
Decrypt `token.paymentData.data` according to the token `version`, then parse the UTF-8 result as JSON.
**4. Validate the transaction**
Confirm that `transactionId` has not been processed, and compare the decrypted currency and amount with the order.
**5. Build the Waffo request**
Put the decrypted JSON in `token.decryptedPaymentData`, then submit it to Waffo.
Follow Apple's [Payment token format reference](https://developer.apple.com/documentation/PassKit/payment-token-format-reference) for the complete cryptographic procedure and field definitions. See [Setting up Apple Pay](https://developer.apple.com/documentation/PassKit/setting-up-apple-pay) for certificate setup.
> [!WARNING]
>
> Decrypted data contains the device account number and payment cryptogram. Do not decrypt it in the browser or log the complete token, device account number, private key, or payment cryptogram.
## Decrypted payload structure
Decrypting `token.paymentData` produces the payment token payload defined by Apple:
```json theme={null}
{
"applicationPrimaryAccountNumber": "5555555555554444",
"applicationExpirationDate": "270831",
"currencyCode": "156",
"transactionAmount": 10,
"deviceManufacturerIdentifier": "050110030273",
"paymentDataType": "3DSecure",
"paymentData": {
"onlinePaymentCryptogram": "AORgiMGqVyeCAAt1LKSuAoABFA==",
"eciIndicator": ""
}
}
```
MPAN scenarios for Subscription payments also include Merchant Token information such as `merchantTokenIdentifier`.
| Field | Description |
| - | - |
| `applicationPrimaryAccountNumber` | Device account number (DPAN) |
| `applicationExpirationDate` | Card expiration date as six digits in `YYMMDD` format |
| `paymentData.onlinePaymentCryptogram` | 3DS cryptogram used for payment verification; it may be empty |
| `paymentDataType` | Payment data type: `3DSecure` or `EMV` |
| Other fields | Supporting data such as the device identifier, ECI, and Merchant Token information |
## Pass-through format
The decrypted payload alone is insufficient. It does not contain the card network, and an Apple Pay DPAN BIN generally cannot be used to determine the network. `billingContact` is also outside the encrypted data and is the only source of the cardholder name and billing address. Preserve the outer structure returned by Apple Pay and add `token.decryptedPaymentData` inside `token`. You may also keep `token.paymentData`.
The following JSON is the content of the [`paymentTokenData`](/docs/api-reference/order-create/create-new-order#body-payment-token-data) field in [`/api/v1/order/create`](/docs/api-reference/order-create/create-new-order):
```json theme={null}
{
"billingContact": {
"countryCode": "US",
"givenName": "wz",
"familyName": "w",
"postalCode": "20001",
"addressLines": ["dk", "dh"],
"administrativeArea": "AL",
"locality": "djj"
},
"token": {
"paymentMethod": {
"network": "MasterCard",
"type": "credit",
"displayName": "MasterCard 4444"
},
"transactionIdentifier": "e392617d9e2f7938ca727c6fd063dc7915e42f546bca810ea43f7c2759c52a26",
"decryptedPaymentData": {
"applicationPrimaryAccountNumber": "5555555555554444",
"applicationExpirationDate": "270831",
"currencyCode": "156",
"transactionAmount": 10,
"deviceManufacturerIdentifier": "050110030273",
"paymentDataType": "3DSecure",
"paymentData": {
"onlinePaymentCryptogram": "AORgiMGqVyeCAAt1LKSuAoABFA==",
"eciIndicator": ""
}
}
}
}
```
[`paymentTokenData`](/docs/api-reference/order-create/create-new-order#body-payment-token-data) is a `String` in the create-order API. Serialize the complete object as a JSON string before submitting the request:
```typescript theme={null}
const paymentTokenData = JSON.stringify(decryptedApplePayPayload);
```
When Waffo detects `token.decryptedPaymentData`, it prioritizes the decrypted token and skips platform decryption.
## Field requirements
| Field | Requirement | Description |
| - | - | - |
| `token.decryptedPaymentData.applicationPrimaryAccountNumber` | Required | Device account number (DPAN) |
| `token.decryptedPaymentData.applicationExpirationDate` | Required | Six digits in `YYMMDD` format |
| `token.paymentMethod.network` | Required | Card network copied unchanged from the outer structure of the original token |
| `billingContact` | Recommended | Source of the cardholder name and billing address; pass the complete object when Apple Pay returns it |
| `token.decryptedPaymentData.paymentData.onlinePaymentCryptogram` | Optional | 3DS payment cryptogram; pass it unchanged when present |
| Other fields in `token.decryptedPaymentData` | Optional | Pass fields such as `paymentDataType`, `deviceManufacturerIdentifier`, `eciIndicator`, and `merchantTokenIdentifier` unchanged when present |
> [!NOTE]
>
> Only Apple Pay accepts decrypted token pass-through. Google Pay tokens must be submitted in encrypted form.
# Apple Pay integration
Source: https://waffo.com/docs/en/developer-docs/integration/apple-pay/overview
Compare four Apple Pay integration options and their different requirements for runtime environment, frontend work, and token handling.
This page compares four Apple Pay integration options and their different requirements for runtime environment, frontend work, and token handling. Waffo supports Apple Pay through hosted Checkout and Merchant-built payment experiences.
## Follow the recommended decision order
For most Merchants, start with one of the first three Waffo-hosted Apple Pay options. You can reuse the Apple Pay capabilities in Waffo Checkout instead of applying for an Apple Developer account, managing Apple Pay certificates, or implementing token decryption. Then select the presentation that fits your product:
1. **You can open a standalone payment page**: Redirect to Waffo Checkout. This option works for websites, mobile web pages, and apps, and provides the most direct default integration path.
2. **You need to keep the customer in your Merchant app**: Load Waffo Checkout in an App WebView.
3. **You need to keep the customer on the current web or H5 page**: Use the iframe managed by the Waffo frontend SDK, and complete domain verification and registration in advance.
4. **You must fully control the Apple Pay UI and token**: Integrate directly only when the Merchant has an Apple Developer account, Merchant ID, Apple Pay certificates, and the required PCI DSS compliance status, and your team can manage server-side decryption and ongoing compliance.
> [!TIP]
>
> Unless your product experience or compliance boundary requires the Merchant to process the Apple Pay token, use Waffo-hosted Checkout. Direct integration has the highest cost and requires the Merchant to maintain Apple accounts and certificates, PCI DSS compliance, cryptographic implementation, and long-term operations.
## Compare integration options
Here, terminal means the runtime that hosts Waffo Checkout or the Merchant's Apple Pay UI. App WebView is an app integration: the outer container is the Merchant's native or hybrid iOS app, while a WebView loads Waffo Checkout. An Android app can reuse the WebView Checkout pattern for other payment methods, but Apple Pay does not apply to an Android terminal.
| Option | Applicable scope | Supported terminals | Presentation | Integration cost | Merchant prerequisites |
| - | - | - | - | - | - |
| Redirect to Waffo Checkout | Use Waffo-hosted Checkout with the lowest integration cost; Apple Pay visibility depends on device and browser support | Desktop web, mobile web, or an iOS app that can open an external browser or top-level payment page | Waffo Checkout in the browser's top-level page | Lowest: create the order and implement the page redirect | No additional Apple Pay prerequisites |
| Load Waffo Checkout in an App WebView | Keep the payment flow in the app while Waffo handles the Apple Pay token | A native or hybrid iOS app | Waffo Checkout in an App WebView | Low: add WebView and external-navigation handling | No additional Apple Pay prerequisites |
| Load Waffo Apple Pay Checkout in an iframe | Embed Waffo Checkout in the current web page and support the required SDK integration, domain verification, and iframe adaptation | Desktop web or mobile web | An iframe managed by the Waffo frontend SDK | Medium: integrate the SDK and complete domain verification and iframe adaptation | Use `@waffo/payment-sdk`, contact Waffo technical support for domain verification and registration, and meet the iframe attribute and layout requirements |
| Integrate directly with Apple Pay | Require full control of the Apple Pay UI and token and already have the Apple account setup and required PCI DSS compliance status | A web terminal that supports Apple Pay JS or an iOS app that uses PassKit | A Merchant UI built with Apple Pay JS or PassKit | Highest: build the Apple Pay frontend, manage certificates, verify and decrypt the token on the server, and submit it in Waffo's format | A valid Apple Developer account, Merchant ID, Apple Pay certificates, domain verification for web integrations, and the required PCI DSS compliance status and ongoing controls |
## Option 1: Redirect to Waffo Checkout
Call [`/api/v1/order/create`](/docs/api-reference/order-create/create-new-order), parse [`orderAction`](/docs/api-reference/order-create/create-new-order#response-data) from the response, and open [`orderAction.webUrl`](/docs/api-reference/order-create/create-new-order#response-data) in the browser's top-level page.
Waffo Checkout displays Apple Pay in this option. You don't integrate Apple Pay JS on your page or handle an Apple Pay token. Use the Webhook or the [`/api/v1/order/inquiry`](/docs/api-reference/order-inquiry/order-inquiry) result as the source of truth for payment status.
Choose this option first when you do not need to keep the customer on the current page or inside your app. It has the fewest page-container and frontend dependencies and keeps redirects and payment-result handling consistent.
## Option 2: Load Waffo Checkout in an App WebView
Open [`orderAction.webUrl`](/docs/api-reference/order-create/create-new-order#response-data) in your App WebView. Send [`userTerminal=APP`](/docs/api-reference/order-create/create-new-order#body-user-info) when creating the order so Waffo returns a link suitable for an app.
Make sure the WebView can handle external pages or wallet navigation, and preserve the complete query string when your app passes a URL to or from the WebView.
## Option 3: Load Waffo Apple Pay Checkout in an iframe
> [!WARNING]
>
> An iframe integration must use the Waffo frontend SDK `@waffo/payment-sdk`. Before integration, contact Waffo technical support to complete Apple Pay domain verification and registration. Do not load [`orderAction.webUrl`](/docs/api-reference/order-create/create-new-order#response-data) in a plain `