Keepface
Sign up

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 token OR discount_code is required — both work; we resolve token first, fall back to discount_code. This is how brand-side cart codes get attributed.
  • order_id is your idempotency key. Send the same order_id twice → second call is a no-op (returns existing row).
  • gross_amount is in major units (dollars, not cents). 149.00 means $149.
  • currency is a 3-letter ISO code.
  • Everything else is optional but recommended — customer_email, customer_ip, and customer_country power fraud detection and the geo-restriction rules.

Responses

StatusMeaningYour action
201Conversion createdStop retrying
200 + ok=true + created=falseDuplicate (idempotent)Stop retrying
200 + ok=falseBrand not enabled / pausedDon’t retry until you re-enable
401 invalid_signatureHMAC failedCheck secret + timestamp
401 stale_timestampClock driftSync your server clock to NTP
422 rejectedAnti-fraud / restriction triggeredInspect message field

Where to find your secret

  1. Brands → Edit → Affiliate → Tracking tab → Generate secret
  2. The secret is shown ONCE on generation. Copy it into your env var (KF_AFFILIATE_SECRET) immediately.
  3. 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:

  1. Health endpoint — admin staff can confirm in /app/affiliate-ops → Health that postback Postback 401s (7d) = 0. Anything above zero means your HMAC is wrong.
  2. Send one test order — use the cURL recipe above with a real token from your test creator. Confirm 201.
  3. 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.

Was this article helpful?