Merchant Stamp
Merchant stamp
Payout and refund write APIs add a second signature on top of
API Key HMAC. Morph's hosted wallet produces the on-chain
signature. You only stamp a string Morph already prepared — param_json — with
the origin key bound in the merchant Console.
There is no public submit endpoint, and no OTP / 2FA on the OpenAPI path.
sign_mode must be waas.
Before you stamp
- Create an APIKey / APISecret at account.morph.network.
- Bind an origin key in the Console (SDK iframe). OpenAPI does not
accept a
key_id; Morph uses the origin key you selected for the merchant. - Confirm the paying address is a hosted wallet under your account via
GET /payout/v1/wallets.
A missing origin key on authorize returns 90605.
What you sign
Step ② of a payout (Create Payout) or a refund (Prepare Refund) returns:
param_json— a string. The same envelope on EVM, SOL, and TRX, even for a single item:
{"items":[{"operation":"signTransaction","param":"<unsigned payload JSON string>"}]}
param_hash—lowercaseHex(SHA256(utf8(param_json))), 64 hex characters. Convenience only. Do not stampparam_hash.
TRX payouts put 2–3 items in param_json.items. You still stamp the whole
string once. Each TRX param is only { "chain": "trx", "data": "<hex>" }.
Create also returns a sibling raw_txs array (unsigned txs with raw_data).
Echo raw_txs on authorize; do not fold it into param_json.
How to compute merchant_sign
Mint a 13-digit Unix millisecond timestamp, then sign param_json bound to
that value. Send the same digits as waas_timestamp on authorize. Morph writes
them to X-Waas-Timestamp when it calls the hosted wallet. The stamp and the
header must be the same string.
waas_timestamp = "<13-digit unix milliseconds>"
digest = SHA256(utf8(param_json + "|" + waas_timestamp)) // 32 raw bytes
merchant_sign = Base64(Ed25519.Sign(originPriv, digest))
Rules:
- Sign the 32-byte digest, not a hex encoding of it, and not
param_hash. waas_timestampis required, exactly 13 digits. Missing or malformed values return90000. The hosted wallet accepts the header only within ±60 seconds, so mint the timestamp when you authorize. A mismatch returns90604.- On step ③, echo
param_jsonbyte-for-byte. Do not parse andJSON.stringify, pretty-print, reorder keys, or change whitespace. A rewrite returns90604. intent_hashon preview (if present) is a pay-service business hash. Do not stamp it.data.security(security_check/security_request_check/security_double_check/verify_result) is a risk-control echo. You do not have to verify it; integrity is the merchant stamp.
An empty merchant_sign returns 90606.
Go
msg := paramJSON + "|" + waasTimestamp
sum := sha256.Sum256([]byte(msg)) // 32 bytes
sig := ed25519.Sign(originPriv, sum[:])
merchantSign := base64.StdEncoding.EncodeToString(sig)
Node.js
const digest = crypto.createHash("sha256").update(`${paramJson}|${waasTimestamp}`, "utf8").digest();
const sig = nacl.sign.detached(digest, originPriv);
const merchantSign = Buffer.from(sig).toString("base64");
List wallets
GET /pro/merchant/payout/v1/wallets
No query parameters. Use this to confirm tenant_id / tee_mid and the hosted
addresses you may pass as source_wallet.address / source_address. It does
not rotate on-chain keys.
{
"error_code": 0,
"msg": "success",
"data": {
"merchant_id": "mch_...",
"tenant_id": "...",
"tee_mid": "...",
"two_fa_bound": false,
"two_fa_type": "",
"wallets": [
{ "wallet_id": "...", "chain_type": "evm", "address": "0xabc1...def0" }
]
},
"trace": "..."
}