The three IDs, and which one to use where
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, and the lookup table is in API Reference Tables. Top levelmerchantInfo (the object itself is mandatory)
userInfo (the object itself is mandatory)
paymentInfo (the object itself is mandatory)
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
cardInfo, riskData, addressInfo, brandInfo — all optional objects
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 asA0003 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:
extendInfois stored verbatim and echoed on/api/v1/order/inquiryand 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.
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:
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. A0040is 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, 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 withA0003 — 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 and
Order Amounts, Limits & Declines.
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.
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.
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, throughpaymentInfo.payMethodProperties, which is a JSON string. Two keys
are involved:
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.
Can I decide whether a payment goes through 3DS?
You can express a decision, on card-like payments, withcardInfo.threeDsDecision. It takes exactly two values:
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.
How do I charge a stored card with no customer present?
SendpaymentInfo.merchantInitiatedMode, which takes two values:
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.
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:
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.
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:
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 allowgoodsInfo.goodsUrl— maximum length 128, must be a full URL (expected for compliance and risk review)userPhone— must follow+CountryCode-NumberorderRequestedAt— must be ISO 8601 with milliseconds and aZsuffix
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.
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?.
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 newpaymentRequestId. 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:
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.