Server-side postback integration with HMAC
POST your order data to api.keepface.ai/api/v2/affiliate/postback/{brand_id} on every successful purchase. Sign the body with HMAC-SHA256 using your brand secret. We respond 200/201 on accept, 401 on bad signature.
What server-side postbacks buy you
The JS pixel works in 90% of cases. You need server postbacks when:
- Refunds matter — Stripe / Shopify firing a refund webhook server-to-server gives you accurate clawback without browser involvement
- Headless checkout — your front-end doesn’t fire DOM events (mobile app, voice commerce, B2B sales force)
- Audit compliance — your finance team wants every commission tied to a server-recorded source
If your business is none of the above, stay on the JS pixel and skip this guide.
The contract
Endpoint
POST https://api.keepface.ai/api/v2/affiliate/postback/{brand_id}
{brand_id} is the numeric ID we assigned your brand. Find it in Brands → Edit → URL (last segment).
Required headers
Content-Type: application/json
X-KF-Signature: <hex-encoded HMAC-SHA256 of "<timestamp>.<body>">
X-KF-Timestamp: <unix epoch seconds>
The timestamp protects against replay attacks. We reject if |now - timestamp| > 300 seconds.
Body
{
"token": "AbCd1234",
"discount_code": "NIKE-ABCD1234",
"order_id": "shopify-7301421",
"gross_amount": 149.00,
"net_amount": 127.45,
"currency": "USD",
"customer_email": "[email protected]",
"customer_ip": "203.0.113.42",
"customer_country": "US",
"skus": ["SKU-RED-M", "SKU-BLUE-L"],
"brand_confirmed_at": "2026-06-01T14:32:00Z",
"metadata": { "any_extra": "your-fields-here" }
}
Field rules:
- Either
tokenORdiscount_codeis required — both work; we resolve token first, fall back to discount_code. This is how brand-side cart codes get attributed. order_idis your idempotency key. Send the same order_id twice → second call is a no-op (returns existing row).gross_amountis in major units (dollars, not cents).149.00means $149.currencyis a 3-letter ISO code.- Everything else is optional but recommended —
customer_email,customer_ip, andcustomer_countrypower fraud detection and the geo-restriction rules.
Responses
| Status | Meaning | Your action |
|---|---|---|
| 201 | Conversion created | Stop retrying |
| 200 + ok=true + created=false | Duplicate (idempotent) | Stop retrying |
| 200 + ok=false | Brand not enabled / paused | Don’t retry until you re-enable |
| 401 invalid_signature | HMAC failed | Check secret + timestamp |
| 401 stale_timestamp | Clock drift | Sync your server clock to NTP |
| 422 rejected | Anti-fraud / restriction triggered | Inspect message field |
Where to find your secret
- Brands → Edit → Affiliate → Tracking tab → Generate secret
- The secret is shown ONCE on generation. Copy it into your env var (
KF_AFFILIATE_SECRET) immediately. - You can rotate. After rotation, the previous secret stays valid for 7 days so you can roll your servers without downtime.
Never commit the secret to git. If you lose it, you can generate a new one — the old one is invalidated after the 7-day overlap.
Sign-then-send recipe
Node.js
const crypto = require('crypto');
async function sendPostback(orderData) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify(orderData);
const signedPayload = `${timestamp}.${body}`;
const sig = crypto
.createHmac('sha256', process.env.KF_AFFILIATE_SECRET)
.update(signedPayload)
.digest('hex');
const r = await fetch(
`https://api.keepface.ai/api/v2/affiliate/postback/${process.env.KF_BRAND_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-KF-Signature': sig,
'X-KF-Timestamp': timestamp,
},
body,
}
);
if (!r.ok) throw new Error(`postback ${r.status}: ${await r.text()}`);
return r.json();
}
PHP / Laravel
use Illuminate\Support\Facades\Http;
function sendPostback(array $orderData): array {
$timestamp = (string) time();
$body = json_encode($orderData);
$sig = hash_hmac('sha256', $timestamp . '.' . $body, env('KF_AFFILIATE_SECRET'));
$r = Http::withHeaders([
'Content-Type' => 'application/json',
'X-KF-Signature' => $sig,
'X-KF-Timestamp' => $timestamp,
])->withBody($body, 'application/json')
->post('https://api.keepface.ai/api/v2/affiliate/postback/' . env('KF_BRAND_ID'));
if (! $r->successful()) throw new \Exception("postback {$r->status()}: {$r->body()}");
return $r->json();
}
Python
import hmac, hashlib, json, os, time, requests
def send_postback(order_data: dict) -> dict:
ts = str(int(time.time()))
body = json.dumps(order_data, separators=(',', ':'))
sig = hmac.new(
os.environ['KF_AFFILIATE_SECRET'].encode(),
f'{ts}.{body}'.encode(),
hashlib.sha256,
).hexdigest()
r = requests.post(
f'https://api.keepface.ai/api/v2/affiliate/postback/{os.environ["KF_BRAND_ID"]}',
headers={
'Content-Type': 'application/json',
'X-KF-Signature': sig,
'X-KF-Timestamp': ts,
},
data=body,
timeout=10,
)
r.raise_for_status()
return r.json()
Quick cURL test (replace SECRET + BRAND_ID + TOKEN)
#!/bin/bash
SECRET="your-secret-here"
BRAND_ID="123"
BODY='{"token":"AbCd1234","order_id":"test-001","gross_amount":99.99,"currency":"USD"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST "https://api.keepface.ai/api/v2/affiliate/postback/$BRAND_ID" \
-H "Content-Type: application/json" \
-H "X-KF-Signature: $SIG" \
-H "X-KF-Timestamp: $TS" \
-d "$BODY"
Expected response: 201 Created + JSON conversion row. If you get 401, check the secret + clock.
Refunds (also via postback)
Same endpoint, different path:
POST https://api.keepface.ai/api/v2/affiliate/postback/{brand_id}/refund
Body:
{
"order_id": "shopify-7301421",
"refund_id": "shopify-refund-99821",
"refund_amount": 49.00,
"currency": "USD",
"brand_confirmed_at": "2026-06-15T10:11:00Z"
}
We claw back commission proportionally by default (50% refunded → 50% commission reversed). To clawback the full commission on any refund, set Refund policy: Full clawback in the brand settings.
Shopify-specific path
Shopify gives you HMAC signing for free via their webhook secret. Use our dedicated Shopify endpoint instead of the generic postback:
POST https://api.keepface.ai/api/v2/affiliate/shopify/{brand_id}
- Configure in Shopify admin → Settings → Notifications → Webhooks
- Topic:
Order creation+Order refund(set both) - Format: JSON
- URL: the path above
- Use Shopify’s auto-generated webhook secret. Paste it into Brands → Edit → Affiliate → Tracking → Shopify webhook secret.
Shopify signs with their own scheme (X-Shopify-Hmac-Sha256 base64). We honor it directly — no need to write your own signer.
WooCommerce-specific path
WooCommerce → use the generic postback endpoint above. Best implementation: drop the signer inside a woocommerce_order_status_completed hook.
add_action('woocommerce_order_status_completed', function ($order_id) {
$order = wc_get_order($order_id);
$token = $_COOKIE['kf_aff'] ?? null;
$code = null;
foreach ($order->get_coupon_codes() as $c) { $code = $c; break; }
if (!$token && !$code) return; // not an affiliate sale
send_postback([
'token' => $token,
'discount_code' => $code,
'order_id' => (string) $order_id,
'gross_amount' => (float) $order->get_total(),
'currency' => $order->get_currency(),
'customer_email' => $order->get_billing_email(),
'customer_country' => $order->get_billing_country(),
]);
});
Daily reconciliation cron
Don’t rely solely on real-time webhooks. Run a daily job that resends any orders missed in the last 24h:
SELECT id, order_id, gross_amount, currency
FROM orders
WHERE created_at > NOW() - INTERVAL '24 hours'
AND (affiliate_code IS NOT NULL OR affiliate_token IS NOT NULL)
AND affiliate_postback_sent_at IS NULL;
Resend each row, mark affiliate_postback_sent_at = NOW() on 2xx. This catches transient failures and protects against our outages.
Verifying you set this up right
Three checks:
- Health endpoint — admin staff can confirm in
/app/affiliate-ops → Healththat postbackPostback 401s (7d) = 0. Anything above zero means your HMAC is wrong. - Send one test order — use the cURL recipe above with a real token from your test creator. Confirm 201.
- Send a replay — send the same order twice; second call should return
ok=true, created=false.
If you see consistent 401s, walk through: secret matches → timestamp within 5min → body byte-identical between sign and send (no JSON re-encoding between hash and POST).
Frequently asked questions
Do I have to use HMAC, or can I send unsigned posts?
HMAC is required. We reject unsigned requests with 401. The signing key is generated when you first enable affiliate on your brand — keep it as a secret env var on your server.
What happens if my server is down when a sale occurs?
Your checkout retries the postback (your code, not ours). We also recommend a daily reconciliation cron that resends any postbacks missed in the last 24h — sample below.
Can I send the same order twice?
Yes, safely. We deduplicate on (brand_id, order_id). A duplicate returns the existing conversion row, not an error.
When do I sign with timestamps?
Always include the X-KF-Timestamp header — it stops captured postbacks from being replayed forever. We allow ±300 seconds of clock skew.