Voypace Partner APIv1.1
English

转化回调 API

从你的服务器向 Voypace 发送转化事件(订单、退款和其他事件)。每次回调都是一个带签名的 HTTPS POST 请求,请求体为 JSON。

#概述

#快速开始

  1. 向你的 Voypace 客户经理申请测试密钥(ck_test_...)和对应的签名密钥(cs_...),同时把你的服务器出口 IP 发给我们(IP 白名单)。
  2. 用测试向量检查你的签名代码。
  3. 向 https://openapi.pre.voypace.com/v1/callbacks 发送一个测试回调。
  4. 检查响应:HTTP 200,并且 "status":"accepted"。
  5. 所有上线检查通过后,我们会给你生产环境的正式密钥。

#环境

环境基础地址密钥用途
测试https://openapi.pre.voypace.com测试密钥 ck_test_...联调测试。回调会被校验和记录,但不会被转发。
生产https://openapi.voypace.com正式密钥 ck_live_...正式流量。

#接口

方法和路径用途
POST /v1/callbacks发送单个事件(必须)
POST /v1/callbacks/batch以 JSON 数组批量发送,最多 100 个事件(可选)
GET /v1/callbacks/{id}查询单个事件的状态(可选)

#鉴权和签名

每个请求都必须带以下三个请求头:

请求头值
X-Api-Key你的回调 API 密钥,例如 ck_live_...
X-Timestamp当前 Unix 时间(秒),字符串形式
X-SignatureHMAC-SHA256(signing_secret, X-Timestamp + "." + raw_body) 的小写十六进制

规则:

#示例代码

每个示例都包含一个 sign 函数和一个 send 示例。所有示例都已用下面的测试向量验证过。

curl.sh
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"
sign.py
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)
sign.js
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 };
Callback.java
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());
    }
}
callback.go
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)
}
callback.php
<?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。

text
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 检查:

bash
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'

#请求字段

每个字段都有一个级别:

字段类型级别规则
order_idstring必填真实订单号,每笔订单唯一,最长 64 个字符。不能是固定值或占位符。重试时使用相同的值。
click_idstring必填我们跟踪链接上的 click_id 值,原样返回。最长 128 个字符。没有它,我们就无法把订单和点击对应起来。
event_namestring必填purchase、refund、register、add_to_cart 之一。如果有其他事件,请告诉你的客户经理。
event_timeinteger必填事件发生的时间,Unix 时间(秒)。
payoutstring必填这个事件给我们的佣金(不是订单金额)。两位小数的十进制字符串,例如 "3.42"。register 和 add_to_cart 请发送 "0.00"。
currencystring必填payout(以及 order_amount)的币种,ISO 4217,例如 USD。
refund_ofstring退款时必填原 purchase 的 order_id。
campaign_idstring推荐回传参数。最长 128 个字符。
source_idstring推荐回传参数。最长 128 个字符。
placement_idstring推荐回传参数。最长 128 个字符。
networkstring推荐回传参数。最长 128 个字符。
keywordstring推荐回传参数。最长 128 个字符。
order_amountstring可选订单金额,两位小数的十进制字符串。
countrystring可选买家国家,ISO 3166-1 alpha-2,例如 VN。
extraobject可选其他任意数据,最大 2 KB。我们会保存,但不会使用。

#回传参数

我们的跟踪链接上带有一些参数。用户发生转化时,请把你从跟踪链接上收到的值原样返回。

跟踪链接参数回调字段级别
click_idclick_id必填
campaign_idcampaign_id推荐
source_idsource_id推荐
placement_idplacement_id推荐
networknetwork推荐
keywordkeyword推荐

规则:

订单和点击: 一次点击(click_id)可能产生多笔订单。请每笔订单发送一次回调,每次使用各自的 order_id。不同的订单绝不能复用同一个 order_id。

#示例

#购买

http
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"}

#退款

json
{"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"}

#批量

POST /v1/callbacks/batch 接收一个最多包含 100 个事件的 JSON 数组。对整个数组请求体签名一次。

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)而失败。

json
{"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 + "."。

json
{"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"}

#响应和错误码

成功:HTTP 200

json
{"code":0,"id":"pb_01JCX4M9T2QK","status":"accepted","request_id":"req_7f2kq9mx3a"}

错误使用统一的 JSON 格式:

json
{"code":"bad_signature","message":"Signature does not match","request_id":"req_9a1b2c3d4e"}
HTTPcode含义是否重试
400bad_request字段缺少或错误(message 会说明是哪个)否,修正数据
401bad_keyAPI 密钥缺失、格式错误或不存在;密钥用错了环境;或你的账户已暂停。message 会说明具体原因。否,检查配置
401revoked密钥已被吊销或已过期否,申请新密钥
401bad_signatureX-Signature 不匹配否,修正签名代码
401expired_timestampX-Timestamp 缺失、不是 Unix 秒,或相差超过 5 分钟否,同步服务器时间
403ip_denied你的 IP 不在白名单里否,联系我们
403ip_blocked你的 IP 被临时封禁否,联系我们
404not_found路径不存在,或(GET 时)事件 id 不存在否
413too_large / payload_too_large请求体过大否
429rate_limited请求太多是,退避重试
500internal我们这边出错是,退避重试
502 / 503 / 504unavailable服务暂时不可用是,退避重试
(超时)–5 秒内没有响应是,退避重试

#重试和幂等

#限制

限制值
请求体大小每个请求 16 KB;批量:每个事件 16 KB,每个请求 1 MB
批量数量最多 100 个事件
extra 字段最大 2 KB
时间戳窗口± 5 分钟
响应时间我们在 5 秒内响应
限流默认每个 API 密钥每秒 200 个请求。如需更高,请联系你的客户经理。

#IP 白名单

#密钥和安全

#测试和上线清单

  1. 把你的服务器出口 IP 和要使用的 event_name 完整列表发给我们。
  2. 拿到测试密钥和签名密钥。
  3. 用测试向量检查你的签名代码。
  4. 向 https://openapi.pre.voypace.com 发送测试回调:
    • 至少一个 purchase,
    • 一个 refund,
    • 一个重复事件(再次发送相同的 order_id,应返回 "status":"duplicate"),
    • 一个签名错误的请求(应返回 401 bad_signature)。 请先点击一次真实的跟踪链接,这样回调会带上真实的 click_id 和回传参数。
  5. 我们会根据 request_id 和你一起核对每个请求。
  6. 拿到正式密钥。切换到 https://openapi.voypace.com 和正式密钥,我们一起观察最初的正式事件。

#更新记录

版本日期变更
v1.12026-11-04新增推荐回传参数(campaign_id、source_id、placement_id、network、keyword)。新的测试向量。
v1.02026-11-04第一版:带签名的 POST /v1/callbacks、批量接口、错误码、重试规则。

#支持

如有问题、需要新密钥或 IP 变更,请联系你的 Voypace 客户经理。报告请求相关的问题时,请附上 request_id。