转化回调 API
从你的服务器向 Voypace 发送转化事件(订单、退款和其他事件)。每次回调都是一个带签名的 HTTPS POST 请求,请求体为 JSON。
#概述
- 用户发生转化(例如下单)时,你的服务器向我们的 API 每个事件发送一次回调。
- 每次回调都是带签名的 HTTPS
POST请求,请求体为 JSON。 - 每次回调以
order_id标识。如果你再次发送相同的order_id,我们视为同一事件,不会重复计算。 - 请回传你从我们的跟踪链接上收到的参数:
click_id必填;campaign_id、source_id、placement_id、network、keyword推荐回传(详情)。
#快速开始
- 向你的 Voypace 客户经理申请测试密钥(
ck_test_...)和对应的签名密钥(cs_...),同时把你的服务器出口 IP 发给我们(IP 白名单)。 - 用测试向量检查你的签名代码。
- 向
https://openapi.pre.voypace.com/v1/callbacks发送一个测试回调。 - 检查响应:HTTP
200,并且"status":"accepted"。 - 所有上线检查通过后,我们会给你生产环境的正式密钥。
#环境
| 环境 | 基础地址 | 密钥 | 用途 |
|---|---|---|---|
| 测试 | https://openapi.pre.voypace.com | 测试密钥 ck_test_... | 联调测试。回调会被校验和记录,但不会被转发。 |
| 生产 | https://openapi.voypace.com | 正式密钥 ck_live_... | 正式流量。 |
- 密钥只能在对应的环境使用。把测试密钥发到生产环境,或把正式密钥发到测试环境,都会返回
401 bad_key(message 会说明这个密钥属于哪个环境)。 - 只支持 HTTPS(TLS 1.2 或更高)。普通 HTTP 会被重定向到 HTTPS(
308),请始终直接调用https://地址。 - 请求头
Content-Type: application/json,UTF-8 编码。
#接口
| 方法和路径 | 用途 |
|---|---|
POST /v1/callbacks | 发送单个事件(必须) |
POST /v1/callbacks/batch | 以 JSON 数组批量发送,最多 100 个事件(可选) |
GET /v1/callbacks/{id} | 查询单个事件的状态(可选) |
#鉴权和签名
每个请求都必须带以下三个请求头:
| 请求头 | 值 |
|---|---|
X-Api-Key | 你的回调 API 密钥,例如 ck_live_... |
X-Timestamp | 当前 Unix 时间(秒),字符串形式 |
X-Signature | HMAC-SHA256(signing_secret, X-Timestamp + "." + raw_body) 的小写十六进制 |
规则:
raw_body是你在请求体中发送的原始字节。JSON 只序列化一次,对这些字节签名,然后发送同样的字节。签名后不要再修改请求体。- 如果
X-Timestamp与我们的时间相差超过 5 分钟,请求会被拒绝。请保持服务器时间同步(NTP)。 - 签名密钥以
cs_开头,只能保存在你的服务器上。不要放进 URL、日志或客户端代码。 - 我们还会检查请求是否来自你的 IP 白名单。
#示例代码
每个示例都包含一个 sign 函数和一个 send 示例。所有示例都已用下面的测试向量验证过。
API_KEY="ck_test_..." # your callback API key
SECRET="cs_..." # your signing secret
BODY='{"order_id":"SO2611041431","click_id":"c7hq2kx9w3mfrz8n","event_name":"purchase","event_time":1762237912,"payout":"3.42","currency":"USD"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -sS -X POST https://openapi.pre.voypace.com/v1/callbacks \
-H "Content-Type: application/json" \
-H "X-Api-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
--data-binary "$BODY"import hashlib
import hmac
import json
import time
import urllib.request
BASE_URL = "https://openapi.pre.voypace.com" # production: https://openapi.voypace.com
API_KEY = "ck_test_..." # your callback API key
SECRET = "cs_..." # your signing secret
def sign(secret: str, timestamp: str, raw_body: bytes) -> str:
message = timestamp.encode("utf-8") + b"." + raw_body
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def send(event: dict) -> dict:
# Serialize once, sign these exact bytes, and send the same bytes.
raw_body = json.dumps(event, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
timestamp = str(int(time.time()))
request = urllib.request.Request(
BASE_URL + "/v1/callbacks",
data=raw_body,
method="POST",
headers={
"Content-Type": "application/json",
"X-Api-Key": API_KEY,
"X-Timestamp": timestamp,
"X-Signature": sign(SECRET, timestamp, raw_body),
},
)
with urllib.request.urlopen(request, timeout=5) as response: # raises HTTPError on 4xx/5xx
return json.load(response)const crypto = require("node:crypto");
const BASE_URL = "https://openapi.pre.voypace.com"; // production: https://openapi.voypace.com
const API_KEY = "ck_test_..."; // your callback API key
const SECRET = "cs_..."; // your signing secret
function sign(secret, timestamp, rawBody) {
return crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest("hex");
}
async function send(event) {
// Serialize once, sign these exact bytes, and send the same string.
const rawBody = JSON.stringify(event);
const timestamp = String(Math.floor(Date.now() / 1000));
const response = await fetch(`${BASE_URL}/v1/callbacks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": API_KEY,
"X-Timestamp": timestamp,
"X-Signature": sign(SECRET, timestamp, rawBody),
},
body: rawBody,
signal: AbortSignal.timeout(5000),
});
return { status: response.status, body: await response.json() };
}
module.exports = { sign, send };import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Callback {
static final String BASE_URL = "https://openapi.pre.voypace.com"; // production: https://openapi.voypace.com
static final String API_KEY = "ck_test_..."; // your callback API key
static final String SECRET = "cs_..."; // your signing secret
static String sign(String secret, String timestamp, byte[] rawBody) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(mac.doFinal(rawBody));
}
static HttpResponse<String> send(String jsonBody) throws Exception {
byte[] rawBody = jsonBody.getBytes(StandardCharsets.UTF_8);
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/v1/callbacks"))
.timeout(Duration.ofSeconds(5))
.header("Content-Type", "application/json")
.header("X-Api-Key", API_KEY)
.header("X-Timestamp", timestamp)
.header("X-Signature", sign(SECRET, timestamp, rawBody))
.POST(HttpRequest.BodyPublishers.ofByteArray(rawBody))
.build();
return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
}
}package voypace
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
)
const (
BaseURL = "https://openapi.pre.voypace.com" // production: https://openapi.voypace.com
APIKey = "ck_test_..." // your callback API key
Secret = "cs_..." // your signing secret
)
func Sign(secret, timestamp string, rawBody []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
return hex.EncodeToString(mac.Sum(nil))
}
func Send(rawBody []byte) (*http.Response, error) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
req, err := http.NewRequest(http.MethodPost, BaseURL+"/v1/callbacks", bytes.NewReader(rawBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", APIKey)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Signature", Sign(Secret, timestamp, rawBody))
client := &http.Client{Timeout: 5 * time.Second}
return client.Do(req)
}<?php
const BASE_URL = 'https://openapi.pre.voypace.com'; // production: https://openapi.voypace.com
const API_KEY = 'ck_test_...'; // your callback API key
const SECRET = 'cs_...'; // your signing secret
function sign(string $secret, string $timestamp, string $rawBody): string {
return hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
}
function send(array $event): array {
// Encode once, sign these exact bytes, and send the same string.
$rawBody = json_encode($event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$timestamp = (string) time();
$ch = curl_init(BASE_URL . '/v1/callbacks');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $rawBody,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Api-Key: ' . API_KEY,
'X-Timestamp: ' . $timestamp,
'X-Signature: ' . sign(SECRET, $timestamp, $rawBody),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode($body, true)];
}#测试向量
用这个假的签名密钥检查你的代码。你的 sign 函数必须得到相同的 X-Signature。
signing_secret = cs_test_4fJ9kQ2mVx7LpR8tN3wZc6Yb1Hd5Ae0S
X-Timestamp = 1762237912
raw_body = {"order_id":"SO2611041431","click_id":"c7hq2kx9w3mfrz8n","event_name":"purchase","event_time":1762237912,"payout":"3.42","currency":"USD","campaign_id":"LP_20261103","source_id":"8012345","placement_id":"1234","network":"net_a","keyword":"7788990"}
string to sign = 1762237912.{"order_id":"SO2611041431","click_id":"c7hq2kx9w3mfrz8n","event_name":"purchase","event_time":1762237912,"payout":"3.42","currency":"USD","campaign_id":"LP_20261103","source_id":"8012345","placement_id":"1234","network":"net_a","keyword":"7788990"}
X-Signature = 87538dcf86688a89751f83655e25ac5076245814c9507cf979ff1ec1ab2589b2用 OpenSSL 检查:
printf '%s' '1762237912.{"order_id":"SO2611041431","click_id":"c7hq2kx9w3mfrz8n","event_name":"purchase","event_time":1762237912,"payout":"3.42","currency":"USD","campaign_id":"LP_20261103","source_id":"8012345","placement_id":"1234","network":"net_a","keyword":"7788990"}' \
| openssl dgst -sha256 -hmac 'cs_test_4fJ9kQ2mVx7LpR8tN3wZc6Yb1Hd5Ae0S'#请求字段
每个字段都有一个级别:
- 必填:缺少或错误 → 返回
400,事件不会被接收。 - 推荐:请始终发送。缺少时我们仍会接收事件(不会返回
400),但报表会缺少细节。 - 可选:有就发送。
| 字段 | 类型 | 级别 | 规则 |
|---|---|---|---|
order_id | string | 必填 | 真实订单号,每笔订单唯一,最长 64 个字符。不能是固定值或占位符。重试时使用相同的值。 |
click_id | string | 必填 | 我们跟踪链接上的 click_id 值,原样返回。最长 128 个字符。没有它,我们就无法把订单和点击对应起来。 |
event_name | string | 必填 | purchase、refund、register、add_to_cart 之一。如果有其他事件,请告诉你的客户经理。 |
event_time | integer | 必填 | 事件发生的时间,Unix 时间(秒)。 |
payout | string | 必填 | 这个事件给我们的佣金(不是订单金额)。两位小数的十进制字符串,例如 "3.42"。register 和 add_to_cart 请发送 "0.00"。 |
currency | string | 必填 | payout(以及 order_amount)的币种,ISO 4217,例如 USD。 |
refund_of | string | 退款时必填 | 原 purchase 的 order_id。 |
campaign_id | string | 推荐 | 回传参数。最长 128 个字符。 |
source_id | string | 推荐 | 回传参数。最长 128 个字符。 |
placement_id | string | 推荐 | 回传参数。最长 128 个字符。 |
network | string | 推荐 | 回传参数。最长 128 个字符。 |
keyword | string | 推荐 | 回传参数。最长 128 个字符。 |
order_amount | string | 可选 | 订单金额,两位小数的十进制字符串。 |
country | string | 可选 | 买家国家,ISO 3166-1 alpha-2,例如 VN。 |
extra | object | 可选 | 其他任意数据,最大 2 KB。我们会保存,但不会使用。 |
#回传参数
我们的跟踪链接上带有一些参数。用户发生转化时,请把你从跟踪链接上收到的值原样返回。
| 跟踪链接参数 | 回调字段 | 级别 |
|---|---|---|
click_id | click_id | 必填 |
campaign_id | campaign_id | 推荐 |
source_id | source_id | 推荐 |
placement_id | placement_id | 推荐 |
network | network | 推荐 |
keyword | keyword | 推荐 |
规则:
- 使用点击时带来的相同参数名和完全相同的值。不要解码、去空格、改大小写或填默认值。
- 从点击到转化,把这些值和订单一起保存,和保存
click_id的方式一样。 - 如果点击时某个值为空或没有,发送空字符串
""或者不传该字段。 - 缺少回传参数不会导致
400。只有click_id是必填的。 - 跟踪链接上没有列在这里的其他参数不需要回传。
订单和点击: 一次点击(click_id)可能产生多笔订单。请每笔订单发送一次回调,每次使用各自的 order_id。不同的订单绝不能复用同一个 order_id。
#示例
#购买
POST /v1/callbacks HTTP/1.1
Host: openapi.voypace.com
Content-Type: application/json
X-Api-Key: ck_live_...
X-Timestamp: 1762237912
X-Signature: <hex HMAC>
{"order_id":"SO2611041431","click_id":"c7hq2kx9w3mfrz8n","event_name":"purchase","event_time":1762237912,"payout":"3.42","currency":"USD","order_amount":"28.50","country":"VN","campaign_id":"LP_20261103","source_id":"8012345","placement_id":"1234","network":"net_a","keyword":"7788990"}#退款
{"order_id":"RF2611050902","click_id":"c7hq2kx9w3mfrz8n","event_name":"refund","event_time":1762304520,"payout":"3.42","currency":"USD","refund_of":"SO2611041431","campaign_id":"LP_20261103","source_id":"8012345","placement_id":"1234","network":"net_a","keyword":"7788990"}- 退款时,
order_id是退款单的编号(唯一),refund_of是原订单号。 - 退款中的
payout是要扣回的佣金金额(正数)。 - 退款时也请带上原点击的回传参数。
#批量
POST /v1/callbacks/batch 接收一个最多包含 100 个事件的 JSON 数组。对整个数组请求体签名一次。
[
{"order_id":"SO2611041431","click_id":"c7hq2kx9w3mfrz8n","event_name":"purchase","event_time":1762237912,"payout":"3.42","currency":"USD"},
{"order_id":"SO2611041432","click_id":"k2mx8qp4rt6vw9ya","event_name":"purchase","event_time":1762237950,"payout":"1.10","currency":"USD"}
]响应中每个事件有一个结果,顺序与请求相同。只重试失败的项。单项可能因 bad_request 或 too_large(单个事件超过 16 KB)而失败。
{"code":0,"request_id":"req_7f2kq9mx3a","results":[
{"index":0,"code":0,"id":"pb_01JCX4M9T2QK","status":"accepted"},
{"index":1,"code":"bad_request","message":"payout must be a decimal string with up to 2 decimals, e.g. \"3.42\""}
]}#查询状态
GET /v1/callbacks/{id} 返回你的密钥发送过的某个事件。{id} 是回调响应里的 id。请求头同样带上这三个;请求体为空,所以签名字符串是 X-Timestamp + "."。
{"code":0,"id":"pb_01JCX4M9T2QK","order_id":"SO2611041431","event_name":"purchase","status":"delivered","attempts":1,"created_at":"2026-11-04T06:31:52.104Z","delivered_at":"2026-11-04T06:31:53.020Z","request_id":"req_3c4d5e6f7a"}status表示处理进度:pending、sending、retrying、delivered、dry_run(测试环境)、recorded(仅保存,例如退款)、dropped(不使用的事件类型)、failed或dead。id不存在,或属于其他商户的事件,返回404 not_found。
#响应和错误码
成功:HTTP 200
{"code":0,"id":"pb_01JCX4M9T2QK","status":"accepted","request_id":"req_7f2kq9mx3a"}status为accepted(新事件)或duplicate(我们已有这个order_id)。两者都表示成功。request_id是你的请求在我们日志里的编号。请在你那边也记录下来,方便我们快速查找请求。
错误使用统一的 JSON 格式:
{"code":"bad_signature","message":"Signature does not match","request_id":"req_9a1b2c3d4e"}| HTTP | code | 含义 | 是否重试 |
|---|---|---|---|
400 | bad_request | 字段缺少或错误(message 会说明是哪个) | 否,修正数据 |
401 | bad_key | API 密钥缺失、格式错误或不存在;密钥用错了环境;或你的账户已暂停。message 会说明具体原因。 | 否,检查配置 |
401 | revoked | 密钥已被吊销或已过期 | 否,申请新密钥 |
401 | bad_signature | X-Signature 不匹配 | 否,修正签名代码 |
401 | expired_timestamp | X-Timestamp 缺失、不是 Unix 秒,或相差超过 5 分钟 | 否,同步服务器时间 |
403 | ip_denied | 你的 IP 不在白名单里 | 否,联系我们 |
403 | ip_blocked | 你的 IP 被临时封禁 | 否,联系我们 |
404 | not_found | 路径不存在,或(GET 时)事件 id 不存在 | 否 |
413 | too_large / payload_too_large | 请求体过大 | 否 |
429 | rate_limited | 请求太多 | 是,退避重试 |
500 | internal | 我们这边出错 | 是,退避重试 |
502 / 503 / 504 | unavailable | 服务暂时不可用 | 是,退避重试 |
| (超时) | – | 5 秒内没有响应 | 是,退避重试 |
#重试和幂等
- 只在超时(5 秒)、
429和5xx时重试。 - 退避间隔:大约 10 秒、1 分钟、5 分钟、30 分钟、2 小时,之后每 2 小时一次,总共最长 24 小时。
- 重试时始终使用相同的
order_id和相同的字段值。每次重试都要生成新的X-Timestamp和X-Signature。 - 不要重试
400、401、403或413。请记录下来并通知你的团队。 - 收到
duplicate表示我们已经有这个事件,请停止重试。 - 请尽量实时发送事件(几分钟内)。超过 7 天的事件可能不会被接收。
#限制
| 限制 | 值 |
|---|---|
| 请求体大小 | 每个请求 16 KB;批量:每个事件 16 KB,每个请求 1 MB |
| 批量数量 | 最多 100 个事件 |
extra 字段 | 最大 2 KB |
| 时间戳窗口 | ± 5 分钟 |
| 响应时间 | 我们在 5 秒内响应 |
| 限流 | 默认每个 API 密钥每秒 200 个请求。如需更高,请联系你的客户经理。 |
#IP 白名单
- 每个密钥只接受来自其白名单中 IP 或 CIDR 网段的请求。
- 测试前请把你的服务器出口 IP 发给我们(回调会从哪些 IP 或网段发出),IP 变更前也请提前告知。
- 同一 IP 多次鉴权失败会被临时封禁(24 小时)。遇到这种情况请联系我们。
#密钥和安全
- 每个密钥包含两个值:API 密钥(
ck_test_...或ck_live_...,放在X-Api-Key中发送)和签名密钥(cs_...,不会发送)。 - 两者都只显示一次。请保存在密钥管理系统中,我们无法再次显示。
- 轮换: 请联系你的客户经理。你会拿到新的密钥和签名密钥;旧密钥会继续可用 24 小时,方便你无缝切换。
- 泄露: 如果密钥或签名密钥泄露,请立即告诉我们。我们会吊销它并给你新的。
#测试和上线清单
- 把你的服务器出口 IP 和要使用的
event_name完整列表发给我们。 - 拿到测试密钥和签名密钥。
- 用测试向量检查你的签名代码。
- 向
https://openapi.pre.voypace.com发送测试回调:- 至少一个
purchase, - 一个
refund, - 一个重复事件(再次发送相同的
order_id,应返回"status":"duplicate"), - 一个签名错误的请求(应返回
401 bad_signature)。 请先点击一次真实的跟踪链接,这样回调会带上真实的click_id和回传参数。
- 至少一个
- 我们会根据
request_id和你一起核对每个请求。 - 拿到正式密钥。切换到
https://openapi.voypace.com和正式密钥,我们一起观察最初的正式事件。
#更新记录
| 版本 | 日期 | 变更 |
|---|---|---|
| v1.1 | 2026-11-04 | 新增推荐回传参数(campaign_id、source_id、placement_id、network、keyword)。新的测试向量。 |
| v1.0 | 2026-11-04 | 第一版:带签名的 POST /v1/callbacks、批量接口、错误码、重试规则。 |
#支持
如有问题、需要新密钥或 IP 变更,请联系你的 Voypace 客户经理。报告请求相关的问题时,请附上 request_id。