RekomiRekomi
DemoPricingFor creators
DashboardSign inLaunch your program
Launch your program
Rekomi Docs
Rekomi Docs
Welcome to Rekomi
HomeQuickstart for brandsPlans and trialsIntegrationsStripe Connect (sales tracking)Organization settingsTeam managementNotifications
Install tracking

Payment gateways

Payment gatewaysInstall on StripeThe Rekomi app for StripeInstall on PaddleInstall on BraintreeInstall on ShopifyInstall on WixInstall on Lemon SqueezyInstall on ChargebeeInstall on PolarInstall on RecurlyInstall on GumroadInstall on CreemInstall on Dodo PaymentsS2S API (any gateway)

Membership and course platforms

Install on MemberstackInstall on OutsetaInstall on PodiaInstall on TeachableInstall on Thinkific

Newsletter and creator platforms

Install on GhostInstall on beehiivInstall on Kit (ConvertKit)

Site builders

Install on WordPressInstall on WebflowInstall on FramerInstall on SquarespaceInstall on BubbleInstall on Carrd

Frameworks

Install on Vanilla JavaScriptInstall on Next.js App RouterInstall on Next.js Pages RouterInstall on React (Vite, CRA, Remix)Install on VueInstall on RailsInstall on Django

Campaigns and commissions

CampaignsCommission modelsPay per click or lead (CPC & CPL)Coupon-code attributionProducts and per-product ratesCreative assetsRecruiting (sub-affiliates)Tracking and attributionTest your integration

Affiliates

Recruit affiliatesManage affiliatesAI co-pilotApply to the curated networkBooking creators with deals

Money flow

SalesCustomersPayoutsMulti-currencyTax formsReports

Email

Sending domainEmailsBroadcasts
Quickstart for affiliatesYour public pageThe page builderBrand dealsDeals and getting paidMessagesBuild your creator profileBrowse and apply to campaignsApply directly via a brand's public pageAffiliate dashboard tourYour tracking linkProduct & page linksUsing creative assetsEarnings and performanceConnect Stripe for payoutsYour tax formsGetting paidFeesSupported countriesPromote Rekomi and earn
API overviewAuthenticationOAuth 2.0Server-to-server trackingTracking script & window.RekomiTrack leads and signupsNo-code & non-Stripe checkoutsCustom domainConversion currencyCoupon code trackingSub-affiliate recruiting APIWebhooksZapierWhite-label embedMCP serverAPI reference
For brandsInstall tracking
Developers

Server-to-server tracking

HMAC-signed conversion ingest for any payment gateway beyond the native Stripe, Paddle, Braintree, Shopify, Wix, Lemon Squeezy, Chargebee, Polar, Recurly, Gumroad, Creem, and Dodo Payments connections.

Use the S2S tracking endpoint to record a conversion from any payment gateway, or from any conversion that never flows through a connected processor at all. Stripe, Paddle, Braintree, Shopify, Wix, Lemon Squeezy, Chargebee, Polar, Recurly, Gumroad, Creem, and Dodo Payments connect natively, so no relay is needed for those. For everything else, including mobile in-app purchases, server-side trial conversions, and custom event sources, this is the path: if you can POST an HMAC-signed request from your backend, Rekomi records the conversion. Rekomi treats S2S conversions the same as natively-tracked ones: attribution, refund handling, payouts.

Plan tier required: Starter or higher. Trialing orgs (14-day free trial) are always accepted. An org whose plan tier is below Starter and is not trialing returns 402 with { "error": "plan_tier_required", "required": "Starter", "current": "<tier>", ... }.

Works with any payment gateway

S2S is the universal path: if your backend can POST an HMAC-signed request, Rekomi records the conversion, no matter which payment gateway sits behind it (or none at all). It is deliberately not tied to a list of supported platforms, so anything that is not natively connected works here, including custom checkouts, mobile in-app purchases, and server-side trial conversions.

Already on a natively-supported gateway? Stripe, Paddle, Braintree, Shopify, Wix, Lemon Squeezy, Chargebee, Polar, Recurly, Gumroad, Creem, and Dodo Payments connect directly in a few clicks with no relay to build. If you use one of those, prefer the native integration; reach for S2S for every other gateway.

