Keepface
Sign up

API reference — postback, pixel, Shopify webhook

Five public endpoints, two auth modes (HMAC-signed body for postback/refund, origin-allowlisted for pixel, Shopify-style HMAC for Shopify webhook). All idempotent on (brand_id, order_id) so retries are safe.

Endpoint inventory

MethodPathAuthPurpose
GET/api/v2/affiliate/resolve/{token}None (token is the key)CF Worker calls this to resolve a click to a destination URL
POST/api/v2/affiliate/postback/{brand_id}HMAC bodySale event from brand backend
POST/api/v2/affiliate/postback/{brand_id}/refundHMAC bodyRefund event from brand backend
OPTIONS/api/v2/affiliate/pixel/{brand_id}NoneCORS preflight
POST/api/v2/affiliate/pixel/{brand_id}Origin allowlistBrowser-side sale event from JS pixel
POST/api/v2/affiliate/shopify/{brand_id}Shopify HMACShopify orders/refunds/uninstall

{brand_id} is the numeric ID we assigned your brand. Find it in Brands → Edit → URL (last segment).

Common shapes

Sale event 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"],
  "categories":      ["apparel"],
  "brand_confirmed_at": "2026-06-01T14:32:00Z",
  "source_method":   "postback",
  "metadata":        { "any_extra": "your-fields-here" }
}

Field semantics:

FieldTypeRequiredNotes
tokenstring(8)OR discount_codeMixed-case alphanumeric; case-sensitive
discount_codestring ≤64OR tokenCase-insensitive on lookup
order_idstring ≤128yesYour idempotency key. Same id twice → no-op
gross_amountnumber ≥0yesMajor units (149.00 = $149, NOT 14900)
net_amountnumber ≥0noDefaults to gross
currencystring(3)yesISO 4217, uppercase
customer_emailstringnoUsed for fraud + dedupe. Hashed before storage
customer_ipstringnoUsed for fraud + geo. Hashed before storage
customer_countrystring(2)noISO 3166-1 alpha-2. If absent, falls back to IP geo
skusstring[]noUsed for excluded_skus restriction
categoriesstring[]noUsed for excluded_categories restriction
brand_confirmed_atISO 8601noWhen the brand finalized the order. Defaults to now
source_methodenumnopostback
metadataobjectnoFree-form, surfaced in admin drilldown

Refund event body

{
  "order_id":            "shopify-7301421",
  "refund_id":           "shopify-refund-99821",
  "refund_amount":       49.00,
  "currency":            "USD",
  "brand_confirmed_at":  "2026-06-15T10:11:00Z",
  "metadata":            { "reason": "buyer_remorse" }
}

order_id must match an existing conversion. refund_id is optional but recommended — without it, repeated refund posts for the same order create phantom rows.

Auth — HMAC body signing (postback + refund)

Required headers

Content-Type: application/json
X-KF-Signature: <hex(hmac_sha256(secret, "<timestamp>.<body>"))>
X-KF-Timestamp: <unix epoch seconds>

Signing algorithm

signed_payload = timestamp + "." + raw_body
signature = lowercase(hex(HMAC-SHA256(brand_secret, signed_payload)))

We verify with constant-time compare (hash_equals). Don’t roll your own — most TLS / language stdlibs have a constant-time string equality.

Timestamp tolerance

abs(now - timestamp) > 300 seconds → 401 stale. Sync your server clock to NTP.

Secret rotation

When you rotate your secret via Brands → Edit → Affiliate → Tracking → Rotate secret, the previous secret stays valid for 7 days (configurable up to 30) so you can roll your servers without breaking in-flight webhooks. After the overlap window the old secret hard-fails.

Backward compat — unsigned timestamps

Until your brand opts in to hmac_require_timestamp: true in affiliate_config, we ALSO accept body-only signatures (signed payload = raw body, no timestamp prefix). New deployments should set the strict flag.

Auth — pixel endpoint

The pixel endpoint accepts Origin allowlist verification instead of HMAC (the browser can’t sign without exposing the secret to the world).

Configure allowed origins in Brands → Edit → Affiliate → Tracking → Allowed pixel origins. Accepts hostnames (shop.brand.com) and wildcards (*.brand.com).

Origin mismatch → 200 ok:false reason:origin_not_allowed. We don’t 401 because the browser may be following a 302 chain and Origin can drop legitimately.

Auth — Shopify webhook

Shopify signs every webhook with their own HMAC scheme:

  • Header: X-Shopify-Hmac-Sha256: <base64(hmac_sha256(shopify_secret, raw_body))>
  • We verify against the secret stored in affiliate_config.shopify_webhook_secret (configured in Brands → Edit → Affiliate → Tracking → Shopify webhook secret)
  • Set the same secret on the Shopify side at Settings → Notifications → Webhooks

We process orders/create, orders/paid, refunds/create, and app/uninstalled topics.

Response codes

StatusBody shapeWhen
201{data: conversion}New conversion created
200{ok: true, created: false}Idempotent — same order_id already exists
200{ok: false, reason: "unknown_brand"}Brand doesn’t exist. Stops retry storms.
200{ok: false, reason: "affiliate_disabled"}Brand exists, affiliate paused. Stops retries.
200{ok: true, reason: "no_token_or_code"} (Shopify only)Order had no attribution signal. Not retry-worthy.
400{error: "invalid_json"}Body wasn’t valid JSON
401{error: "invalid_signature"}HMAC mismatch
401{error: "stale_timestamp"}Clock skew >5min
412{error: "shopify_not_configured"} (Shopify only)Brand exists but no Shopify secret set
422{error: "rejected", message: "..."}Anti-fraud / restriction triggered
422{error: "token_brand_mismatch"}Token belongs to a different brand than the URL-bound one
429{error: "Too Many Requests"}Rate limit hit. Read Retry-After header.

