- date
- entry
- 009
- topic
- integration
- rev
- —
A payment callback is a write endpoint, not a notification
The POST your payment provider sends carries no identity, can be forged by anyone, and will arrive more than once. It has to do three things — recompute the signature, read the status code correctly, settle idempotently. Skip any one and what breaks involves real money.
Start with what this endpoint actually is. Integrating ECPay's AIO gateway, you supply a
ReturnURL; after payment their server sends a server-to-server POST to it as
application/x-www-form-urlencoded. It reads like a report. It is really:
- public — an external host must be able to reach it, so it cannot sit behind a login or a private network
- unauthenticated — no API key, no token, no session
- sendable by anyone — know the URL and you can POST your own "payment complete"
- repeated — nothing guarantees a given transaction's callback arrives only once
- a mutation of a money field — what it triggers is "mark this order paid"
Put those five together and it cannot be written as a notification handler. It is an unauthenticated, publicly reachable, replayable write endpoint.
One: verify the signature
Identity comes from the signature. Every ECPay request and callback carries a
CheckMacValue; you recompute it with your own HashKey and HashIV and reject anything
that does not match. The algorithm (AIO V5, SHA256):
- Take every parameter except
CheckMacValueand sort by parameter name lowercased - Join as
HashKey=KEY&a=1&b=2...&HashIV=IV— HashKey first, HashIV last - URL-encode the whole string, then lowercase it
- Undo the .NET-style encoding (below)
- SHA256 → hex → uppercase
Step 4 is where everyone loses time. ECPay's encoding comes from .NET, so these need reverting:
%2d → - %5f → _ %2e → . %21 → !
%2a → * %28 → ( %29 → ) %20 → +
In Go it comes out roughly as:
func CheckMacValue(params map[string]string, hashKey, hashIV string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k != "CheckMacValue" { keys = append(keys, k) }
}
sort.Slice(keys, func(i, j int) bool {
return strings.ToLower(keys[i]) < strings.ToLower(keys[j])
})
var b strings.Builder
b.WriteString("HashKey=" + hashKey)
for _, k := range keys { b.WriteString("&" + k + "=" + params[k]) }
b.WriteString("&HashIV=" + hashIV)
enc := strings.ToLower(url.QueryEscape(b.String()))
enc = strings.NewReplacer(
"%2d", "-", "%5f", "_", "%2e", ".", "%21", "!",
"%2a", "*", "%28", "(", "%29", ")", "%20", "+",
).Replace(enc)
sum := sha256.Sum256([]byte(enc))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
When you get CheckMacValue Error (10200073), it is nearly always one of three things: a
character missing from the replacement table, sorting that did not lowercase first, or an unhandled
special character in a value. All three give you the same error code and no further hint.
Develop against ECPay's published test merchant — these are in the public docs, not secrets:
MerchantID 3002607, HashKey pwFHCqoQZGmho4w6, HashIV
EkRm7iFT261dpevs, test card 4311-9522-2222-2222 with CVC 222. The
production set is a credential and belongs in an
encrypted parameter — not .env, not a commit, not a chat window.
Two: the status code is not just success or failure
Once the signature checks out, the next decision is RtnCode. The instinct is
if RtnCode == "1" { settle }. That is right for credit cards and wrong for ATM transfers
and convenience-store payments.
Asynchronous methods produce two callbacks: the first when a virtual account number
or store code is issued, the second when the customer actually pays. The issuing callback does not
carry RtnCode 1 — accept only 1 and you treat issuance as failure; treat every callback
as settlement and you ship goods before anyone has paid.
So the order needs a real state machine, at minimum three states:
created → pending_payment → paid
↘ expired / failed
The issuing callback moves the order to pending_payment and records the payment
deadline; only the settlement callback moves it to paid. None of this surfaces while you
are testing with a credit card. It surfaces with the first customer who pays at a convenience store.
Three: idempotency, because it arrives twice
Retries happen for many reasons: a network timeout, your service restarting at the wrong moment, or a malformed response from you (next section). The provider's design assumes "if I did not get a clear success, send it again", which makes duplicate delivery normal behaviour, not an anomaly.
Use MerchantTradeNo as the idempotency key: an order already in paid that
receives a second settlement callback responds success and does nothing else — no second ledger
entry, no second shipping notification, no second stock decrement.
-- order table, roughly
orders(id, quote_id, merchant_trade_no UNIQUE, amount, status, paid_at)
That UNIQUE on merchant_trade_no is the last line of defence. It also
handles a related rule: ECPay requires that number to be 20 characters or fewer, alphanumeric, and
never reused — a retried order needs a fresh one, or you get 10200052.
Get the response body wrong and it keeps calling
When you are done, the response must be the plain text 1|OK. Not JSON, not an empty 200,
not a page of HTML.
Get it wrong and ECPay concludes the notification failed, so it resends — even though your handler already succeeded. The transaction gets delivered repeatedly, weak idempotency turns that into double settlement, and the merchant console shows the notification as failed.
This is another instance of EMQX reading an empty 200 as a denial: what the other side wants is the content of the body, not the status code. Returning 200 is not the same as answering.
The amount never comes from the client
A separate but equally important point: TotalAmount is always recomputed server-side
from the quote or cart. Never accept a number the frontend sent. The only thing the frontend gets to
name is which quote.
A few spec details worth knowing: TotalAmount must be a whole number of TWD and a
decimal gets rejected outright; MerchantTradeDate is
yyyy/MM/dd HH:mm:ss; EncryptType is fixed at 1. And creating
an order is not an API call — you emit a self-submitting HTML form that POSTs to the checkout
endpoint.
Testing locally
ECPay cannot reach localhost, so a development ReturnURL needs a public address —
ngrok http 8080 or any similar tunnel is enough.
Also, test-environment ATM accounts never actually receive money; the second callback has to be triggered from the merchant console's simulate-payment function. Without it, it is easy to believe your settlement path works when you have only ever exercised the issuing half.
One thing worth adding
Store the raw payload of every callback, verbatim, along with arrival time and the signature verification result. No provider asks for this. It exists so you can answer questions later.
Payment disputes have a predictable shape: did this money arrive, when, and against which order? Keep only the processed order state and you can answer half of that. Keep the raw payload and you can replay exactly what you received, including fields you were not parsing at the time. It is the same consideration as whether the system can still answer questions about itself.
If you remember one thing
Design the callback endpoint as "a repeatable write request from a stranger", not as a piece of mail. Signature verification supplies the identity, the state machine handles the asynchrony, the idempotency key handles the replay — one for each property that will otherwise bite you, and each missing one is a missing line of defence.
Revision history
- First published