The platforms below just ship with ready-made, step-by-step S2S walkthroughs in the Setup checklist. They are convenience guides, not the limit; any backend works (see below):

Shopify logo

Shopify

Recommended for production. Subscribe to order_paid, relay handler calls /api/tracking/s2s.

Gumroad logo

Gumroad

Prefer the native Gumroad connection (one click, no relay). This legacy Ping URL relay remains for unconnected sellers.

Rails logo

Rails

ApplicationController concern or background worker. OpenSSL::HMAC for signing.

Django logo

Django

View or Celery task. hmac.new(secret, ...).hexdigest() for signing.

Any backend works. S2S is just an HTTPS POST with HMAC-SHA256 signing. The Node.js example below ports 1:1 to Python, Ruby, Go, PHP, .NET, Elixir, or anything that can make HTTP requests and compute HMAC. Use this path whenever you can keep a secret server-side.

Endpoint

POST /api/tracking/s2s

Production: https://api.rekomi.com/api/tracking/s2s Staging: https://rekomi-api-staging-owaet.ondigitalocean.app/api/tracking/s2s

Headers

Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxxx
X-Rekomi-Signature: t=1715366423,sig=8f4e2c5b...
Content-Type: application/json

Authentication accepts either header (Authorization takes precedence when both are sent):

  • Authorization: Bearer rk_live_* (recommended) or Bearer rk_test_*: your bearer API key.
  • X-Rekomi-Api-Key: rk_live_*: legacy header. Same key, same semantics.

Plus:

  • X-Rekomi-Signature (required): HMAC-SHA256 signature in t=<unix-seconds>,sig=<hex> format. Computed as HMAC-SHA256(signing_secret, "<unix-seconds>.<raw-body-bytes>"). The signing secret is the separate rks_... value you got when creating the API key, not the bearer.

Request body

{
  "externalEventId": "purchase_abc123",
  "affiliateSlug": "jane-recommends",
  "amountCents": 9900,
  "currency": "USD",
  "customerId": "cus_external_xyz"
}
  • externalEventId (required): your unique identifier for this conversion. Used for de-duplication.
  • affiliateSlug (required): the affiliate to credit. From the affiliate's tracking URL.
  • amountCents (required): the conversion amount in minor currency units. Positive integer. Max 100,000,000 cents ($1M cap).
  • currency (optional): ISO 4217 code, exactly 3 letters. Defaults to "USD". Normalized to uppercase server-side, so usd and USD are equivalent.
  • customerId (optional): your customer identifier. Useful for joining back to your own database.
  • customerName (optional): the customer's display name, shown on the conversion row in the dashboard.
  • customerEmail (optional): the customer's email.
  • customerAvatarUrl (optional): an https avatar image URL; when omitted, an avatar is derived from customerEmail.

Currency

The currency field accepts a 31-code ISO 4217 allowlist (USD, EUR, GBP, JPY and 27 others), matched case-insensitively (usd and USD are equivalent). A well-formed 3-letter code that is not on the allowlist returns 400 unsupported_currency. A value that is not exactly 3 characters long is treated as absent and falls back to the USD default; any 3-character value that is not on the allowlist returns 400 unsupported_currency. Send a real ISO code to avoid silently logging the wrong currency. See Conversion currency for the full list, error response shapes, and how the org's display home currency is set.

Response (success)

Two success shapes: fresh conversion vs. duplicate (replay-safe).

Fresh:

{ "ok": true, "conversionId": "c0nv0001-..." }

Duplicate (the same externalEventId was already seen for this org):

{ "ok": true, "deduped": true }

The deduped shape does not echo a conversionId; the original conversion still owns the event. Treat both as success on the caller side. Retries are safe indefinitely.

Response (error)

Error responses use one of four shapes depending on what failed:

401 Unauthorized: empty body. Triggered by:

  • missing or invalid bearer key
  • missing, malformed, or wrong HMAC signature
  • timestamp more than 300 seconds out of sync

