§01 Quickstart
快速开始
四步,二十分钟。全程在沙箱里做,不需要提交任何资质材料。
- 注册并创建应用在控制台填应用名称与回调地址,拿到
app_id与app_secret。密钥只展示一次。 - 发一笔下单请求照 §03 算签名,POST 到
/merchant/orders,同步拿到收银台链接。 - 在沙箱里模拟付款打开收银台链接,点「模拟支付成功」。
- 接住回调并验签服务端收到
payment.succeeded,验签通过后发货,返回success。
curl -X POST https://api-sandbox.nayunfu.cn/api/v1/merchant/orders \
-H 'Content-Type: application/json' \
-d '{
"app_id": "ap_sandbox_8f2c41",
"out_trade_no": "AI202601080001",
"amount": "19.90",
"subject": "AI 绘画 100 次额度",
"channel_code": "sandbox_qr",
"notify_url": "https://your-app.com/napay/notify",
"expires_in": 900,
"timestamp": 1767838471,
"nonce": "9d4c1f7b2a6e",
"sign": "a3f1c9…"
}'
§02 Environment
环境与凭证
两套环境互不相通,密钥也不通用。沙箱密钥前缀 ap_sandbox_,生产密钥前缀 ap_live_,写代码时用前缀判断环境最省事。
| 环境 | 接口基址 | 收银台域名 | 状态 |
|---|---|---|---|
| 沙箱 | https://api-sandbox.nayunfu.cn/api/v1 |
pay-sandbox.nayunfu.cn |
已开放,不涉及真实资金 |
| 生产 | https://api.nayunfu.cn/api/v1 |
pay.nayunfu.cn |
随真实通道开通后可用 |
凭证怎么放
app_id可以出现在请求体里,属于公开标识。app_secret只用于服务端算签名。不要放进前端代码、小程序包、公开仓库。- 轮换密钥时新旧两把并行 24 小时,期间两把都能验签,够你滚动发布。
- 怀疑泄露就在控制台立即吊销,吊销即时生效,已在途的请求会返回
40100。
回调地址:每个应用一个 notify_url,必须是 https 且公网可达。下单时传的 notify_url 优先于应用配置,方便你按环境分流。
§03 Signature
签名规则
请求和回调用同一套规则,方向相反。算法是 HMAC-SHA256,输出小写十六进制。
- 挑字段取报文里全部非空字段,排除
sign本身。null和空字符串跳过。 - 排序拼接按 key 的 ASCII 升序排列,拼成
k=v,用&连接,得到 stringA。 - 计算
sign = HMAC-SHA256(stringA, app_secret),取小写 hex。 - 放回报文把结果写进
sign字段发出。验签时用常量时间比较,别用==。
amount=19.90&app_id=ap_sandbox_8f2c41&channel_code=sandbox_qr&expires_in=900
&nonce=9d4c1f7b2a6e¬ify_url=https://your-app.com/napay/notify
&out_trade_no=AI202601080001&subject=AI 绘画 100 次额度×tamp=1767838471
三个容易踩的点。
金额一律用字符串,两位小数。用浮点数序列化出 19.9 或 19.900000001 都会验签失败。
嵌套对象(如 metadata)取紧凑 JSON 作为值:无空格、key 升序、中文不转义。建议 metadata 只放扁平字符串,绕开序列化差异。
拼接和摘要都按 UTF-8 字节处理。
import crypto from 'node:crypto';
const BASE = 'https://api-sandbox.nayunfu.cn/api/v1';
const APP_ID = 'ap_sandbox_8f2c41';
const SECRET = process.env.NAPAY_APP_SECRET;
// stringA:非空字段按 key 升序拼接,排除 sign
function stringA(p) {
return Object.keys(p)
.filter(k => k !== 'sign' && p[k] !== '' && p[k] != null)
.sort()
.map(k => `${k}=${typeof p[k] === 'object' ? JSON.stringify(p[k]) : p[k]}`)
.join('&');
}
function sign(p) {
return crypto.createHmac('sha256', SECRET).update(stringA(p), 'utf8').digest('hex');
}
const body = {
app_id: APP_ID,
out_trade_no: 'AI202601080001',
amount: '19.90',
subject: 'AI 绘画 100 次额度',
channel_code: 'sandbox_qr',
notify_url: 'https://your-app.com/napay/notify',
expires_in: 900,
timestamp: Math.floor(Date.now() / 1000),
nonce: crypto.randomBytes(6).toString('hex')
};
body.sign = sign(body);
const res = await fetch(`${BASE}/merchant/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const { data } = await res.json();
console.log(data.trade_no, data.checkout_url);
import hmac, hashlib, json, time
def string_a(payload: dict) -> str:
items = []
for k in sorted(payload):
if k == "sign":
continue
v = payload[k]
if v is None or v == "":
continue
if isinstance(v, (dict, list)):
v = json.dumps(v, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
items.append(f"{k}={v}")
return "&".join(items)
def verify(payload: dict, secret: str) -> bool:
if abs(time.time() - int(payload["timestamp"])) > 300:
return False # 时间戳超出允许偏差
expect = hmac.new(secret.encode(), string_a(payload).encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expect, payload.get("sign", ""))
§04 Create order
创建订单
POST /merchant/orders 同步返回收银台链接与平台订单号。支付结果一律以回调为准,不要拿这一步的返回当成已付款。
请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| app_id | string | 必填 | 应用标识,控制台可见。 |
| out_trade_no | string | 必填 | 你的订单号,同一应用内唯一。1–64 位,允许字母、数字、-、_。 |
| amount | string | 必填 | 单位元,两位小数。范围 0.01–50000.00。 |
| subject | string | 必填 | 商品或服务名称,最长 64 字。展示在收银台和账单上。 |
| channel_code | string | 必填 | 通道标识。沙箱用 sandbox_qr;真实通道开通后取值见控制台通道台账。 |
| notify_url | string | 必填 | 接收回调的 https 地址,只接 POST。 |
| timestamp | int | 必填 | 秒级 Unix 时间戳,与平台时间偏差需在 ±300 秒内。 |
| nonce | string | 必填 | 随机串,8–32 位。5 分钟内不可重复。 |
| sign | string | 必填 | 按 §03 算出的签名。 |
| currency | string | 可选 | 目前只支持 CNY,缺省即 CNY。 |
| expires_in | int | 可选 | 有效期秒数,默认 900,范围 300–7200。到期自动关单。 |
| return_url | string | 可选 | 付款完成后浏览器跳转地址。仅用于体验,不能当支付凭据。 |
| description | string | 可选 | 订单备注,最长 128 字,只在你的账单里可见。 |
| metadata | object | 可选 | 最多 20 个键,键值均为字符串。原样在回调里回传。 |
响应
{
"code": 0,
"message": "ok",
"request_id": "req_01HQ8F3M7X", // 提工单时带上这个
"data": {
"trade_no": "NP-2601-0001", // 平台订单号
"out_trade_no": "AI202601080001",
"status": "created",
"amount": "19.90",
"checkout_url": "https://pay-sandbox.nayunfu.cn/c/NP-2601-0001",
"expires_at": "2026-01-08T10:39:31+08:00"
}
}
幂等:24 小时内用同一个 out_trade_no 且金额一致重复下单,返回原订单,不会产生第二笔。金额不一致返回 40009。所以下单接口可以安全重试。
§05 Query & close
查询与关单
GET 接口把签名字段放在 query 上,规则与请求体一致。查询是你的兜底手段:回调迟迟不到时,按业务需要主动查一次,不要让用户干等。
| 动作 | 接口 | 说明 |
|---|---|---|
| GET | /merchant/orders/{trade_no} | 按平台订单号查。也可用 /merchant/orders?out_trade_no=… 按你的订单号查。 |
| POST | /merchant/orders/{trade_no}/close | 主动关单。只有 created 状态可关,已付款的订单关单返回 40900。 |
| POST | /merchant/notifications/{trade_no}/retry | 手动重推回调,用于修好接口后补通知。控制台也有同样的按钮。 |
轮询节奏:建议 5 秒起、指数退避到 30 秒,最长跟到订单 expires_at。已经收到回调就停止轮询。
§06 Webhook
回调通知
付款成功后我们 POST 一份带签名的 JSON 到你的 notify_url。顺序很重要:先验签,再改订单状态,最后回 success。
{
"app_id": "ap_sandbox_8f2c41",
"event": "payment.succeeded",
"trade_no": "NP-2601-0001",
"out_trade_no": "AI202601080001",
"channel_code": "sandbox_qr",
"amount": "19.90",
"fee": "0.32", // 19.90 × 1.6%,四舍五入到分
"settle_amount": "19.58", // 商户实收
"paid_at": "2026-01-08T10:24:31+08:00",
"timestamp": 1767838471,
"nonce": "9d4c1f7b2a6e",
"sign": "a3f1c9…"
}
你要做的四件事
- 验签失败就返回 HTTP 400,不要处理业务,也不要泄露失败原因给调用方。
- 核对
out_trade_no与amount是否与你本地订单一致,金额不符按异常处理并提工单。 - 按
trade_no做幂等:同一笔重复到达时直接返回success,不要重复发货。 - 5 秒内返回,响应体是纯文本
success(或 JSON{"code":0})。耗时的发货动作丢进队列。
重推节奏
| 次数 | 距付款成功 | 备注 |
|---|---|---|
| 第 1 次 | 立即 | 正常情况这一次就成功 |
| 第 2 次 | +15 秒 | 前一次未在 5 秒内返回 success 时继续 |
| 第 3 次 | +1 分钟 | |
| 第 4 次 | +5 分钟 | |
| 第 5 次 | +15 分钟 | |
| 第 6 次 | +1 小时 | |
| 第 7 次 | +3 小时 | 之后停止自动重推,可在控制台手动重推 |
§07 State
订单状态
正向链路四个状态,每一次跃迁都有对应报文;另有一个终止态 closed,表示这笔订单不会再收到钱。
| 状态 | 含义 | 对应事件 |
|---|---|---|
| created | 已下单,拿到收银台链接,等待用户付款。 | — |
| paid | 通道确认收款,平台完成金额与订单号校验。 | payment.succeeded |
| notified | 回调已被你的服务端确认收到。 | 回调返回 success 后置位 |
| reconciled | 已进入次日账单,逐笔可核,费率单列。 | 账单可下载 |
| closed | 超时或主动关单,终止态。 | order.closed |
退款暂未提供接口,随真实通道一起开放。沙箱阶段请按「不可退」设计你的业务流程,需要试退款流程可以提工单沟通。
§08 Errors
错误码
HTTP 状态码表示请求层面的结果,业务结果看响应体的 code。message 是给人读的,不要用来做分支判断。
| code | 含义 | 怎么处理 |
|---|---|---|
| 40001 | 参数校验失败 | 看 data.fields 里指出的字段,修好再发。不要重试。 |
| 40009 | out_trade_no 重复但金额不一致 | 换一个订单号,或先关掉原订单。 |
| 40100 | 签名验证失败 | 对照 §03 逐字段核 stringA,重点查金额格式与嵌套序列化。 |
| 40101 | 时间戳超出 ±300 秒 | 校准服务器时间,用 NTP。 |
| 40102 | nonce 重复 | 每次请求都生成新的随机串。 |
| 40300 | 应用未开通该通道 | 查通道台账当前状态,沙箱期请用 sandbox_qr。 |
| 40400 | 订单不存在 | 核对订单号与环境,沙箱订单在生产环境查不到。 |
| 40900 | 订单状态不允许该操作 | 先查询当前状态再决定下一步。 |
| 42900 | 请求频率超限 | 按响应头 Retry-After 退避后重试。 |
| 50000 | 平台内部错误 | 可重试。带 request_id 提工单,2 小时内响应。 |
| 50200 | 通道暂时不可用 | 可重试或换通道下单,订单不会重复扣款。 |
§09 Limits
约定与限制
| 项目 | 取值 | 说明 |
|---|---|---|
| 金额格式 | 字符串,两位小数 | 0.01–50000.00,禁止浮点数。 |
| 时间格式 | ISO 8601 带时区 | 如 2026-01-08T10:24:31+08:00。 |
| 下单频率 | 沙箱 10 次 / 秒 | 生产按商户核定,控制台可见当前额度。 |
| 幂等窗口 | 24 小时 | 按 out_trade_no 计。 |
| 签名有效期 | ±300 秒 | nonce 5 分钟内不可重复。 |
| 回调超时 | 连接 2 秒 / 总 5 秒 | 超时按失败进入重推队列。 |
| notify_url | https,443 端口 | 不支持自签证书,不支持 302 跳转。 |
| 接口调用费 | ¥0 | 下单、查询、回调重推都不计费。 |
本页描述 v1 接口。字段只增不删,新增字段一律可选,破坏性变更会另起版本号并提前书面通知。
§10 For your assistant
粘给编码助手
下面这段是整份手册的压缩版,字段和规则都在里面。复制给编码助手,让它直接生成对接代码,比让它去猜要准。
NAPAY 钠云付 v1 支付接口摘要(用于生成对接代码)
BASE_SANDBOX = https://api-sandbox.nayunfu.cn/api/v1
BASE_LIVE = https://api.nayunfu.cn/api/v1
AUTH = 报文内签名,无 Bearer token
CREDENTIALS = app_id(可公开) + app_secret(仅服务端)
SIGN
1. 取报文全部非空字段,排除 sign
2. key 按 ASCII 升序,拼 "k=v",以 & 连接 -> stringA(值不做 URL 编码)
3. 嵌套对象取紧凑 JSON:无空格、key 升序、中文不转义
4. sign = hex_lower(HMAC_SHA256(stringA, app_secret))
5. 验签用常量时间比较;timestamp 容差 ±300s;nonce 5 分钟去重
CREATE ORDER POST /merchant/orders
必填 app_id, out_trade_no, amount(string,2dp), subject, channel_code,
notify_url(https), timestamp(sec), nonce, sign
可选 currency(CNY), expires_in(300-7200,默认900), return_url, description,
metadata(object,<=20 键,值为字符串)
返回 code=0, request_id, data{trade_no, status=created, checkout_url, expires_at}
幂等 同 out_trade_no + 同金额 24h 内返回原订单;金额不同报 40009
QUERY GET /merchant/orders/{trade_no} 或 ?out_trade_no=xxx(签名字段放 query)
CLOSE POST /merchant/orders/{trade_no}/close(仅 created 可关)
RETRY POST /merchant/notifications/{trade_no}/retry
WEBHOOK POST notify_url,JSON,字段:
app_id, event, trade_no, out_trade_no, channel_code, amount, fee,
settle_amount, paid_at, timestamp, nonce, sign
event: payment.succeeded | order.closed
处理顺序:验签 -> 核对 out_trade_no 与 amount -> 按 trade_no 幂等 -> 发货
响应:5 秒内返回纯文本 success;失败重推 7 次(0s,15s,1m,5m,15m,1h,3h)
STATE created -> paid -> notified -> reconciled;终止态 closed
FEE 交易费率 1.6%,settle_amount = amount - fee;接口调用不计费
ERRORS 40001 参数 / 40009 订单号重复 / 40100 验签失败 / 40101 时间戳 /
40102 nonce 重复 / 40300 通道未开通 / 40400 订单不存在 /
40900 状态不允许 / 42900 限流 / 50000 内部错误(可重试) /
50200 通道不可用(可重试)
约定 金额一律字符串两位小数,禁止 float;支付结果只认回调,不认前端跳转;
沙箱 channel_code = sandbox_qr,不涉及真实资金。