Voypace Partner APIv1.1
中文

Conversion Callback API

Send conversions (orders, refunds and other events) from your server to Voypace. Every callback is one signed HTTPS POST with a JSON body.

#Overview

#Quick start

  1. Ask your Voypace account manager for a test key (ck_test_...) and its signing secret (cs_...). Send us your server egress IPs at the same time (IP allowlist).
  2. Check your signing code with the test vector.
  3. Send a test callback to https://openapi.pre.voypace.com/v1/callbacks.
  4. Check the response: HTTP 200 with "status":"accepted".
  5. When all go-live checks pass, we give you a live key for production.

#Environments

EnvironmentBase URLKeyUse
Testhttps://openapi.pre.voypace.comtest key ck_test_...Integration testing. Callbacks are checked and logged, but never forwarded.
Productionhttps://openapi.voypace.comlive key ck_live_...Live traffic.

#Endpoints

Method and pathUse
POST /v1/callbacksSend one event (required)
POST /v1/callbacks/batchSend up to 100 events as a JSON array (optional)
GET /v1/callbacks/{id}Check the status of one event (optional)

#Authentication and signing

Every request must have these three headers:

HeaderValue
X-Api-KeyYour callback API key, for example ck_live_...
X-TimestampCurrent Unix time in seconds, as a string
X-SignatureLowercase hex of HMAC-SHA256(signing_secret, X-Timestamp + "." + raw_body)

Rules:

#Code samples

Each sample has a sign function and a send example. All samples are tested against the test vector below.

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)];
}

#Test vector

Use this fake secret to check your code. Your sign function must return the same 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

Check it with 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'

#Request fields

Each field has a level:

FieldTypeLevelRules
order_idstringRequiredThe real order id, unique for each order, max 64 chars. Never a fixed value or a placeholder. Use the same value when you retry.
click_idstringRequiredThe click_id value from our tracking link. Return it unchanged. Max 128 chars. Without it we cannot match the order to the click.
event_namestringRequiredOne of purchase, refund, register, add_to_cart. If you have other events, tell your account manager.
event_timeintegerRequiredWhen the event happened, Unix time in seconds.
payoutstringRequiredOur commission for this event (not the order amount). Decimal string with 2 decimals, for example "3.42". For register and add_to_cart, send "0.00".
currencystringRequiredCurrency of payout (and order_amount), ISO 4217, for example USD.
refund_ofstringRequired for refundsThe order_id of the original purchase.
campaign_idstringRecommendedPass-back parameter. Max 128 chars.
source_idstringRecommendedPass-back parameter. Max 128 chars.
placement_idstringRecommendedPass-back parameter. Max 128 chars.
networkstringRecommendedPass-back parameter. Max 128 chars.
keywordstringRecommendedPass-back parameter. Max 128 chars.
order_amountstringOptionalOrder amount, decimal string with 2 decimals.
countrystringOptionalBuyer country, ISO 3166-1 alpha-2, for example VN.
extraobjectOptionalAny other data, max 2 KB. We store it but do not use it.

#Pass-back parameters

Our tracking link carries some parameters. When a user converts, please return the values you received on our tracking link unchanged.

Tracking link parameterCallback fieldLevel
click_idclick_idRequired
campaign_idcampaign_idRecommended
source_idsource_idRecommended
placement_idplacement_idRecommended
networknetworkRecommended
keywordkeywordRecommended

Rules:

Orders and clicks: one click (click_id) can lead to many orders. Send one callback per order, each with its own order_id. A different order must never reuse an order_id.

#Examples

#Purchase

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

#Refund

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

#Batch

POST /v1/callbacks/batch takes a JSON array of up to 100 events. Sign the whole array body once.

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

The response has one result per event, in the same order. Retry only the failed items. An item can fail with bad_request or too_large (one event over 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\""}
]}

#Check status

GET /v1/callbacks/{id} returns one event that your key sent. {id} is the id from the callback response. Send the same three headers; the body is empty, so the string to sign is 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"}

#Responses and error codes

Success: HTTP 200

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

Errors use one JSON format:

json
{"code":"bad_signature","message":"Signature does not match","request_id":"req_9a1b2c3d4e"}
HTTPcodeMeaningRetry?
400bad_requestMissing or wrong field (the message says which)No, fix the data
401bad_keyAPI key is missing, malformed or unknown; key used in the wrong environment; or your account is paused. The message says which.No, fix the setup
401revokedThe key was revoked or has expiredNo, ask for a new key
401bad_signatureX-Signature does not matchNo, fix the signing code
401expired_timestampX-Timestamp is missing, not Unix seconds, or more than 5 minutes offNo, sync your clock
403ip_deniedYour IP is not on the allowlistNo, contact us
403ip_blockedYour IP is temporarily blockedNo, contact us
404not_foundUnknown path, or (for GET) unknown event idNo
413too_large / payload_too_largeBody too largeNo
429rate_limitedToo many requestsYes, with backoff
500internalOur side failedYes, with backoff
502 / 503 / 504unavailableService temporarily unavailableYes, with backoff
(timeout)–No answer within 5 secondsYes, with backoff

#Retry and idempotency

#Limits

LimitValue
Body size16 KB per request; batch: 16 KB per event and 1 MB per request
Batch sizeUp to 100 events
extra fieldUp to 2 KB
Timestamp window± 5 minutes
Response timeWe answer within 5 seconds
Rate limit200 requests per second per API key by default. Ask your account manager if you need more.

#IP allowlist

#Keys and security

#Test and go-live checklist

  1. Send us your server egress IPs and the full list of event_name values you will use.
  2. Get the test key and signing secret.
  3. Check your signing code with the test vector.
  4. Send test callbacks to https://openapi.pre.voypace.com:
    • at least one purchase,
    • one refund,
    • one duplicate (the same order_id again, expect "status":"duplicate"),
    • one with a wrong signature (expect 401 bad_signature). Click a real tracking link first, so the callbacks carry a real click_id and the pass-back parameters.
  5. We check every request with you, using the request_id values.
  6. Get the live key. Switch to https://openapi.voypace.com and the live key. We watch the first live events together.

#Changelog

VersionDateChanges
v1.12026-11-04Pass-back parameters (campaign_id, source_id, placement_id, network, keyword) added as recommended fields. New test vector.
v1.02026-11-04First version: signed POST /v1/callbacks, batch endpoint, error codes, retry rules.

#Support

Questions, new keys or IP changes: contact your Voypace account manager. Please include the request_id when you report a problem with a request.