409 Conflict: your organization already tracks conversions automatically through a native connection, so S2S ingest is disabled to prevent the same sale being counted twice. JSON body with an error code and a human-readable message. There is one code for each of the twelve native connections:

  • stripe_connect_active
  • paddle_connect_active
  • braintree_connect_active
  • lemonsqueezy_connect_active
  • chargebee_connect_active
  • polar_connect_active
  • shopify_connect_active
  • wix_connect_active
  • recurly_connect_active
  • gumroad_connect_active
  • creem_connect_active
  • dodo_connect_active

Disconnect the named gateway from its page under the Connect payment gateway step (for Shopify, uninstall the Rekomi app; for Wix, uninstall the Rekomi app from the Wix dashboard) if you genuinely want to switch to S2S. The same conflicts apply to POST /api/tracking/lead (see below).

402 Plan tier required: JSON body with an upgrade prompt:

{
  "error": "plan_tier_required",
  "required": "Starter",
  "current": "None",
  "trialing": false,
  "message": "S2S tracking requires Starter tier or higher. Upgrade in Settings > Billing."
}

400 Bad Request: JSON body with just an error code:

{ "error": "amount_out_of_range" }

The full list of 400 codes:

  • external_event_id_required: missing/empty externalEventId
  • affiliate_slug_required: missing/empty affiliateSlug
  • amount_out_of_range: amountCents is ≤ 0 or > 100,000,000
  • unsupported_currency: currency is a 3-letter code that is not on the allowlist (body also echoes the offending currency)
  • invalid_body: JSON parse failure
  • no_active_program: the campaign this affiliate belongs to is inactive or suspended
  • affiliate_not_found: affiliateSlug does not match any link in your org
  • affiliate_not_active: the affiliate exists but is not currently approved, so their slug no longer earns
  • not_a_sale_campaign: the affiliate's campaign rewards clicks or leads, not sales, so a sale postback is rejected

Signature computation

The signature is HMAC-SHA256 of the string {unix-seconds}.{raw-body-bytes} using your plaintext signing secret as the key. Hex-encode the result.

import crypto from "node:crypto";

function sign(body: string, signingSecret: string): string {
  const t = Math.floor(Date.now() / 1000);
  const payload = `${t}.${body}`;
  const sig = crypto.createHmac("sha256", signingSecret).update(payload).digest("hex");
  return `t=${t},sig=${sig}`;
}

Sign the exact bytes you send. The server HMACs the raw request body it receives, so you must sign and send the identical byte array. The #1 cause of a 401 on an otherwise-correct request is serializing the body twice (signing one JSON string, sending another whose whitespace or key casing differs). Serialize once to bytes, sign those bytes, send those bytes.

For example, in .NET: var body = JsonSerializer.SerializeToUtf8Bytes(payload); then HMAC over "{t}." + body and new ByteArrayContent(body), never a second JsonSerializer.Serialize(payload) call for the request content.

Replay protection

The server rejects signatures with t= more than 300 seconds (5 minutes) old, or more than 300 seconds in the future. Keep your server's clock synced via NTP.

The server also rejects duplicate (externalEventId, organization) pairs. The second call with the same external event id returns the original conversion with deduped = true. Safe to retry indefinitely.

Examples

The same request in four languages; every other stack follows the identical shape (serialize once, HMAC those exact bytes, send those bytes).

import crypto from "node:crypto";

const BEARER = process.env.REKOMI_API_KEY!;        // rk_live_...
const SIGNING = process.env.REKOMI_SIGNING_SECRET!; // rks_...

async function logConversion(input: {
  externalEventId: string;
  affiliateSlug: string;
  amountCents: number;
  currency?: string;
  customerId?: string;
}) {
  const body = JSON.stringify(input);
  const t = Math.floor(Date.now() / 1000);
  const sig = crypto.createHmac("sha256", SIGNING).update(`${t}.${body}`).digest("hex");
  const res = await fetch("https://api.rekomi.com/api/tracking/s2s", {
    method: "POST",
    headers: {
      "X-Rekomi-Api-Key": BEARER,
      "X-Rekomi-Signature": `t=${t},sig=${sig}`,
      "Content-Type": "application/json",
    },
    body,
  });
  if (!res.ok) throw new Error(`Rekomi S2S failed: ${res.status} ${await res.text()}`);
  return await res.json();
}
import hmac, hashlib, time, json, requests

