Rekomi Docs
For brandsInstall tracking
Brands

Install on Django

Server-side S2S is the primary path. templates/base.html head install for click capture, services/rekomi.py for HMAC-signed conversion fires. Use Celery for async if firing from Stripe webhooks.

Django apps have a backend, so server-side S2S is the right conversion path (more reliable than browser pixels under ad blockers). Click capture uses a head script in templates/base.html (which every other page extends). Conversion fires happen in a small rekomi.py services module using Python's standard hmac and hashlib. If you fire from Stripe webhooks under any load, wrap the network call in a Celery (or Dramatiq) task so Stripe doesn't time out at 30 seconds.

Install the head script in templates/base.html

templates/base.html is the parent template every other page extends via {% extends 'base.html' %}. Add the head script here:

{% load static %}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>{% block title %}Your App{% endblock %}</title>

    {% block extra_head %}{% endblock %}

    <!-- Rekomi tracking -->
    <script async src="https://api.rekomi.com/api/v1/r/loader.js" data-program-id="{{ REKOMI_PROGRAM_ID }}"></script>
    <noscript>
      <img src="https://api.rekomi.com/api/v1/r/c.gif" width="1" height="1" alt="" referrerpolicy="no-referrer-when-downgrade" />
    </noscript>
  </head>
  <body>
    {% block content %}{% endblock %}
  </body>
</html>

The {{ REKOMI_PROGRAM_ID }} token is hydrated via a Django context processor (next step). Your program ID is on your campaign's install recipe under Setup > Install in the dashboard.

Context processor for environment-specific config

Add a context processor so {{ REKOMI_PROGRAM_ID }} is available in every template:

# yourapp/context_processors.py
from django.conf import settings

def rekomi(request):
    return {
        "REKOMI_PROGRAM_ID": getattr(settings, "REKOMI_PROGRAM_ID", ""),
    }

Register it in settings.py:

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        # ... other config ...
        "OPTIONS": {
            "context_processors": [
                # ... existing processors ...
                "yourapp.context_processors.rekomi",
            ],
        },
    },
]

# Pull from environment so the same code works across dev/staging/prod
# (add `import os` at the top of settings.py if it isn't there already).
# The program ID is a public identifier; the API key and signing secret are
# server-side only (issued when you create a Rekomi API key).
REKOMI_PROGRAM_ID = os.environ.get("REKOMI_PROGRAM_ID", "")
REKOMI_API_KEY = os.environ.get("REKOMI_API_KEY", "")          # rk_live_...
REKOMI_SIGNING_SECRET = os.environ.get("REKOMI_SIGNING_SECRET", "")  # rks_...

Now every template (including base.html) has access to {{ REKOMI_PROGRAM_ID }}.

Build a rekomi.py service for S2S conversion fires

Create yourapp/services/rekomi.py. The S2S endpoint authenticates with your Bearer API key AND an X-Rekomi-Signature header in t=<unix-seconds>,sig=<hex> format, where the signature is HMAC-SHA256(signing_secret, "<unix-seconds>.<raw-body>"). Sign the exact bytes you send. Body fields are camelCase.

import hashlib
import hmac
import json
import time
from typing import Optional

import requests
from django.conf import settings

ENDPOINT = "https://api.rekomi.com/api/tracking/s2s"
REFUND_ENDPOINT = "https://api.rekomi.com/api/tracking/refund"


def _signed_headers(body: str) -> dict:
    t = int(time.time())
    signature = hmac.new(
        settings.REKOMI_SIGNING_SECRET.encode("utf-8"),
        f"{t}.{body}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {settings.REKOMI_API_KEY}",
        "X-Rekomi-Signature": f"t={t},sig={signature}",
    }


def track_conversion(
    external_event_id: str,
    affiliate_slug: str,
    amount_cents: int,
    currency: str = "USD",
    customer_id: Optional[str] = None,
    customer_email: Optional[str] = None,
):
    payload = {
        "externalEventId": external_event_id,
        "affiliateSlug": affiliate_slug,
        "amountCents": amount_cents,
        "currency": currency,
    }
    if customer_id:
        payload["customerId"] = customer_id
    if customer_email:
        payload["customerEmail"] = customer_email

    body = json.dumps(payload, separators=(",", ":"))
    response = requests.post(
        ENDPOINT,
        data=body,
        headers=_signed_headers(body),
        timeout=10,
    )
    response.raise_for_status()
    return response


def track_refund(external_event_id: str, refund_amount_cents: Optional[int] = None):
    payload = {"externalEventId": external_event_id}
    if refund_amount_cents is not None:  # omit for a full refund
        payload["refundAmountCents"] = refund_amount_cents

    body = json.dumps(payload, separators=(",", ":"))
    return requests.post(
        REFUND_ENDPOINT,
        data=body,
        headers=_signed_headers(body),
        timeout=10,
    )

The webhook below reads the affiliate slug from the Checkout Session's client_reference_id, so stamp it when you create the session. The slug lives in the buyer's browser (window.Rekomi.getReferral()); forward it in the request that starts checkout, then set it server-side:

# yourapp/views.py
# request.POST["rekomi_referral"] is window.Rekomi.getReferral(), sent by your checkout button.
def create_checkout(request):
    session = stripe.checkout.Session.create(
        mode="subscription",
        line_items=[{"price": request.POST["price_id"], "quantity": 1}],
        client_reference_id=request.POST.get("rekomi_referral") or None,
        success_url=SUCCESS_URL,
        cancel_url=CANCEL_URL,
    )
    return JsonResponse({"url": session.url})

