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
| Method | Path | Auth | Purpose |
|---|---|---|---|
| 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 body | Sale event from brand backend |
| POST | /api/v2/affiliate/postback/{brand_id}/refund | HMAC body | Refund event from brand backend |
| OPTIONS | /api/v2/affiliate/pixel/{brand_id} | None | CORS preflight |
| POST | /api/v2/affiliate/pixel/{brand_id} | Origin allowlist | Browser-side sale event from JS pixel |
| POST | /api/v2/affiliate/shopify/{brand_id} | Shopify HMAC | Shopify 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:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string(8) | OR discount_code | Mixed-case alphanumeric; case-sensitive |
discount_code | string ≤64 | OR token | Case-insensitive on lookup |
order_id | string ≤128 | yes | Your idempotency key. Same id twice → no-op |
gross_amount | number ≥0 | yes | Major units (149.00 = $149, NOT 14900) |
net_amount | number ≥0 | no | Defaults to gross |
currency | string(3) | yes | ISO 4217, uppercase |
customer_email | string | no | Used for fraud + dedupe. Hashed before storage |
customer_ip | string | no | Used for fraud + geo. Hashed before storage |
customer_country | string(2) | no | ISO 3166-1 alpha-2. If absent, falls back to IP geo |
skus | string[] | no | Used for excluded_skus restriction |
categories | string[] | no | Used for excluded_categories restriction |
brand_confirmed_at | ISO 8601 | no | When the brand finalized the order. Defaults to now |
source_method | enum | no | postback |
metadata | object | no | Free-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
| Status | Body shape | When |
|---|---|---|
| 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
| Endpoint | Key |
|---|---|
| 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)) |
| Pixel | Same as postback sale |
| Shopify webhook | Same — Shopify’s order.id is the order_id |
Duplicate calls return the existing row with created: false and HTTP 200.
Rate limits
| Endpoint | Limit | Window |
|---|---|---|
/affiliate/postback/{brand_id} | 120 | 1 min/IP |
/affiliate/postback/{brand_id}/refund | 120 | 1 min/IP |
/affiliate/pixel/{brand_id} | 240 | 1 min/IP |
/affiliate/shopify/{brand_id} | 120 | 1 min/IP |
/affiliate/resolve/{token} | 600 | 1 min/IP |
/track/page (Worker) | 60 | 1 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’svelocity_ip— same IP exceededfraud_rules.velocity_per_ip_hourvelocity_influencer— influencer exceededfraud_rules.velocity_per_influencer_hourgeo_denied—customer_countrynot ingeo_allow_listsku_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_brandtoken_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):
| Status | Cache | Body |
|---|---|---|
| 400 | 300s | {error: "invalid_token"} — regex mismatch |
| 404 | 60s | {error: "not_found"} — no link with that token |
| 410 | 60s | {error: "not_yet_active"} — link’s active_from is in the future |
| 410 | 3600s | {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)
pending → approved is automatic (cron) when:
- Holdback window has elapsed
- No dispute open
- No fraud_flag set
- Has not already been refunded past commission
approved → paid 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_codefield on postback + pixel + auto-extracted from Shopifydiscount_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].