BEARER = "rk_live_..."
SIGNING = "rks_..."

def log_conversion(external_event_id, affiliate_slug, amount_cents, currency="USD"):
    body = json.dumps({
        "externalEventId": external_event_id,
        "affiliateSlug": affiliate_slug,
        "amountCents": amount_cents,
        "currency": currency,
    }, separators=(",", ":"))
    t = int(time.time())
    sig = hmac.new(SIGNING.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
    r = requests.post("https://api.rekomi.com/api/tracking/s2s", data=body, headers={
        "X-Rekomi-Api-Key": BEARER,
        "X-Rekomi-Signature": f"t={t},sig={sig}",
        "Content-Type": "application/json",
    })
    r.raise_for_status()
    return r.json()
<?php
// Works in plain PHP, Laravel, WordPress plugins, anywhere with curl.
$bearer  = getenv('REKOMI_API_KEY');        // rk_live_...
$signing = getenv('REKOMI_SIGNING_SECRET'); // rks_...

function logConversion(string $externalEventId, string $affiliateSlug, int $amountCents, string $currency = 'USD'): array
{
    global $bearer, $signing;
    // Serialize ONCE; the same bytes are signed and sent.
    $body = json_encode([
        'externalEventId' => $externalEventId,
        'affiliateSlug'   => $affiliateSlug,
        'amountCents'     => $amountCents,
        'currency'        => $currency,
    ], JSON_UNESCAPED_SLASHES);
    $t   = time();
    $sig = hash_hmac('sha256', $t . '.' . $body, $signing);

    $ch = curl_init('https://api.rekomi.com/api/tracking/s2s');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'X-Rekomi-Api-Key: ' . $bearer,
            'X-Rekomi-Signature: t=' . $t . ',sig=' . $sig,
            'Content-Type: application/json',
        ],
    ]);
    $res    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new RuntimeException("Rekomi S2S failed: {$status} {$res}");
    }
    return json_decode($res, true);
}
require "net/http"
require "openssl"
require "json"

BEARER  = ENV.fetch("REKOMI_API_KEY")        # rk_live_...
SIGNING = ENV.fetch("REKOMI_SIGNING_SECRET") # rks_...

def log_conversion(external_event_id:, affiliate_slug:, amount_cents:, currency: "USD")
  body = JSON.generate({
    externalEventId: external_event_id,
    affiliateSlug: affiliate_slug,
    amountCents: amount_cents,
    currency: currency,
  })
  t   = Time.now.to_i
  sig = OpenSSL::HMAC.hexdigest("SHA256", SIGNING, "#{t}.#{body}")

  uri = URI("https://api.rekomi.com/api/tracking/s2s")
  res = Net::HTTP.post(uri, body, {
    "X-Rekomi-Api-Key" => BEARER,
    "X-Rekomi-Signature" => "t=#{t},sig=#{sig}",
    "Content-Type" => "application/json",
  })
  raise "Rekomi S2S failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
  JSON.parse(res.body)
end

Tracking leads (signups)

For Stripe and Paddle, leads are captured automatically, with no extra code, so you usually do not need this endpoint there. Use it when your gateway is not natively connected and you drive tracking from your backend: post a free signup (not a sale) to POST /api/tracking/lead with the same Bearer API key + X-Rekomi-Signature HMAC auth shown above. Note the mutual-exclusivity conflicts above apply here too: while a native connection (Stripe, Paddle, Braintree, Lemon Squeezy, Chargebee, Polar, Recurly, Gumroad, Creem, Dodo Payments, the Shopify app, or the Wix app) is active, the lead endpoint returns the matching *_connect_active 409, and the browser Rekomi.convert() call is the lead path instead. A lead ties a customer email to the referral before payment, so a later sale is credited even if the click cookie is gone. See Track leads and signups for the body shape, the browser Rekomi.convert() equivalent, and how the email-match fallback works.

