Partner API / Webhooks
Webhooks
Get told the moment a sale changes, instead of asking the API every few minutes.
Where to set itAPI key tab, Webhooks: one https address per partner
Events
conversion.created, conversion.approved, conversion.paid, conversion.clawed_backSentWithin a minute of the change, as a JSON POST
Signed
X-AffiliatePipe-Signature: t=<unix time>,v1=<HMAC-SHA256>Retries5 more tries after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours
Rate limitNot counted: webhooks are free
Every event
Each message is a JSON POST with id, type, created and data. The fields in data match List conversions. Each sale sends each event once. Clicks are not sent by webhook: use the live stream for those.
conversion.createdA sale through your link is recorded. Commission is pending for 45 days.conversion.approvedThe 45 days have passed with no refund. The commission counts towards your next payout.conversion.paidThe commission was included in a payout to your Stripe account.conversion.clawed_backThe sale was refunded or disputed within 120 days, so its commission is reversed.pingYou pressed Send a test event on the API key tab. data is empty.conversion.created
{
"id": "evt_8f2c41d0a9b3e6f1",
"type": "conversion.created",
"created": "2026-10-20T09:21:12Z",
"data": {
"id": 412,
"domain": "greyhoundapi.com",
"amount": 4900,
"commission": 490,
"currency": "usd",
"status": "pending",
"recurring": true,
"created_at": "2026-10-20T09:21:11Z"
}
}conversion.approved
{
"id": "evt_8f2c41d0a9b3e6f1",
"type": "conversion.approved",
"created": "2026-10-20T09:21:12Z",
"data": {
"id": 412,
"domain": "greyhoundapi.com",
"amount": 4900,
"commission": 490,
"currency": "usd",
"status": "approved",
"recurring": true,
"created_at": "2026-10-20T09:21:11Z"
}
}conversion.paid
{
"id": "evt_8f2c41d0a9b3e6f1",
"type": "conversion.paid",
"created": "2026-10-20T09:21:12Z",
"data": {
"id": 412,
"domain": "greyhoundapi.com",
"amount": 4900,
"commission": 490,
"currency": "usd",
"status": "paid",
"recurring": true,
"created_at": "2026-10-20T09:21:11Z"
}
}conversion.clawed_back
{
"id": "evt_8f2c41d0a9b3e6f1",
"type": "conversion.clawed_back",
"created": "2026-10-20T09:21:12Z",
"data": {
"id": 412,
"domain": "greyhoundapi.com",
"amount": 4900,
"commission": 490,
"currency": "usd",
"status": "clawed_back",
"recurring": true,
"created_at": "2026-10-20T09:21:11Z"
}
}ping
{
"id": "evt_8f2c41d0a9b3e6f1",
"type": "ping",
"created": "2026-10-20T09:21:12Z",
"data": {}
} Check the signature
Compute HMAC-SHA256 of t, a full stop, and the raw body, with your signing secret. It must equal v1, and t should be within five minutes of now. Reply with any 2xx; anything else is retried.
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_AFFILIATEPIPE_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $sig), $p); // t=...&v1=...
$ok = isset($p['t'], $p['v1'])
&& abs(time() - (int) $p['t']) < 300
&& hash_equals(hash_hmac('sha256', $p['t'] . '.' . $body, getenv('AP_WEBHOOK_SECRET')), $p['v1']);
if (!$ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
// $event['type'] is conversion.created, .approved, .paid or .clawed_back
http_response_code(200);import hmac, hashlib, os, time
def verify(body: bytes, header: str) -> bool:
parts = dict(x.split("=", 1) for x in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t or abs(time.time() - int(t)) > 300:
return False
want = hmac.new(os.environ["AP_WEBHOOK_SECRET"].encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)import crypto from "node:crypto";
export function verify(rawBody, header) {
const p = Object.fromEntries(header.split(",").map(x => x.split("=")));
if (!p.t || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const want = crypto.createHmac("sha256", process.env.AP_WEBHOOK_SECRET).update(`${p.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(p.v1 || ""));
}- Saving a new address makes a new secret; the old one stops working.
- Sales from before you set the address are not sent. Use List conversions with
format=csvfor history. - Only public https addresses on port 443 are accepted.