Retry policy

Your client should retry on 429 (respect Retry-After) and 5xx with exponential backoff (1s, 2s, 4s, 8s, max 5 attempts).

Do NOT retry on 200 / 201 / 401 / 412 / 422 — these are terminal.

Idempotency

EndpointKey
Postback (sale)(brand_id, order_id)
Postback (refund)(brand_id, order_id, refund_id) if refund_id present; otherwise (brand_id, order_id, sha256(refund_amount + currency))
PixelSame as postback sale
Shopify webhookSame — Shopify’s order.id is the order_id

Duplicate calls return the existing row with created: false and HTTP 200.

Rate limits

EndpointLimitWindow
/affiliate/postback/{brand_id}1201 min/IP
/affiliate/postback/{brand_id}/refund1201 min/IP
/affiliate/pixel/{brand_id}2401 min/IP
/affiliate/shopify/{brand_id}1201 min/IP
/affiliate/resolve/{token}6001 min/IP
/track/page (Worker)601 min/IP

Burst exceeded → 429 + Retry-After: <seconds>. The X-RateLimit-Limit and X-RateLimit-Remaining headers are on every response so you can pre-empt.

Need higher per-customer limits? Sprint 4 enterprise plan introduces sandbox + per-brand bucketed limits. Email [email protected] with your expected volume.

Anti-fraud rejections

A conversion may be accepted at ingest but flagged for admin review (status remains pending, fraud_flags array populated). Reasons:

  • self_purchase — buyer email / IP matches the influencer’s
  • velocity_ip — same IP exceeded fraud_rules.velocity_per_ip_hour
  • velocity_influencer — influencer exceeded fraud_rules.velocity_per_influencer_hour
  • geo_deniedcustomer_country not in geo_allow_list
  • sku_excluded — order contains only excluded SKUs

Flagged conversions sit in pending past the holdback window — the auto-approve cron skips rows with any fraud_flag. Admin reviews manually.

Hard rejections (422) at ingest:

  • affiliate_disabled (brand or campaign paused)
  • unknown_brand
  • token_brand_mismatch (token belongs to different brand)

Click resolve endpoint

GET /api/v2/affiliate/resolve/{token}

Called by the CF Worker on every kpfc.link click. Public, edge-cached for 3600s per token.

Response (200):

{
  "token":           "AbCd1234",
  "campaign_id":     123,
  "influencer_id":   456,
  "brand_id":        789,
  "destination_url": "https://brand.com/product/x",
  "cookie_days":     30,
  "campaign_status": "active"
}

Error states (cached briefly to absorb token-enumeration storms):

StatusCacheBody
400300s{error: "invalid_token"} — regex mismatch
40460s{error: "not_found"} — no link with that token
41060s{error: "not_yet_active"} — link’s active_from is in the future
4103600s{error: "expired"} — link’s active_to has passed

Your code should never call this endpoint directly — the CF Worker handles it. Listed here only for ops debugging.

Conversion lifecycle (state machine)

pending ──holdback expires──→ approved ──wallet payout──→ paid
   │                              │
   │  brand opens dispute         │  brand opens dispute (S2.2)
   ↓                              ↓
disputed                      disputed (+ wallet held)
   │                              │
   ├──influencer wins──→ approved │
   │                              │
   └──auto-reject 7d──→ rejected ←┘

                            │  refund event

                  commission clawed back (clawback_minor stamped)

pendingapproved is automatic (cron) when:

  • Holdback window has elapsed
  • No dispute open
  • No fraud_flag set
  • Has not already been refunded past commission

approvedpaid is the wallet payout step (manual finance action; Wise transfer happens off-cycle).

Webhooks for monitoring (your-side)

We don’t currently push outbound events to you. Sprint 4 will add a delivery-log webhook (affiliate.conversion.created, affiliate.refund.processed, affiliate.dispute.opened). Until then, poll:

GET /api/v2/marketplace/company/affiliate/conversions?since=<iso8601>

Auth: workspace sanctum. Returns conversions for brands the workspace owns.

Versioning & deprecation

  • v2 is stable. All current paths are under /api/v2/.
  • v3 timeline: TBA. We’ll announce 6 months before any v3 GA; v2 stays alive for at least 12 months after v3 ships.
  • Schema additions (new optional fields) are NOT breaking — they ship on v2 freely. Subscribe to changelog for these.
  • Field removals / type changes are breaking → v3 only.

Changelog

  • 2026-06-01 — Initial v2 spec. Postback + pixel + Shopify + resolve endpoints stable.
  • 2026-06-01 — Discount-code attribution added (discount_code field on postback + pixel + auto-extracted from Shopify discount_codes[]).
  • 2026-06-01 — HMAC timestamp signing introduced (backward-compat with body-only until brand opts in).
  • 2026-06-01 — Rate limits documented + enforced.
  • 2026-06-01 — Token regex widened to mixed-case base62 (entropy 218T vs prior 2.8T).

Frequently asked questions

Are these endpoints versioned?

Yes — all live under /api/v2/. Breaking changes ship behind /api/v3/ and we keep v2 alive for 12 months minimum after v3 GA.

Do I need to use OAuth?

No. Postback uses HMAC-signed bodies, pixel uses origin allowlists, Shopify uses their own HMAC scheme. There's no OAuth on the affiliate surface.

What's your rate limit?

120/min/IP on postback + Shopify, 240/min on pixel, 600/min on the resolve endpoint, 60/min on /track/page. Burst above these returns 429 with a Retry-After header. Need higher? Email [email protected].

Was this article helpful?