Note that client_reference_id is read only by YOUR webhook below. Rekomi's native Stripe integration does not read it; that path uses rekomi_affiliate_slug subscription metadata instead.

Call it from your Stripe webhook view:

# yourapp/views.py
import stripe
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from yourapp.services.rekomi import track_conversion

stripe.api_key = settings.STRIPE_SECRET_KEY

@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except (ValueError, stripe.SignatureVerificationError):
        return HttpResponse(status=400)

    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        if session.get("client_reference_id"):
            track_conversion(
                external_event_id=session["id"],
                affiliate_slug=session["client_reference_id"],
                amount_cents=session["amount_total"],
                currency=session["currency"],
                customer_id=session.get("customer"),
                customer_email=(session.get("customer_details") or {}).get("email"),
            )

    return HttpResponse(status=200)

Async with Celery for production

If your Stripe webhook view does any meaningful work, wrap the track_conversion call in a Celery task so Stripe's 30-second timeout doesn't pressure your stack:

# yourapp/tasks.py
from celery import shared_task
from yourapp.services.rekomi import track_conversion

@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def track_conversion_async(self, **kwargs):
    try:
        # Return something serializable, not the Response object, or the
        # result backend fails to store it and triggers a needless retry.
        resp = track_conversion(**kwargs)
        return resp.status_code
    except Exception as exc:
        raise self.retry(exc=exc)

Then in your view:

from yourapp.tasks import track_conversion_async

# In your webhook handler:
track_conversion_async.delay(
    external_event_id=session["id"],
    affiliate_slug=session["client_reference_id"],
    amount_cents=session["amount_total"],
    currency=session["currency"],
    customer_id=session.get("customer"),
    customer_email=(session.get("customer_details") or {}).get("email"),
)

Stripe gets a 200 immediately; the S2S call happens in the background with retry-on-failure.

CSRF + middleware compatibility

The Rekomi loader script is loaded via a <script src> tag (a plain GET from the browser to Rekomi's domain). It doesn't trip Django's CSRF middleware.

The S2S endpoint is called server-to-server with HMAC, never going through your Django app's CSRF surface.

If you have a strict CSP middleware (e.g., django-csp), allow Rekomi. On django-csp 4.x:

# settings.py (django-csp 4.x)
CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "script-src": ["'self'", "https://api.rekomi.com"],
        "connect-src": ["'self'", "https://api.rekomi.com"],
    }
}

On django-csp 3.x the equivalents are CSP_SCRIPT_SRC and CSP_CONNECT_SRC tuples. Match the form to your installed version: 3.x-style settings are silently ignored on 4.x, which looks exactly like the browser blocking the loader.

Quirks worth knowing

Two secrets, not one. The Bearer API key (rk_live_...) authenticates the request; the separate signing secret (rks_...) signs the body. Both are issued when you create a Rekomi API key. Keep both server-side only.

Sign the exact bytes you POST. Use json.dumps(payload, separators=(",", ":")) once, HMAC that exact string over f"{t}.{body}", and POST the same bytes. Re-serializing between hashing and POSTing is the #1 cause of a 401.

Clock sync matters. The server rejects a signature whose t= timestamp is more than 300 seconds out of sync. Keep your host on NTP.

requests.post blocks the event loop in async views. If you're using Django 4.1+'s async views (async def my_view), use httpx.AsyncClient instead of requests. The signing is identical.

Celery is recommended but optional. If you don't run Celery, the synchronous track_conversion call works fine for low-volume webhooks. Keep timeout=10 so a slow Rekomi response doesn't block Stripe's webhook window.

Django templates auto-escape. The {{ REKOMI_PROGRAM_ID }} token is auto-escaped, which is fine for an alphanumeric program ID.

Refunds

When Stripe fires charge.refunded, pass the SAME external event id you sent when recording the sale (the Checkout Session id in this guide). Rekomi looks up the conversion strictly by that id, so passing charge["id"] returns 404 and the commission is never clawed back. Map the charge back to the original id through your own records:

if event["type"] == "charge.refunded":
    charge = event["data"]["object"]
    from yourapp.services.rekomi import track_refund
    # Resolve the original external event id (the Checkout Session id in
    # this guide) via charge["payment_intent"] in your own DB.
    # Omit refund_amount_cents for a full refund; pass minor units for a partial.
    order = Order.objects.filter(payment_intent_id=charge["payment_intent"]).first()
    if order:
        track_refund(external_event_id=order.checkout_session_id)

Wrap in Celery the same way as the conversion call if you have async infrastructure.

Troubleshooting

{{ REKOMI_PROGRAM_ID }} renders as empty in HTML output. The context processor isn't registered, or the env var isn't set. Check settings.TEMPLATES[0]["OPTIONS"]["context_processors"] and your .env.

S2S call returns 401. Bearer key missing, HMAC mismatch, or clock skew. Confirm REKOMI_API_KEY and REKOMI_SIGNING_SECRET are set, the signature uses the t=<unix>,sig=<hex> format over "<t>.<body>", you're hashing the exact body you POST, and your server clock is within 300 seconds of real time.

S2S call returns 200 with deduped: true. You already recorded a conversion with this externalEventId; the call was safely ignored.

S2S call raises HTTPError with a 400 affiliate_slug_required or affiliate_not_found. The Stripe Session's client_reference_id was empty or stale, meaning the browser didn't carry a valid referral into checkout. Check the head install on the page the affiliate sent traffic to.

Stripe webhook times out at 30 seconds. Your track_conversion call is blocking the response. Wrap in Celery as shown above.

requests.exceptions.SSLError. Your Python environment is missing a CA bundle. Install via pip install certifi or set REQUESTS_CA_BUNDLE.