发卡宝 API v1 只有两个接口:幂等核销(consume)与对账查询(query)。核销为不可自动回退的资产消耗,跨系统一致性靠「幂等单号 + 可查询 + 接入方本地订单」保证,可彻底解决“卡已烧、权益未到账”的掉单问题。
apikey 或 method=check/setUsed 的请求一律返回 410 GONE,未升级的集成请尽快切换到本页协议。| Header | 说明 |
|---|---|
X-API-Key | 商户 API Key(身份标识,重置后同步变化) |
X-Timestamp | Unix 秒级时间戳,与服务端偏差 ≤ ±300 秒;只取一次,签名与发送必须同值 |
X-Sign | 按下述规则计算的签名(小写 hex),服务端常量时间比较 |
POST: Sign = HMAC_SHA256( Secret, Timestamp + "." + 请求体原文(raw body) )
GET : Sign = HMAC_SHA256( Secret, Timestamp + "." + URL查询串原文(QUERY_STRING) )
要点:
1. POST 参与签名的是"实际发出的字节"——先 json_encode 得到 $body,签名用 $body,发送也用 $body,中间不得重排/格式化;
2. GET 的查询串自行拼接(如 http_build_query 的结果),签什么就发什么,避免 curl/代理二次编码改变顺序;
3. 签名失败返回 401 INVALID_SIGN,可用示例 Demo 对照排查。
| 规则 | 说明 |
|---|---|
clientTxnId 全局唯一 | 由接入方生成,并先写入你自己的订单表再发请求;建议格式 商户前缀_时间_随机(勿嵌卡号,单号会进日志) |
| 重试必须复用同一个 clientTxnId | 超时/断网属于“结果未知”:拿原单号重发 consume(命中重放语义)或调 query 确认。禁止换新单号重试同一张卡,否则失去幂等保护 |
| 重放返回同一结果 | 同单号重复请求返回 replay:true 且 pid 与首次一致,不会二次扣卡;同单号换卡号请求被拒(INVALID_PARAM) |
| 一卡一销 | 平台唯一台账;卡被别的单烧掉后,你的新单收到 ALREADY_USED |
| 接入方本地订单三态 | 推荐 pending / success / failed:发放权益与订单翻 success 在同一个本地事务内原子完成(只允许翻转一次);“结果未知”保持 pending,由你的定时任务 query 收敛 |
| 补发唯一依据 | query 返回 usedTxn == 自己的单号 才可补发;不等 = 卡经其他渠道消耗,严禁发放,转人工 |
| err | HTTP | 含义 | 接入方动作 |
|---|---|---|---|
NOT_FOUND | 404 | 卡密不存在 | 终态失败,提示换卡 |
ALREADY_USED | 409 | 卡已被核销(含被别的单烧掉) | 终态失败,提示换卡 |
REFUNDED | 409 | 该核销单已被客服退款,拒绝重放 | 终态失败 |
INVALID_PARAM | 400 | 参数缺失/非法,或单号与卡号不符 | 修正后重试(未烧卡,安全) |
INVALID_AUTH / INVALID_KEY / INVALID_SIGN / INVALID_TIMESTAMP | 401 | 鉴权头缺失 / Key不存在 / 签名错 / 时间偏差>5min | 检查 Key、Secret、服务器时钟;校时后用原单号重试 |
ACCOUNT_DISABLED / API_DISABLED / IP_DENIED | 403 | 账号或 API 被禁用 / IP 不在白名单 | 联系管理员 |
RATE_LIMITED | 429 | 触发限流 | 退避后原单号重试 |
BUSY / 5xx / 超时 / 非JSON响应 | 500 | 结果未知(不是失败!) | 订单保持 pending,返回用户“处理中”;稍后 query 对账收敛,不得判死 |
GONE | 410 | 调用了已下线的老协议 | 升级到 v1 |
行锁原子操作:校验 + 登记台账 + 标记已用一次完成,是整条链路唯一需要同步等待的调用(建议连接 3s / 总 6s 超时)。幂等:同 clientTxnId 重放返回相同结果。
| 参数 | 必填 | 说明 |
|---|---|---|
method | 是* | 固定 "consume"(*独立入口 /api/v1/consume.php 无需 method 字段) |
cardNo | 是 | 卡号(本平台 17 位:ABCDE-FGHIJ-KLMNO) |
clientTxnId | 是 | 接入方订单号,字符集 A-Za-z0-9_.:-,长度 1–64,全局唯一,重试复用 |
POST /api/index.php
Content-Type: application/json
X-API-Key: 9ffa60a9c4aa4eb512312d680e2974dc
X-Timestamp: 1789502400
X-Sign: hmac_sha256(secret, "1789502400." + 请求体原文)
{"method":"consume","cardNo":"BQ7WK-T9U4W-NGRTJ","clientTxnId":"m51_20260916_3f9a27de01c8b4f5"}
| 响应 | 示例 / 处理 |
|---|---|
| 首次核销成功 | {"code":1,"data":{"pid":38,"replay":false}} |
| 同单号重放 | {"code":1,"data":{"pid":38,"replay":true}}(pid 与首次一致) |
pid 为发卡宝商品ID(卡种),到账换算由接入方映射(例:38=30天、39=90天、40=永久)。收到映射外的未知 pid:卡已核销,绝不可判死,转人工核对发放。查询核销台账,供超时收敛、定时对账、人工补单前核对。先按 clientTxnId 查,未命中自动回落按 cardNo 查(并可追溯卡表兜底),单参数也可查,两个都带最稳。
| 参数(querystring) | 必填 | 说明 |
|---|---|---|
clientTxnId | 二选一 | 核销时携带的接入方订单号 |
cardNo | 二选一 | 卡号 |
GET /api/index.php?clientTxnId=m51_20260916_3f9a27de01c8b4f5
(签名原文 = Timestamp + "." + "clientTxnId=m51_20260916_3f9a27de01c8b4f5")
已核销:{"code":1,"data":{"used":true, "pid":38,"usedTxn":"m51_20260916_3f9a27de01c8b4f5","usedAt":"2026-09-16 03:21:56"}}
未核销:{"code":1,"data":{"used":false,"pid":0, "usedTxn":"","usedAt":""}}
used==true && usedTxn==本单 → 允许执行本地发放(只发一次);usedTxn≠本单 → 他渠道消耗,禁止发放转人工;used==false 且订单已超时(如>30分钟)→ 可安全置失败,允许用户复用原单号重新提交。平台不提供自动回滚(卡状态单调向前)。“本地没办成”的兜底不是退卡,而是本地重试发放。
1 本地落单 pending(生成 clientTxnId,落库成功后才发请求)
2 POST consume(唯一同步阻塞,1 次 HTTP)
├─ code=1 → 3
├─ NOT_FOUND / ALREADY_USED / REFUNDED → 订单 failed,提示换卡
└─ 超时 / 429 / BUSY / 5xx / 鉴权错 → 保持 pending,返回"处理中"
3 本地事务:按 pid 发放权益 + 订单翻 success(同一事务,原子只翻转一次)
4 定时任务(每分钟):扫 pending 超时单 → GET query
├─ used=false 超30分钟 → failed(用户可复用原单号重提)
├─ used=true usedTxn==本单 → 补执行第3步(自愈)
└─ used=true usedTxn!=本单 → 转人工,禁止发放
5 客服通道:误核销人工 refund,卡恢复可用;你的历史单号再重放会收到 REFUNDED
<?php
class FakabaoAPI
{
public function __construct(
private string $baseUrl, // 例 https://fakabao.net
private string $apiKey,
private string $secret,
private int $connTimeout = 3,
private int $timeout = 6
) {}
/** 核销:['ok'=>bool,'pid'=>int,'replay'=>bool] 或 ['ok'=>false,'err'=>..,'msg'=>..] */
public function consume(string $cardNo, string $clientTxnId): array
{
$body = json_encode(
['method' => 'consume', 'cardNo' => $cardNo, 'clientTxnId' => $clientTxnId],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
return $this->request('POST', '/api/index.php', $body);
}
/** 对账查询:主查单号,可带卡号回落 */
public function query(string $clientTxnId, string $cardNo = ''): array
{
$qs = http_build_query(array_filter(
['clientTxnId' => $clientTxnId, 'cardNo' => $cardNo],
function ($v) { return $v !== '' && $v !== null; }
));
// GET 签名原文 = 实际发出的 QUERY_STRING,自行拼接保证一致
return $this->request('GET', '/api/index.php?' . $qs, null, $qs);
}
private function request(string $method, string $path, ?string $body, ?string $signBase = null): array
{
$ts = (string)time(); // 只取一次,签名/发送同值
$signBase = $body ?? $signBase;
$ch = curl_init($this->baseUrl . $path);
$headers = [
'X-API-Key: ' . $this->apiKey,
'X-Timestamp: ' . $ts,
'X-Sign: ' . hash_hmac('sha256', $ts . '.' . $signBase, $this->secret),
];
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$headers[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->connTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => true, // 生产严禁关闭
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
]);
$raw = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno !== 0 || $raw === false || $raw === '') {
return ['ok' => false, 'err' => 'TIMEOUT',
'msg' => '结果未知:订单保持pending,稍后用同一clientTxnId调query收敛'];
}
$j = json_decode($raw, true);
if (!is_array($j)) {
return ['ok' => false, 'err' => 'BAD_RESPONSE', 'msg' => '响应不可解析,按未知结果处理'];
}
if ((int)($j['code'] ?? 0) === 1) {
$d = $j['data'];
if (isset($d['used'])) {
return ['ok' => true, 'used' => (bool)$d['used'], 'pid' => (int)$d['pid'],
'usedTxn' => (string)$d['usedTxn'], 'usedAt' => (string)$d['usedAt']];
}
return ['ok' => true, 'pid' => (int)$d['pid'], 'replay' => (bool)$d['replay']];
}
return ['ok' => false, 'err' => (string)($j['err'] ?? 'BUSY'), 'msg' => (string)($j['msg'] ?? '')];
}
}
// ============ 使用 ============
$api = new FakabaoAPI('https://fakabao.net', '你的KEY', '你的SECRET');
$card = 'ABCDE-FGHIJ-KLMNO';
$txn = 'm51_' . date('YmdHis') . '_' . bin2hex(random_bytes(8));
// TODO 先把 $txn 以 pending 写入你自己的订单表,再发起核销
$r = $api->consume($card, $txn);
if ($r['ok']) {
// TODO 本地事务:按 $r['pid'] 发放权益 + 订单翻 success(同一事务,只翻转一次)
} elseif (in_array($r['err'], ['NOT_FOUND', 'ALREADY_USED', 'REFUNDED'], true)) {
// 终态失败:订单置 failed,提示换卡
} else {
// TIMEOUT/BUSY/RATE_LIMITED/BAD_RESPONSE/鉴权错:
// 保持 pending,前端文案"处理中,1分钟后自动到账",定时任务调 query 收敛
}
?>
已封装签名、超时语义、错误分类与补发核对,含充值闭环 + reconcile 模板。
⬇ 下载 PHP SDK fakabao-sdk-php.zip · 约 6 KB · 内含 FakabaoClient.php / example.php / README.md
require 'FakabaoClient.php';
$c = new FakabaoClient('https://fakabao.net', 'API_KEY', 'API_SECRET');
try {
$r = $c->consume($cardNo, $txn); // ['pid'=>38,'replay'=>false]
} catch (FakabaoException $e) {
if ($e->isRetryable()) { /* 保持pending,query收敛 */ }
/* 否则 NOT_FOUND / ALREADY_USED / REFUNDED 终态失败 */
}
$q = $c->query($cardNo, $txn); // ['used','pid','usedTxn','usedAt']
if ($c->isUsedByMe($cardNo, $txn)) { /* 唯一允许自动补发的情况 */ }
| 问题 | 回答 |
|---|---|
| 一直 401 INVALID_SIGN? | 三点排查:① POST 签的是请求体原文(encode 后不许再改动);② GET 签的是 QUERY_STRING 原文(自行拼接,别让库改写);③ Secret 重置后未同步 |
| INVALID_TIMESTAMP? | 时钟偏差超5分钟,校准 NTP 后用原单号重试即可,不会重复扣卡 |
| consume 超时,卡到底烧没烧? | 未知,别猜。等1分钟拿原单号 query:used=false → 可安全重发;used=true 且 usedTxn=自己 → 直接补发放。也可用同一单号再 consume,命中重放返回相同结果 |
| 能回滚已核销的卡吗? | API 不提供回滚(防“退款重烧”双花)。误核销走客服人工 refund:卡恢复可用,但旧单号重放会收到 REFUNDED |
| 用户重复提交同一张卡? | 你侧落订单表并复用同一 clientTxnId;即便发了不同单号,平台一卡一销兜底,败者收 ALREADY_USED |
| 还在用老 apikey/setUsed 代码? | 老协议已 410 下线,请尽快升级;怀疑泄漏的旧 Key 请在商户中心重置作废 |