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 "<timestamp>.<body>" with HMAC-SHA256 using your brand secret, send it as X-KF-Signature plus X-KF-Timestamp. 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": "buyer@example.com",
"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 |
Set up in your Keepface panel
The panel side is fully self-serve; no support ticket needed.
1. Open the channel
Company → Brands → Edit brand → Affiliate tab → scroll to “Server-to-server postback” → toggle ON.
The endpoint URL (with your numeric brand id baked in) appears at the top of the block, plus a matching refund endpoint below it. Copy either straight into your backend.
2. Mint the signing secret
Inside the same block, click 🔑 Generate on the “Postback signing secret” card.
The secret is a 96-character hex string, shown exactly once. Copy it immediately into your server’s env vars (KF_AFFILIATE_SECRET). If you dismiss the reveal without saving, no drama, just click 🔑 Rotate and you get a fresh one.
3. Verify
After you send your first signed test post, the panel’s Verify button flips the channel status from PENDING to VERIFIED. If it stays PENDING, check:
- Shared secret matches (case-sensitive; no leading / trailing whitespace).
- Server clock is within 300s of real time (NTP sync your VM).
- Body is byte-identical between the hash step and the POST step (don’t re-serialize JSON after signing).
4. Rotate whenever
Click 🔑 Rotate and the previous secret stays valid for 7 days so you can deploy the new value across your fleet without a flag-day cutover. After the overlap expires the old secret 401s.
Never commit the secret to git. If you lose it, rotate; the previous value 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? Skip the code, use webhooks
If you’re on Shopify, don’t build the signer yourself. Shopify signs each webhook with their own X-Shopify-Hmac-Sha256 header using a shared secret; we validate it directly. No theme edits, no signer to write, refunds and app-uninstall handled natively.
See the dedicated guide: Shopify webhook integration.
Endpoint (baked into the panel for you):
POST https://api.keepface.ai/api/v2/affiliate/shopify/{brand_id}
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.