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
- When a user converts (for example, places an order), your server sends one callback per event to our API.
- Every callback is a signed HTTPS
POSTwith a JSON body. - Each callback is identified by
order_id. If you send the sameorder_idagain, we treat it as the same event and do not count it twice. - Please return the parameters you received on our tracking link:
click_idis required;campaign_id,source_id,placement_id,networkandkeywordare recommended (details).
#Quick start
- 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). - Check your signing code with the test vector.
- Send a test callback to
https://openapi.pre.voypace.com/v1/callbacks. - Check the response: HTTP
200with"status":"accepted". - When all go-live checks pass, we give you a live key for production.
#Environments
| Environment | Base URL | Key | Use |
|---|---|---|---|
| Test | https://openapi.pre.voypace.com | test key ck_test_... | Integration testing. Callbacks are checked and logged, but never forwarded. |
| Production | https://openapi.voypace.com | live key ck_live_... | Live traffic. |
- A key works only in its own environment. A test key sent to production, or a live key sent to the test environment, gets
401 bad_key(the message says which environment the key belongs to). - HTTPS only (TLS 1.2 or newer). Plain HTTP is redirected to HTTPS (
308). Always call thehttps://URL directly. - Header
Content-Type: application/json, UTF-8.
#Endpoints
| Method and path | Use |
|---|---|
POST /v1/callbacks | Send one event (required) |
POST /v1/callbacks/batch | Send 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:
| Header | Value |
|---|---|
X-Api-Key | Your callback API key, for example ck_live_... |
X-Timestamp | Current Unix time in seconds, as a string |
X-Signature | Lowercase hex of HMAC-SHA256(signing_secret, X-Timestamp + "." + raw_body) |
Rules:
raw_bodyis the exact bytes you send in the body. Serialize the JSON once, sign those bytes, and send the same bytes. Do not change the body after signing.- We reject a request if
X-Timestampis more than 5 minutes away from our clock. Keep your server clock in sync (NTP). - The signing secret starts with
cs_. Keep it on your server only. Never put it in a URL, a log or client-side code. - We also check that the request comes from an IP on your allowlist.
#Code samples
Each sample has a sign function and a send example. All samples are tested against the test vector below.
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)];
}#Test vector
Use this fake secret to check your code. Your sign function must return the same 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 = 87538dcf86688a89751f83655e25ac5076245814c9507cf979ff1ec1ab2589b2Check it with 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'#Request fields
Each field has a level:
- Required: missing or wrong →
400, and the event is not accepted. - Recommended: please always send it. If it is missing, we still accept the event (no
400), but reports lose detail. - Optional: send it if you have it.
| Field | Type | Level | Rules |
|---|---|---|---|
order_id | string | Required | The 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_id | string | Required | The click_id value from our tracking link. Return it unchanged. Max 128 chars. Without it we cannot match the order to the click. |
event_name | string | Required | One of purchase, refund, register, add_to_cart. If you have other events, tell your account manager. |
event_time | integer | Required | When the event happened, Unix time in seconds. |
payout | string | Required | Our 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". |
currency | string | Required | Currency of payout (and order_amount), ISO 4217, for example USD. |
refund_of | string | Required for refunds | The order_id of the original purchase. |
campaign_id | string | Recommended | Pass-back parameter. Max 128 chars. |
source_id | string | Recommended | Pass-back parameter. Max 128 chars. |
placement_id | string | Recommended | Pass-back parameter. Max 128 chars. |
network | string | Recommended | Pass-back parameter. Max 128 chars. |
keyword | string | Recommended | Pass-back parameter. Max 128 chars. |
order_amount | string | Optional | Order amount, decimal string with 2 decimals. |
country | string | Optional | Buyer country, ISO 3166-1 alpha-2, for example VN. |
extra | object | Optional | Any 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 parameter | Callback field | Level |
|---|---|---|
click_id | click_id | Required |
campaign_id | campaign_id | Recommended |
source_id | source_id | Recommended |
placement_id | placement_id | Recommended |
network | network | Recommended |
keyword | keyword | Recommended |
Rules:
- Use the same name and the exact same value that arrived on the click. Do not decode, trim, change case or fill in a default.
- Keep the values with the order from click to conversion, the same way you keep
click_id. - If a value was empty or missing on the click, send an empty string
""or leave the field out. - Missing pass-back parameters never cause a
400. Onlyclick_idis required. - Other parameters on the tracking link that are not listed here do not need to come back.
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
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
{"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"}- For a refund,
order_idis the id of the refund (unique), andrefund_ofis the original order. payoutin a refund is the commission that is taken back (a positive number).- Send the pass-back parameters of the original click in a refund too.
#Batch
POST /v1/callbacks/batch takes a JSON array of up to 100 events. Sign the whole array body once.
[
{"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).
{"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 + ".".
{"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"}statusshows how far we got:pending,sending,retrying,delivered,dry_run(test environment),recorded(stored only, for example a refund),dropped(event type not used),failedordead.- An unknown
id, or an event of another key's merchant, returns404 not_found.
#Responses and error codes
Success: HTTP 200
{"code":0,"id":"pb_01JCX4M9T2QK","status":"accepted","request_id":"req_7f2kq9mx3a"}statusisaccepted(new event) orduplicate(we already have thisorder_id). Both mean success.request_ididentifies your request in our logs. Please log it on your side too. It helps us find a request quickly.
Errors use one JSON format:
{"code":"bad_signature","message":"Signature does not match","request_id":"req_9a1b2c3d4e"}| HTTP | code | Meaning | Retry? |
|---|---|---|---|
400 | bad_request | Missing or wrong field (the message says which) | No, fix the data |
401 | bad_key | API 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 |
401 | revoked | The key was revoked or has expired | No, ask for a new key |
401 | bad_signature | X-Signature does not match | No, fix the signing code |
401 | expired_timestamp | X-Timestamp is missing, not Unix seconds, or more than 5 minutes off | No, sync your clock |
403 | ip_denied | Your IP is not on the allowlist | No, contact us |
403 | ip_blocked | Your IP is temporarily blocked | No, contact us |
404 | not_found | Unknown path, or (for GET) unknown event id | No |
413 | too_large / payload_too_large | Body too large | No |
429 | rate_limited | Too many requests | Yes, with backoff |
500 | internal | Our side failed | Yes, with backoff |
502 / 503 / 504 | unavailable | Service temporarily unavailable | Yes, with backoff |
| (timeout) | – | No answer within 5 seconds | Yes, with backoff |
#Retry and idempotency
- Retry only on timeout (5 s),
429and5xx. - Backoff: about 10 s, 1 min, 5 min, 30 min, 2 h, then every 2 h, for up to 24 hours in total.
- Always retry with the same
order_idand the same field values. Make a newX-TimestampandX-Signaturefor each try. - Never retry
400,401,403or413. Log them and alert your team. - A
duplicateanswer means we already have the event. Stop retrying. - Send events in near real time (within a few minutes). Events older than 7 days may not be accepted.
#Limits
| Limit | Value |
|---|---|
| Body size | 16 KB per request; batch: 16 KB per event and 1 MB per request |
| Batch size | Up to 100 events |
extra field | Up to 2 KB |
| Timestamp window | ± 5 minutes |
| Response time | We answer within 5 seconds |
| Rate limit | 200 requests per second per API key by default. Ask your account manager if you need more. |
#IP allowlist
- Each key accepts requests only from the IPs or CIDR ranges on its allowlist.
- Send us your server egress IPs (all IPs or CIDR ranges your callbacks come from) before testing, and tell us before they change.
- Many failed authentications from one IP lead to a temporary block (24 hours). Contact us if this happens.
#Keys and security
- You get two values for each key: the API key (
ck_test_...orck_live_..., sent inX-Api-Key) and the signing secret (cs_..., never sent). - Both are shown only once. Store them in a secret manager. We cannot show them again.
- Rotation: ask your account manager. You get a new key and secret; the old key keeps working for 24 hours, so you can switch without downtime.
- Leak: if a key or secret leaks, tell us at once. We revoke it and give you a new one.
#Test and go-live checklist
- Send us your server egress IPs and the full list of
event_namevalues you will use. - Get the test key and signing secret.
- Check your signing code with the test vector.
- Send test callbacks to
https://openapi.pre.voypace.com:- at least one
purchase, - one
refund, - one duplicate (the same
order_idagain, expect"status":"duplicate"), - one with a wrong signature (expect
401 bad_signature). Click a real tracking link first, so the callbacks carry a realclick_idand the pass-back parameters.
- at least one
- We check every request with you, using the
request_idvalues. - Get the live key. Switch to
https://openapi.voypace.comand the live key. We watch the first live events together.
#Changelog
| Version | Date | Changes |
|---|---|---|
| v1.1 | 2026-11-04 | Pass-back parameters (campaign_id, source_id, placement_id, network, keyword) added as recommended fields. New test vector. |
| v1.0 | 2026-11-04 | First 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.