When to use S2S vs the edge redirect

  • S2S when conversions originate server-side (mobile app purchases, custom checkout flows, server-side trial conversions).
  • Edge redirect (the /r/{slug} tracking link with first-party cookie) when conversions originate from a browser click on a referral link.
  • Coupon codes when you cannot run any server-side code; give each affiliate a unique discount code and attribution happens at checkout with nothing on your site.

For most subscription products, S2S is the more reliable path because it does not depend on the browser cookie surviving across the entire conversion funnel.

Refunding conversions

S2S conversions are refunded through POST /api/tracking/refund; conversions from a natively connected processor are refunded automatically by that processor's refund webhook and do not need this endpoint.

POST /api/tracking/refund

Same HMAC + bearer auth model as the S2S endpoint above. Body:

{
  "externalEventId": "purchase_abc123",
  "refundAmountCents": 9900
}
  • externalEventId (required): the original conversion's external event id (camelCase, same convention as the S2S body).
  • refundAmountCents (optional): partial refund in minor units. Omit to refund the full original amount (valid only when no partial refund has been recorded yet); after partial refunds, send the exact remaining amount.

Response (full refund):

{
  "ok": true,
  "conversionId": "c0nv0001-...",
  "refundedAmountCents": 9900,
  "cumulativeRefundedAmountCents": 9900,
  "isFullRefund": true
}

Partial refunds accumulate: subsequent refunds add to cumulativeRefundedAmountCents and only flip the conversion to status Refunded once the cumulative total reaches the original amount. Commission reversal is proportional; refunding 40% of the order amount reverses 40% of the commission.

Error responses

This endpoint is also reachable at the alias route POST /api/v1/tracking/refund with identical behavior.

HTTPerrorCause
400invalid_bodyJSON parse failure
401(empty body)Missing / invalid bearer / HMAC signature / timestamp out of sync
402plan_tier_requiredOrg below Starter and not trialing
413payload_too_largeRequest body exceeds 4096 bytes
404conversion_not_foundNo conversion matches the external event id for this org
422external_event_id_requiredMissing / empty
422refund_amount_invalid≤ 0
422already_fully_refundedThe conversion has already been refunded in full
422amount_exceeds_originalThis refund would push cumulative refunded past the conversion amount

Signature

Identical to the S2S signing computation. Reuse the same HMAC helper.

const body = JSON.stringify({
  externalEventId: "purchase_abc123",
  refundAmountCents: 9900,
});
const t = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac("sha256", SIGNING).update(`${t}.${body}`).digest("hex");

await fetch("https://api.rekomi.com/api/tracking/refund", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${BEARER}`,
    "X-Rekomi-Signature": `t=${t},sig=${sig}`,
    "Content-Type": "application/json",
  },
  body,
});

Idempotency and replays

Refund replays for an already-fully-refunded conversion return 422 already_fully_refunded rather than re-decrementing commission. Partial-refund overflows are clamped: if refundAmountCents plus prior refunds would exceed the conversion amount, the request fails with 422 amount_exceeds_original. Always send refundAmountCents equal to the actual refund issued by your processor.

Install on Dodo Payments

Connect Dodo Payments natively: paste one API key with write access enabled and Rekomi creates the webhook endpoint at Dodo itself, so payments, subscription renewals, refunds, and disputes are tracked automatically with nothing to paste back. Carry rekomi_ref in checkout metadata so each sale credits the right affiliate.

Install on Memberstack

Memberstack is an overlay, so Rekomi installs on the host (Webflow, custom HTML, Duda), not in Memberstack itself. Sales record through your own Stripe account via Stripe Connect, coupon codes, or a server-to-server relay.

On this page

Works with any payment gatewayEndpointHeadersRequest bodyCurrencyResponse (success)Response (error)Signature computationReplay protectionExamplesTracking leads (signups)When to use S2S vs the edge redirectRefunding conversionsError responsesSignatureIdempotency and replays