OVERVIEW
How it works
The Worker sits in front of your site and does two jobs. Most requests pass straight through to your site untouched.
Creator
Shares a link to your site
For example
https://example.com/?trendly=NOURA10.Worker
Sets a first-party cookie, trendly_ref
It holds the creator code for 30 days. The page itself is served as usual.
Visitor
Converts
Buys, subscribes or signs up, today or weeks later.
Your backend
POSTs the conversion to the Worker at /trendly/conversion
With the
X-Relay-Secretheader and the code from the cookie, or the coupon code the customer typed.Worker
Reports it to Trendly
Calls
POST /v1/orderswith your Trendly key, retrying if Trendly or the network hiccups.Trendly
Credits the creator
Matches the code, works out the commission, holds it through your refund window, then pays it out.
Because the Worker reports to the Sales API, every rule there applies: orders are matched on your order_id, test keys store test orders, and every call shows up in the request log.
BEFORE YOU START
What you need
- A Cloudflare account with your site on it, proxied through Cloudflare (the orange cloud in DNS). A Worker route only runs on proxied traffic.
- Node.js on your machine, to run
npx wrangler. - A Trendly login for your brand, to create keys and see the orders.
- A backend that knows when a conversion happens: an order, a paid subscription, a sign-up you pay creators for.
SETUP
Set it up
Step 1: Create a test key
In the dashboard, create a test key. It starts with
trk_test_and is shown once, so keep it open for step 3.Step 2: Make the Worker project
Make a folder and put two files in it: worker.js and wrangler.toml, both below with Copy and Download. In
wrangler.toml, replaceexample.comwith your domain inpatternandzone_name.Terminal
mkdir trendly-relay cd trendly-relay # save worker.js and wrangler.toml here npx wrangler loginStarting from
npm create cloudflare@latestornpx wrangler initworks too: use the Hello World Worker, then replace its code with worker.js and its config with wrangler.toml. Already have a Workers project? Add worker.js and copy theroutesand[vars]into your config.Step 3: Add the two secrets
Paste your test key when asked for
TRENDLY_API_KEY. ForRELAY_SECRET, use a long random string; your backend will send it on every call to the Worker.Terminal
openssl rand -hex 32 # copy this for RELAY_SECRET npx wrangler secret put TRENDLY_API_KEY npx wrangler secret put RELAY_SECRETIf Wrangler says the Worker does not exist yet, answer yes to create it.
Step 4: Deploy
Terminal
npx wrangler deployCheck it with the health check. It should answer with your program and
"environment": "test".Step 5: Send a test conversion
From any machine, with your relay secret in
$RELAY_SECRET:Terminal
curl -X POST https://example.com/trendly/conversion \ -H "X-Relay-Secret: $RELAY_SECRET" \ -H "Content-Type: application/json" \ -d '{ "order_id": "SUB-20931", "status": "confirmed", "total_amount": "149.00", "currency": "SAR", "code": "NOURA10", "placed_at": "2026-11-10T14:05:00+03:00" }'The answer is Trendly's. Then open the request log in Developers to see the call, and the program's Orders to see
SUB-20931as a test order. Use a real creator code from your program forcodeto see it attributed.Step 6: Switch to a live key
Create a live key (
trk_live_) and replace the secret. Nothing else changes.Terminal
npx wrangler secret put TRENDLY_API_KEY
CODE
worker.js
The whole Worker, with no dependencies. Read it before you deploy it; it is short.
worker.js
// Trendly conversion relay for Cloudflare Workers. Version 1.0.
// Docs: https://trendly.com.sa/developers/cloudflare
//
// What it does
// 1. Remembers the creator. A visit with ?trendly=CODE sets a first-party cookie
// "trendly_ref" for COOKIE_DAYS days (30 by default), then serves the page as usual.
// 2. Forwards conversions. Your backend sends POST /trendly/conversion with the
// header "X-Relay-Secret: <RELAY_SECRET>" and the order as JSON. The Worker reports it to
// Trendly and answers with what Trendly recorded.
//
// Secrets (wrangler secret put NAME)
// TRENDLY_API_KEY Your Trendly key, trk_test_... while testing, trk_live_... in production.
// RELAY_SECRET Any long random string. Your backend sends it in X-Relay-Secret.
//
// Variables (wrangler.toml [vars])
// TRENDLY_API_BASE https://api.trendly.com.sa
// TRENDLY_PROGRAM (optional) your affiliate program id
// COOKIE_DAYS 30
const LINK_PARAM = 'trendly';
const COOKIE = 'trendly_ref';
const CONVERSION_PATH = '/trendly/conversion';
const HEALTH_PATH = '/trendly/health';
const STATUSES = ['pending', 'confirmed', 'cancelled', 'refunded', 'partially_refunded', 'fraud'];
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === CONVERSION_PATH) return conversion(request, env);
if (url.pathname === HEALTH_PATH) return health(request, env);
return remember(request, env, url);
},
};
// 1. Remember the creator whose link brought the visitor in.
async function remember(request, env, url) {
const response = await fetch(request);
const code = cleanCode(url.searchParams.get(LINK_PARAM));
if (!code) return response;
const days = Number(env.COOKIE_DAYS) > 0 ? Number(env.COOKIE_DAYS) : 30;
const withCookie = new Response(response.body, response);
withCookie.headers.append(
'Set-Cookie',
COOKIE + '=' + encodeURIComponent(code) + '; Max-Age=' + days * 86400 + '; Path=/; Secure; SameSite=Lax',
);
return withCookie;
}
// 2. Forward one conversion to Trendly.
async function conversion(request, env) {
if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405);
if (!env.TRENDLY_API_KEY || !env.RELAY_SECRET) return json({ error: 'worker_not_configured' }, 500);
if (!sameSecret(request.headers.get('X-Relay-Secret'), env.RELAY_SECRET)) return json({ error: 'unauthorized' }, 401);
let body;
try {
body = await request.json();
} catch (e) {
return json({ error: 'invalid_json' }, 400);
}
const order = toTrendlyOrder(body, request, env);
if (!order.order_id) return json({ error: 'order_id_required' }, 400);
if (!STATUSES.includes(order.status)) return json({ error: 'unknown_status', allowed: STATUSES }, 400);
if (!order.total_amount) return json({ error: 'total_amount_required' }, 400);
const result = await sendToTrendly(env, '/v1/orders', order);
return json(result.body, result.status);
}
// Checks the key and the connection, for your monitoring. Needs the relay secret too.
async function health(request, env) {
if (!sameSecret(request.headers.get('X-Relay-Secret'), env.RELAY_SECRET)) return json({ error: 'unauthorized' }, 401);
const result = await sendToTrendly(env, '/v1/ping');
return json(result.body, result.status);
}
// Your fields in, Trendly's order out. Amounts are sent as strings, never as floats.
function toTrendlyOrder(body, request, env) {
const total = money(body.total_amount ?? body.total ?? body.amount);
const order = {
order_id: str(body.order_id ?? body.id),
order_number: str(body.order_number),
status: str(body.status || 'confirmed').toLowerCase(),
discount_code: cleanCode(body.code ?? body.coupon ?? body.discount_code) || cookieCode(body.cookie || request.headers.get('Cookie')),
currency: str(body.currency).toUpperCase() || undefined,
subtotal_amount: money(body.subtotal_amount ?? body.subtotal) || total,
discount_amount: money(body.discount_amount),
tax_amount: money(body.tax_amount ?? body.vat),
shipping_amount: money(body.shipping_amount),
total_amount: total,
refunded_amount: money(body.refunded_amount),
commissionable_amount: money(body.commissionable_amount),
is_new_customer: typeof body.is_new_customer === 'boolean' ? body.is_new_customer : undefined,
customer_ref: str(body.customer_ref) || undefined,
placed_at: str(body.placed_at) || undefined,
status_changed_at: str(body.status_changed_at) || undefined,
metadata: { ...(body.metadata || {}), relay: 'cloudflare-worker/1.0' },
};
if (env.TRENDLY_PROGRAM && !String(env.TRENDLY_PROGRAM).startsWith('(')) order.program_id = env.TRENDLY_PROGRAM;
Object.keys(order).forEach((key) => (order[key] === undefined || order[key] === '') && delete order[key]);
return order;
}
// Three tries with a growing wait, for 429s, 5xx and network errors. The same order sent twice
// lands on one row at Trendly (orders are matched on order_id), so a retry is always safe.
async function sendToTrendly(env, path, payload) {
const base = String(env.TRENDLY_API_BASE || 'https://api.trendly.com.sa').replace(/\/+$/, '');
let last = { status: 502, body: { error: 'trendly_unreachable' } };
for (let attempt = 0; attempt < 3; attempt += 1) {
if (attempt > 0) await sleep(400 * 2 ** attempt);
try {
const response = await fetch(base + path, {
method: payload ? 'POST' : 'GET',
headers: {
Authorization: 'Bearer ' + env.TRENDLY_API_KEY,
'Content-Type': 'application/json',
'User-Agent': 'trendly-cloudflare-worker/1.0',
},
body: payload ? JSON.stringify(payload) : undefined,
});
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : {};
} catch (e) {
parsed = { error: 'unexpected_response', body: text.slice(0, 300) };
}
last = { status: response.status, body: parsed };
if (response.status !== 429 && response.status < 500) return last;
} catch (e) {
last = { status: 502, body: { error: 'trendly_unreachable', message: String(e && e.message) } };
}
}
return last;
}
function cookieCode(cookieHeader) {
const match = String(cookieHeader || '').match(new RegExp('(?:^|;\\s*)' + COOKIE + '=([^;]+)'));
return match ? cleanCode(decodeURIComponent(match[1])) : '';
}
function cleanCode(value) {
const code = String(value || '').trim();
return /^[A-Za-z0-9_-]{2,64}$/.test(code) ? code : '';
}
function money(value) {
if (value === undefined || value === null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(2) : undefined;
}
function str(value) {
return value === undefined || value === null ? '' : String(value).trim();
}
function sameSecret(given, expected) {
if (!given || !expected || given.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < given.length; i += 1) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i);
return diff === 0;
}
function json(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
CONFIG
wrangler.toml
Replace example.com with your domain. If your site is served on www, the pattern is www.example.com/* and zone_name stays example.com.
wrangler.toml
name = "trendly-relay"
main = "worker.js"
compatibility_date = "2025-01-01"
# The Worker runs on your site so it can remember which creator's link a visitor used.
# zone_name is the domain as it appears in your Cloudflare dashboard.
routes = [{ pattern = "example.com/*", zone_name = "example.com" }]
[vars]
TRENDLY_API_BASE = "https://api.trendly.com.sa"
# TRENDLY_PROGRAM = "your program id, when your key is not pinned to one program"
COOKIE_DAYS = "30"
# Secrets, set once with:
# npx wrangler secret put TRENDLY_API_KEY
# npx wrangler secret put RELAY_SECRET
| SETTING | WHAT IT IS |
|---|---|
TRENDLY_API_KEY | Secret. Your Trendly key: trk_test_ while testing, trk_live_ in production. |
RELAY_SECRET | Secret. A long random string your backend sends as X-Relay-Secret. |
TRENDLY_API_BASE | Variable. The Trendly API. Leave it as it is. |
TRENDLY_PROGRAM | Variable, optional. Your affiliate program id, only when your key is not pinned to one program and your account runs more than one. See Which program. |
COOKIE_DAYS | Variable. How long trendly_ref lasts. 30 by default. |
CONVERSIONS
Sending conversions
When a conversion happens, your backend sends POST /trendly/conversion to your own domain, with the X-Relay-Secret header and the conversion as JSON. Send it again whenever the conversion changes.
Fields
| FIELD | REQUIRED | NOTES |
|---|---|---|
order_id | Yes | Your permanent id for the order or subscription. Never reused. |
status | No | Defaults to confirmed. One of pending, confirmed, cancelled, refunded, partially_refunded, fraud. |
total_amount | Yes | What the customer paid. Send a string like "149.00"; the Worker sends Trendly two decimals. |
subtotal_amount | No | Before discount, without VAT and shipping. Defaults to total_amount. |
currency | Strongly | ISO 4217, such as SAR. Must be your program's currency. |
code | Strongly | The creator code. Leave it out and the Worker reads trendly_ref from cookie, or from the Cookie header of the request. |
cookie | No | The visitor's Cookie header as your server received it. Only needed when you do not send code. |
placed_at | Strongly | ISO 8601 with an offset, such as 2026-11-10T14:05:00+03:00. |
refunded_amount | On refund | Total refunded so far. |
customer_ref | No | Pseudonymous only: a hash or an internal id. Never an email, name or phone number. |
is_new_customer | No | true or false. True earns the program's new-customer rate, when it has one. |
metadata | No | Any object. Stored with the order. The Worker adds relay: cloudflare-worker/1.0. |
The Worker also passes on order_number, discount_amount, tax_amount, shipping_amount, commissionable_amount and status_changed_at if you send them, with the same meaning as in the Sales API.
Where the code comes from
code. Otherwise read trendly_ref from the visitor's request. The cookie is set for the exact host the visitor landed on, so if your checkout or API runs on another subdomain, read it in the page with JavaScript and send it along with the order.CHANGES
Refunds and changes
Send the same order_id again with the new status. Trendly updates the order it already has and reverses the commission.
Terminal
curl -X POST https://example.com/trendly/conversion \
-H "X-Relay-Secret: $RELAY_SECRET" \
-H "Content-Type: application/json" \
-d '{
"order_id": "SUB-20931",
"status": "refunded",
"total_amount": "149.00",
"refunded_amount": "149.00",
"currency": "SAR",
"code": "NOURA10",
"placed_at": "2026-11-10T14:05:00+03:00",
"status_changed_at": "2026-11-18T09:30:00+03:00"
}'Sending the same conversion twice is safe: it lands on one order. That is also why the Worker can retry without asking.
MONITORING
Health check
GET /trendly/health checks the Worker's key and its connection to Trendly. It needs the relay secret too, and answers with Trendly's GET /v1/ping: the program, the environment and the key's scopes.
Terminal
curl https://example.com/trendly/health \
-H "X-Relay-Secret: $RELAY_SECRET"Point your uptime monitor at it if it can send a header.
RESPONSES
Responses and errors
When the Worker reaches Trendly, it returns Trendly's status and body unchanged, so a 200 carries the same attribution and commission as the Sales API, and a 400 carries Trendly's error codes. It retries a 429, a 5xx or a network failure up to three times in all, waiting a little longer each time.
The Worker's own answers, before Trendly is called:
| HTTP | ERROR | WHAT TO DO |
|---|---|---|
| 401 | unauthorized | X-Relay-Secret is missing or does not match RELAY_SECRET. |
| 400 | invalid_json | The body is not JSON. |
| 400 | order_id_required | Send order_id. |
| 400 | unknown_status | Use one of the statuses in the allowed list the response includes. |
| 400 | total_amount_required | Send total_amount. |
| 405 | method_not_allowed | /trendly/conversion only takes POST. |
| 500 | worker_not_configured | TRENDLY_API_KEY or RELAY_SECRET is not set. Run wrangler secret put. |
| 502 | trendly_unreachable | Trendly could not be reached after the retries. Send the conversion again later; it is safe to repeat. |
SECURITY
Security
Never call the Worker from the browser
RELAY_SECRET out of front-end code and public repos. If it leaks, set a new one with npx wrangler secret put RELAY_SECRET and update your backend.- The cookie holds only the creator code. Nothing about the visitor is stored or sent.
- Your Trendly key lives in a Cloudflare secret. It is never in the Worker's code or in any response.
- Only
?trendly=values made of letters, digits,-and_(2 to 64 characters) are kept. Anything else is ignored. - Every conversion is still checked by Trendly: an unknown code is stored as unattributed, and a wrong currency or status is refused.
