Developers
Connect your stack · Cloudflare

Cloudflare Worker

A small Worker you deploy on your own Cloudflare account. It remembers which creator's link brought a visitor in, takes each conversion from your backend, and reports it to Trendly with retries. Your checkout does not change.

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.

  1. Creator

    Shares a link to your site

    For example https://example.com/?trendly=NOURA10.

  2. Worker

    Sets a first-party cookie, trendly_ref

    It holds the creator code for 30 days. The page itself is served as usual.

  3. Visitor

    Converts

    Buys, subscribes or signs up, today or weeks later.

  4. Your backend

    POSTs the conversion to the Worker at /trendly/conversion

    With the X-Relay-Secret header and the code from the cookie, or the coupon code the customer typed.

  5. Worker

    Reports it to Trendly

    Calls POST /v1/orders with your Trendly key, retrying if Trendly or the network hiccups.

  6. 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

  1. 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.

  2. 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, replace example.com with your domain in pattern and zone_name.

    Terminal

    mkdir trendly-relay
    cd trendly-relay
    # save worker.js and wrangler.toml here
    npx wrangler login

    Starting from npm create cloudflare@latest or npx wrangler init works 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 the routes and [vars] into your config.

  3. Step 3: Add the two secrets

    Paste your test key when asked for TRENDLY_API_KEY. For RELAY_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_SECRET

    If Wrangler says the Worker does not exist yet, answer yes to create it.

  4. Step 4: Deploy

    Terminal

    npx wrangler deploy

    Check it with the health check. It should answer with your program and "environment": "test".

  5. 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-20931 as a test order. Use a real creator code from your program for code to see it attributed.

  6. 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
SETTINGWHAT IT IS
TRENDLY_API_KEYSecret. Your Trendly key: trk_test_ while testing, trk_live_ in production.
RELAY_SECRETSecret. A long random string your backend sends as X-Relay-Secret.
TRENDLY_API_BASEVariable. The Trendly API. Leave it as it is.
TRENDLY_PROGRAMVariable, 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_DAYSVariable. 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

FIELDREQUIREDNOTES
order_idYesYour permanent id for the order or subscription. Never reused.
statusNoDefaults to confirmed. One of pending, confirmed, cancelled, refunded, partially_refunded, fraud.
total_amountYesWhat the customer paid. Send a string like "149.00"; the Worker sends Trendly two decimals.
subtotal_amountNoBefore discount, without VAT and shipping. Defaults to total_amount.
currencyStronglyISO 4217, such as SAR. Must be your program's currency.
codeStronglyThe creator code. Leave it out and the Worker reads trendly_ref from cookie, or from the Cookie header of the request.
cookieNoThe visitor's Cookie header as your server received it. Only needed when you do not send code.
placed_atStronglyISO 8601 with an offset, such as 2026-11-10T14:05:00+03:00.
refunded_amountOn refundTotal refunded so far.
customer_refNoPseudonymous only: a hash or an internal id. Never an email, name or phone number.
is_new_customerNotrue or false. True earns the program's new-customer rate, when it has one.
metadataNoAny 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

If the customer typed a coupon, send it as 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:

HTTPERRORWHAT TO DO
401unauthorizedX-Relay-Secret is missing or does not match RELAY_SECRET.
400invalid_jsonThe body is not JSON.
400order_id_requiredSend order_id.
400unknown_statusUse one of the statuses in the allowed list the response includes.
400total_amount_requiredSend total_amount.
405method_not_allowed/trendly/conversion only takes POST.
500worker_not_configuredTRENDLY_API_KEY or RELAY_SECRET is not set. Run wrangler secret put.
502trendly_unreachableTrendly 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

The relay secret lets anyone report a sale. Send conversions from your server only, and keep 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.