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
Recommended for production. Subscribe to order_paid, relay handler calls /api/tracking/s2s.
Gumroad
Prefer the native Gumroad connection (one click, no relay). This legacy Ping URL relay remains for unconnected sellers.
Rails
ApplicationController concern or background worker. OpenSSL::HMAC for signing.
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/s2sProduction: 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/jsonAuthentication accepts either header (Authorization takes precedence when both are sent):
Authorization: Bearer rk_live_*(recommended) orBearer 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 int=<unix-seconds>,sig=<hex>format. Computed asHMAC-SHA256(signing_secret, "<unix-seconds>.<raw-body-bytes>"). The signing secret is the separaterks_...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, sousdandUSDare 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 fromcustomerEmail.
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_activepaddle_connect_activebraintree_connect_activelemonsqueezy_connect_activechargebee_connect_activepolar_connect_activeshopify_connect_activewix_connect_activerecurly_connect_activegumroad_connect_activecreem_connect_activedodo_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/emptyexternalEventIdaffiliate_slug_required: missing/emptyaffiliateSlugamount_out_of_range:amountCentsis ≤ 0 or > 100,000,000unsupported_currency:currencyis a 3-letter code that is not on the allowlist (body also echoes the offendingcurrency)invalid_body: JSON parse failureno_active_program: the campaign this affiliate belongs to is inactive or suspendedaffiliate_not_found:affiliateSlugdoes not match any link in your orgaffiliate_not_active: the affiliate exists but is not currently approved, so their slug no longer earnsnot_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)
endTracking 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/refundSame 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.
| HTTP | error | Cause |
|---|---|---|
| 400 | invalid_body | JSON parse failure |
| 401 | (empty body) | Missing / invalid bearer / HMAC signature / timestamp out of sync |
| 402 | plan_tier_required | Org below Starter and not trialing |
| 413 | payload_too_large | Request body exceeds 4096 bytes |
| 404 | conversion_not_found | No conversion matches the external event id for this org |
| 422 | external_event_id_required | Missing / empty |
| 422 | refund_amount_invalid | ≤ 0 |
| 422 | already_fully_refunded | The conversion has already been refunded in full |
| 422 | amount_exceeds_original | This 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.