支付异步通知(CP 回调)
Novacoreall 负责在订单支付成功或退款完成后,向游戏 CP 方配置的 回调地址 发起 HTTP POST 异步通知。本文说明 CP 方需要实现的回调接口格式,并提供 PHP 与 Go 验签示例。
说明:Nova 支付体系采用独立的 JSON + HMAC-SHA256 协议,由本服务统一投递。
整体流程
从「创建订单」到「客户端收到付款结果」,典型链路如下(以应用内调起 Google 等商店支付为例)。
图示为 产品视角;其中向游戏服务器下发的 异步 HTTP 回调即本文描述的 CP 回调。退款等场景在服务侧确认后同样会走异步通知与重试,由平台统一投递。
- 创建订单:游戏客户端通过 SDK 向 SDK 服务端发起支付/下单,服务端创建订单并将 平台订单号返回客户端(并与 CP 侧的
reference_id等对账字段关联)。 - 商店支付:客户端向 Google 等支付平台调起收银台,用户完成付款后客户端拿到平台返回的 支付 token(或等价票据)。
- 验单并完成支付:客户端将 token 提交给 SDK 服务端;服务端向商店服务端 校验 token,通过后在本平台 完成支付履约(落单状态、发放权益等)。
- 异步通知 CP:SDK 服务端在支付成功确认后(以及退款等在服务侧结案后),向游戏 CP 配置的
notify_url异步 POST(本文后续字段与签名约定);此为 服务端到服务端,不经过玩家设备。 - CP 处理与回包:游戏服务器 验签、发货或更新订单,并应答 HTTP 2xx;非 2xx 将触发投递方重试(具体策略以服务实现为准)。
- 客户端结束流程:游戏服务器在完成业务后以 服务端推送、长连接下发或客户端轮询订单等方式,将支付结果告知 游戏客户端,玩家侧流程结束。
与图示对应:玩家在 NovaSDK 完成验单与订单状态确认后,由平台异步向 CP 配置的 notify_url 投递支付/退款结果;图示中的「SDK 服务端」即面向客户端与 CP 的统一接入与结算层。
有效回调地址优先级:订单级 notify_url > 应用后台配置的 notify_url。
请求说明
基本信息
| 项目 | 值 |
|---|---|
| 方法 | POST |
Content-Type | application/json |
| 超时 | 服务端请求超时约 3 秒,请 CP 接口尽快返回 |
| 触发场景 | 订单 status = 1(支付成功)或 status = 4(退款) |
请求头
| 请求头 | 说明 |
|---|---|
NOVA-X-Callback-App-Id | 应用 ID,与 Body 中 app_id 一致 |
NOVA-X-Callback-Timestamp | UTC 毫秒时间戳,与 Body 中 timestamp 一致 |
NOVA-X-Callback-Sign | 签名值(小写十六进制) |
NOVA-X-Callback-Sign-Method | 固定为 hmac-sha256 |
请求体(JSON)
| 参数名 | 类型 | 说明 |
|---|---|---|
order_id | string | Nova 订单号 |
app_id | number | 应用 ID |
uid | number | 玩家 UID |
reference_id | string | CP 订单参考号(对应创建订单时的 reference) |
extension | string | 扩展字段 |
timestamp | number | UTC 毫秒时间戳 |
status | number | 订单状态:1 支付成功,4 已退款(与平台订单表一致) |
payment_platform | string | 支付渠道,如 google、apple |
goods_id | number | 商品 ID,对应创建订单时传入的 goods_id |
示例 Body:
{
"order_id": "20250718112706471433",
"app_id": 10001,
"uid": 1003,
"reference_id": "8f8bfa08-6471-ab96-8107-252407b67c80",
"extension": "8f8bfa08-6471-ab96-8107-252407b67c80",
"timestamp": 1753174571860,
"status": 1,
"payment_platform": "google",
"goods_id": 1001
}签名验证
签名密钥为应用后台配置的 app_secret。
算法步骤
- 取参与签名的参数(不含
sign字段本身):app_id、extension、goods_id、order_id、payment_platform、reference_id、status、timestamp、uid。 - 将各参数转为字符串后,按参数名 字典序(升序) 排列。
- 拼接为
key1=value1&key2=value2&...(无 URL 编码)。 - 对上述字符串做 HMAC-SHA256,密钥为
app_secret,输出 小写十六进制 字符串。 - 与请求头
NOVA-X-Callback-Sign比较(建议大小写不敏感比较)。
参与签名的 timestamp、app_id、uid 必须与 Body JSON 中的值一致(均为十进制字符串形式参与拼接)。
签名原文示例
参数:
app_id=10001
extension=8f8bfa08-6471-ab96-8107-252407b67c80
goods_id=1001
order_id=20250718112706471433
payment_platform=google
reference_id=8f8bfa08-6471-ab96-8107-252407b67c80
status=1
timestamp=1753174571860
uid=1003拼接串:
app_id=10001&extension=8f8bfa08-6471-ab96-8107-252407b67c80&goods_id=1001&order_id=20250718112706471433&payment_platform=google&reference_id=8f8bfa08-6471-ab96-8107-252407b67c80&status=1×tamp=1753174571860&uid=1003响应要求
| 条件 | 说明 |
|---|---|
| 成功 | HTTP 状态码 200–299 |
| 失败 | 非 2xx 状态码,或请求超时、网络错误 |
响应 Body 无固定格式要求;服务以 HTTP 状态码 判断是否成功(HTTP 2xx 视为接收成功)。
重试策略
| 次数 | 间隔(相对上次失败) |
|---|---|
| 第 1 次 | 立即 |
| 第 2 次 | 15 秒 |
| 第 3 次 | 1 分钟 |
最多重试 3 次。仍失败则订单 notify_status 标记为失败,可在后台排查或人工补发。可在控制台结合订单维度排查通知失败原因;本地联调可使用下文提供的 HTTP 接收示例与 mock 服务。
PHP 接收示例
<?php
declare(strict_types=1);
/**
* Nova 支付异步通知接收示例
* 配置:应用后台 app_secret
*/
$appSecret = 'your_app_secret_here';
$rawBody = file_get_contents('php://input') ?: '';
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => 'invalid json']);
exit;
}
$headerAppId = $_SERVER['HTTP_NOVA_X_CALLBACK_APP_ID'] ?? '';
$headerTimestamp = $_SERVER['HTTP_NOVA_X_CALLBACK_TIMESTAMP'] ?? '';
$headerSign = $_SERVER['HTTP_NOVA_X_CALLBACK_SIGN'] ?? '';
$signMethod = $_SERVER['HTTP_NOVA_X_CALLBACK_SIGN_METHOD'] ?? '';
if (strcasecmp($signMethod, 'hmac-sha256') !== 0) {
http_response_code(403);
echo json_encode(['ok' => false, 'message' => 'unsupported sign method']);
exit;
}
// 参与签名的字段须与 Body 一致,并转为字符串
$signParams = [
'app_id' => (string) ($payload['app_id'] ?? ''),
'extension' => (string) ($payload['extension'] ?? ''),
'goods_id' => (string) ($payload['goods_id'] ?? ''),
'order_id' => (string) ($payload['order_id'] ?? ''),
'payment_platform' => (string) ($payload['payment_platform'] ?? ''),
'reference_id' => (string) ($payload['reference_id'] ?? ''),
'status' => (string) ($payload['status'] ?? ''),
'timestamp' => (string) ($payload['timestamp'] ?? ''),
'uid' => (string) ($payload['uid'] ?? ''),
];
ksort($signParams);
$parts = [];
foreach ($signParams as $key => $value) {
$parts[] = "{$key}={$value}";
}
$signPlain = implode('&', $parts);
$expectedSign = hash_hmac('sha256', $signPlain, $appSecret);
if (!hash_equals(strtolower($expectedSign), strtolower($headerSign))) {
http_response_code(403);
echo json_encode(['ok' => false, 'message' => 'invalid sign']);
exit;
}
// 可选:校验 Header 与 Body 一致
if ($headerAppId !== (string) $signParams['app_id']
|| $headerTimestamp !== $signParams['timestamp']) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => 'header mismatch']);
exit;
}
$orderId = $signParams['order_id'];
$referenceId = $signParams['reference_id'];
// TODO: 幂等发货 — 根据 order_id / reference_id 查本地订单,避免重复发货
// if (alreadyDelivered($orderId)) { ... }
http_response_code(200);
echo json_encode(['ok' => true, 'order_id' => $orderId]);Go 接收示例
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
)
type callbackPayload struct {
OrderID string `json:"order_id"`
AppID any `json:"app_id"`
UID any `json:"uid"`
ReferenceID string `json:"reference_id"`
Extension string `json:"extension"`
Timestamp any `json:"timestamp"`
Status any `json:"status"`
PaymentPlatform string `json:"payment_platform"`
GoodsID any `json:"goods_id"`
}
func main() {
const appSecret = "your_app_secret_here"
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
defer r.Body.Close()
var payload callbackPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, `{"ok":false,"message":"invalid json"}`, http.StatusBadRequest)
return
}
if strings.ToLower(r.Header.Get("NOVA-X-Callback-Sign-Method")) != "hmac-sha256" {
http.Error(w, `{"ok":false,"message":"unsupported sign method"}`, http.StatusForbidden)
return
}
expected := buildNotifySignature(map[string]string{
"app_id": stringify(payload.AppID),
"extension": payload.Extension,
"goods_id": stringify(payload.GoodsID),
"order_id": payload.OrderID,
"payment_platform": payload.PaymentPlatform,
"reference_id": payload.ReferenceID,
"status": stringify(payload.Status),
"timestamp": stringify(payload.Timestamp),
"uid": stringify(payload.UID),
}, appSecret)
received := r.Header.Get("NOVA-X-Callback-Sign")
if !strings.EqualFold(expected, received) {
http.Error(w, `{"ok":false,"message":"invalid sign"}`, http.StatusForbidden)
return
}
if r.Header.Get("NOVA-X-Callback-App-Id") != stringify(payload.AppID) ||
r.Header.Get("NOVA-X-Callback-Timestamp") != stringify(payload.Timestamp) {
http.Error(w, `{"ok":false,"message":"header mismatch"}`, http.StatusBadRequest)
return
}
// TODO: 幂等发货
_ = payload.OrderID
_ = payload.ReferenceID
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
_ = http.ListenAndServe(":8090", nil)
}
func buildNotifySignature(params map[string]string, secret string) string {
keys := make([]string, 0, len(params))
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", key, params[key]))
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(strings.Join(parts, "&")))
return fmt.Sprintf("%x", mac.Sum(nil))
}
func stringify(value any) string {
switch v := value.(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case json.Number:
return v.String()
default:
return fmt.Sprintf("%v", v)
}
}下列命令在本地启动一个简易 HTTP 接收端,可与上文 PHP / Go 验签逻辑配合联调:
go run ./cmd/mock-callback-receiver -addr :8090 -app-secret your_app_secret_here常见问题
1. 如何区分支付成功与退款?
根据 Body 中的 status 判断:1 为支付成功,4 为已退款。也可结合 order_id 查询平台订单做二次校验。
2. 回调一直失败怎么办?
检查:notify_url 是否公网可达、是否返回 2xx、app_secret 是否与后台一致、防火墙是否放行。查看表 nova_app_order_notify 的历史请求记录辅助排查。
3. 幂等性要求
同一 order_id 可能因重试多次回调,CP 必须以 order_id(或 reference_id)做幂等,避免重复发货。