Developers
Affiliate · Partner API v1

Report a sale. We handle the rest.

Send us an order. We match it to the creator whose code brought it in, calculate what they earned, hold it through your refund window, and pay it out on schedule. Codes, commission, clawbacks, statements and both dashboards all follow from that one request. There is no second endpoint to build.

One required endpoint

POST /v1/orders. Everything else is optional.

Test keys are real

Same engine, same numbers. Orders land in Trendly flagged as test.

Safe to retry

Idempotent on your order id. Nobody gets paid twice.

OVERVIEW

What this API is for

A brand runs an affiliate program on Trendly. Creators join, the brand approves them, and each approved creator gets a personal discount code. When a customer uses that code in your store, the creator earns commission. Trendly pays them on a schedule the brand sets.

We need one thing from you: the orders. Everything after that is ours.

What you build

One call to POST /v1/orders when an order is placed, and again whenever it changes. That is the integration.

Memberships, approvals, campaigns, commission rules, caps, hold periods, clawbacks, payout batching, statements, and both dashboards are handled for you.

Two rules apply everywhere in this API.

RULEWHY
Money is a decimal string. "240.00", never 240.00.JSON numbers are doubles. 240.10 round-trips as 240.09999999999999. Across a few thousand orders a month that becomes a gap between your books and ours that nobody can trace.
Writes are idempotent on your order id.Retry freely. Replay a whole day. Run a webhook and a nightly reconciliation side by side. The same order cannot be counted twice. It is a database constraint, not careful code.

Already have an orders API? We can poll it instead. Ask your Trendly contact for the integration specification. Most merchants find pushing to us takes a day against a week, so this is the path we recommend.

QUICKSTART

From nothing to a working call

1. Get a key. Ask your Trendly contact. If you have a Trendly login, go to Developers and create one yourself. Start with a test key. It looks like trk_test_….

2. Check it works. This returns the program your key belongs to and the three settings that determine what a creator takes home.

cURL

curl https://api.trendly.com.sa/v1/ping \
  -H "Authorization: Bearer trk_test_YOUR_KEY"

Response

{
  "ok": true,
  "environment": "test",
  "key_name": "Calo checkout",
  "scopes": ["codes:read", "orders:read", "orders:write", "program:read"],
  "program": {
    "id": "3b0c8f14-0a2e-4c77-9b31-6f5f2a1d4e88",
    "name": "Calo Market Partners",
    "currency": "SAR",
    "status": "active",
    "commission_basis": "net_ex_vat_ex_shipping",
    "hold_period_days": 14,
    "payout_cycle_days": 30
  },
  "server_time": "2026-09-18T09:14:22Z"
}

3. Report an order. This is the whole integration.

cURL

curl -X POST https://api.trendly.com.sa/v1/orders \
  -H "Authorization: Bearer trk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "ord_1001",
    "discount_code": "SARA10",
    "status": "confirmed",
    "currency": "SAR",
    "subtotal_amount": "240.00",
    "discount_amount": "24.00",
    "shipping_amount": "15.00",
    "tax_amount": "34.65",
    "total_amount": "265.65",
    "placed_at": "2026-09-08T11:42:03+03:00"
  }'

A test key records the order in Trendly and flags it as test. Open the brand's dashboard and you will see it there, excluded from their figures and from payouts.

Response

{
  "mode": "test",
  "recorded": true,
  "created": true,
  "order_id": "ord_1001",
  "status": "confirmed",

  "attribution": {
    "attributed": true,
    "code": "SARA10",
    "creator_name": "Sara A.",
    "membership_id": "9c1e77a2-4b3d-4e91-8f22-11c0a7d5b6e3"
  },

  "commission": {
    "basis_amount": "216.00",           // 240.00 goods − 24.00 discount
    "basis": "net_ex_vat_ex_shipping",  // VAT and shipping excluded, per the program
    "rate_type": "percentage",
    "rate_value": "10.00",
    "rate_source": "program_default",   // which link in the rate chain won
    "amount": "21.60",
    "currency": "SAR",
    "capped": false,
    "payable_after": "2026-09-22T11:42:03Z"
  },

  "warnings": [
    "Recorded as a test order. It appears in Trendly marked as test, and is excluded
     from the program's figures and from every payout."
  ]
}

Check the commission block

The basis amount, the rate, and where the rate came from. These are the numbers that get disputed at month end. Verify them now against an order you can calculate by hand.

Swap trk_test_ for trk_live_ and the same request counts for real. That is the only difference between the two environments.

AUTHENTICATION

Keys

Send your key as a bearer token on every request. If your gateway strips the Authorization header, send the same value as X-API-Key instead.

Header

Authorization: Bearer trk_live_7Qa4xN2pLmK8vR3sT1wY6z
KEYWHAT IT DOES
trk_test_…Records the order and flags it as test. Same validation, code lookup, and commission calculation as live. Test orders are visible in Trendly and excluded from every figure and payout. A brand can clear them at any time.
trk_live_…Counts for real. Creators get paid from these calls.

Keys are shown once

We store a hash, not the key. Nobody at Trendly can read it back to you.

If you lose a key, rotate it. Rotation issues a replacement and leaves the old key working, so you can deploy the swap with no downtime and revoke the old one after.

Scopes

A key carries some or all of four scopes. Use codes:read on its own for anything running in a checkout. A checkout is the most exposed place a key lives, and one that can only answer "is this code valid?" is worth little to whoever takes it.

SCOPEGRANTS
orders:writeReport orders. This is the one that moves money.
orders:readRead back an order and its commission.
codes:readList codes and validate one against a cart.
program:readThe program's currency, commission basis, and hold period.

ORDERS

Reporting an order

POST

/v1/orders

Call this when an order is placed, and again whenever it changes: confirmed, cancelled, refunded, partially refunded, flagged. Same call, same order_id. We match on it and reconcile.

Reporting changes is not optional

If you report an order as confirmed and never update it, we pay commission on it after a refund you knew about and we did not. This is the most common way an affiliate integration loses a merchant money.

Fields

FIELDREQUIREDNOTES
order_idYesYour permanent order identifier. Never reused. This is what makes the call idempotent.
statusYesOne of the six listed under Order statuses. Anything else returns 400.
subtotal_amountYesGoods value before discount, excluding VAT and shipping.
total_amountYesWhat the customer actually paid.
discount_codeStronglyThe code as applied. Matched case-insensitively. Without it the order is recorded but belongs to nobody.
currencyStronglyISO-4217. Defaults to the program's currency. An order in a different currency returns 400 rather than being converted.
discount_amountStronglyWhat the code took off. Part of the commission basis under most program settings.
shipping_amountStrongly"0.00" if none.
tax_amountStronglyVAT charged.
placed_atStronglyISO-8601 with offset. Required for backfills. Without it we use the time of the call.
refunded_amountOn refundCumulative refunded to date.
order_numberNoHuman-readable reference, shown in support screens.
customer_refNoPseudonymous only. See Privacy.
is_new_customerNoDrives the new vs returning split in the brand dashboard.
metadataNoAny object. Stored with the order, never parsed.

Common aliases are accepted

id for order_id, coupon for discount_code, total for total_amount. Unknown fields are ignored, so your own extras will not break the call.

Warnings

A successful response may include a warnings array. The order was accepted, so never retry on a warning. Each one is worth fixing though. We warn when amounts do not balance to subtotal − discount + shipping + tax = total, when a timestamp has no UTC offset, when a currency had to be assumed, and when a refunded order arrives without a refunded amount.

STATUSES

Order statuses

Map your own statuses onto these six and send your Trendly contact the mapping.confirmed matters most. It starts the clock on a creator's earnings.

STATUSMEANSEFFECT ON COMMISSION
pendingPlaced, not yet paid or acceptedNothing accrues
confirmedPaid and accepted. The countable state.Accrues, held until the hold period elapses
cancelledCancelled before fulfilmentReversed
refundedFully refunded after fulfilmentReversed in full
partially_refundedPartly refundedReversed proportionally
fraudFlagged fraudulentReversed. The creator is flagged for review.

Unknown statuses return 400

We do not guess. Mapping an unrecognised status to something plausible is how orders stop accruing with no error anywhere, and nobody notices until a creator asks why their earnings stalled two months ago.

REFUNDS

Refunds and cancellations

Send the same order_id with the new status. Commission is held through the program's refund window before it becomes payable, so most refunds are absorbed before any money moves.

Partial refund

# Same order_id. We reverse the proportional share of the commission.
# If it was already paid out, a clawback carries into the next cycle.

curl -X POST https://api.trendly.com.sa/v1/orders \
  -H "Authorization: Bearer trk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "ord_1001",
    "status": "partially_refunded",
    "currency": "SAR",
    "subtotal_amount": "240.00",
    "discount_amount": "24.00",
    "shipping_amount": "15.00",
    "tax_amount": "34.65",
    "total_amount": "265.65",
    "refunded_amount": "120.00",
    "placed_at": "2026-09-08T11:42:03+03:00",
    "updated_at": "2026-09-14T10:02:00+03:00"
  }'

If a refund arrives after a payout has gone out, we do not rewrite history. The original accrual stands and a reversal is appended, which carries into the creator's next cycle as a clawback. This is what lets a statement from three months ago still reconcile.

Order of delivery does not matter

A stale update never overwrites a newer one. A replayed call produces one order and one commission. You do not need to sequence or de-duplicate your requests.

BATCH

Batches and backfill

POST

/v1/orders/batch

Up to 500 orders per call, for backfills and nightly reconciliation. Results are per order. One malformed row does not reject the other 499, and the response names the row to fix.

Request

# For backfills and nightly reconciliation. Up to 500 per call.
# Results are per order, so one malformed row does not reject the other 499.

curl -X POST https://api.trendly.com.sa/v1/orders/batch \
  -H "Authorization: Bearer trk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "orders": [ { "order_id": "ord_1", ... }, { "order_id": "ord_2", ... } ] }'

The response is always 200, because the batch itself succeeded. Each entry carries its own status. Rejected entries carry the same error.code and error.hint a single call would have returned.

Backfilling is safe

Replay as far back as you like. Orders already recorded converge on the same row and no commission is written twice. You do not need to track where a previous run stopped.

CODES

Discount codes

GET

/v1/codes

Trendly issues each approved creator a personal discount code. Poll this endpoint to mirror them into your own discount engine, with updated_since set to your last successful run.

Request

# Mirror Trendly's codes into your own discount engine.
# Use updated_since, not created_since. A code disabled this morning must come
# back even though it was created months ago.

curl "https://api.trendly.com.sa/v1/codes?updated_since=2026-09-01T00:00:00%2B03:00&limit=200" \
  -H "Authorization: Bearer trk_live_YOUR_KEY"

updated_since, not created_since

Results come back oldest change first, so a code disabled this morning is returned even though it was created months ago. Filter on creation date instead and you never learn a code was switched off, so a suspended creator keeps earning.

Pagination is by cursor. Pass back next_cursor unmodified; it is opaque and stable, so codes being approved while you page cannot make you skip or repeat one.

Two kinds of code

KINDMEANING
memberA creator's permanent personal code. Shared publicly, unlimited use.
gift_single_useA one-time code for a free product on a specific campaign. Treat it as a separate object. Handling it like a member code is how a free-product code ends up posted to 40,000 followers.

CODES

Who issues the code

A creator is approved in Trendly and needs a discount code. There are four ways that code comes to exist, and the right one depends on whether you already run a coupon engine and whether it can be called from outside. You can mix them: the model is chosen per program, and a single creator can be moved from one to another without losing their history.

MODELWHO MINTS THE CODEWHAT YOU BUILD
Trendly issues itTrendlyNothing at first. Poll GET /v1/codes and create each code in your engine as it appears.
We ask your engineYou, on requestOne endpoint we call when a creator is approved. You reply with the code.
You send it to usYou, on your own scheduleA call to PUT /v1/affiliates/{id}/code whenever your engine mints or changes one.
Somebody types itA personNothing. An operator registers the code in the dashboard. Useful on day one and as the fallback when an integration is down.

A creator can be approved without a code

Approval and code issuance are separate steps, so a creator can be accepted while their code is still being minted. They appear in the dashboard as waiting, and any order that arrives on their code before it reaches us is held as unattributed and credited the moment it does. Nothing is lost by approving early.

Sending us a code

PUT

/v1/affiliates/{affiliate_id}/code

In build

ships with 1.2

Use this when your engine owns the code. It works for the first code a creator gets and for every change after it, so a rotation is the same call as a creation. Requires a key carrying codes:write.

cURL

curl -X PUT https://api.trendly.com.sa/v1/affiliates/aff_8f31c2/code \
  -H "Authorization: Bearer trk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9d1c4f6a-3b77-4a21-9c5e-0f2b8d6a1e44" \
  -d '{
    "code": "SARA15",
    "external_code_id": "cpn_81f3",
    "reason": "Issued by the coupon engine after manual verification"
  }'

The affiliate_id is ours. You already hold it: it is on every order we send you and on every row of GET /v1/codes. Your own identifier belongs in external_code_id, which we store and hand back, so neither side has to keep a mapping table.

RESPONSEMEANING
200The code is live. The body carries the new record and, when it supersedes one, the code it replaced.
404unknown_affiliate. The id is not in this program. Check you sent ours rather than yours.
409code_in_use. Another creator in the same program holds that code. Codes are unique per program, because attribution is impossible otherwise.
422affiliate_not_approved. They have applied but nobody has accepted them yet, so there is nobody to pay.

Send the same idempotency key on a retry

Without one, a timeout you retry looks like a deliberate second change. We record it as a code rotation, disable the first code, and any link a creator already posted stops working. With the key, the retry returns the original answer.

Us asking your engine

POST

{your provisioning URL}

Draft contract

contract open to change

The mirror image, for engines that can be called from outside. You give us a URL and a shared secret; we call it once when a creator is approved and use the code you return. This is the only endpoint in these docs that you host.

Node.js

// You host this. We call it once, when a creator is approved in Trendly.
app.post("/trendly/coupons", async (req, res) => {
  const { affiliate_id, creator, suggested_code, discount } = req.body;

  // Your engine owns the final code. Take our suggestion or ignore it.
  const coupon = await coupons.create({
    code: suggested_code,
    percent_off: discount.value,
    metadata: { trendly_affiliate_id: affiliate_id, handle: creator.handle },
  });

  // Reply inside 10 seconds. Anything slower is treated as a failure and retried.
  res.json({ code: coupon.code, external_code_id: coupon.id });
});

We wait ten seconds, then retry with backoff for an hour. If it never answers, the creator stays approved and their code is marked failed with your error text against it, visible to the brand, who can retry or type a code in by hand. We do not invent a code to fill the gap, because a code your engine has never heard of sells nothing and the failure would surface as missing revenue weeks later.

Every code is kept

Codes are never edited in place. Replacing one closes the old record and opens a new one, so a creator carries a history rather than a current value: what the code was, who changed it, when, and why. Orders stay attached to the code that actually brought them in, which means last quarter's numbers do not move when somebody rotates a code today.

GET /v1/codes returns superseded codes as well as live ones. Filter on status if you only want the current set, and read replaced_by to follow a chain. The brand sees the same history in the dashboard, next to the creator it belongs to.

CHECKOUT

Validating a code at checkout

POST

/v1/codes/validate

If your checkout does not hold its own copy of our codes, ask us in real time. Send the code and, optionally, the cart total; we say whether it is usable and what to take off.

cURL

curl -X POST https://api.trendly.com.sa/v1/codes/validate \
  -H "Authorization: Bearer trk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "SARA10", "cart_total": "240.00" }'

Always 200, even for a bad code

A rejected coupon is a normal checkout outcome, not an error. You get valid: false with a reason to branch on and a message to show the shopper. Returning 404 would mean wrapping this in a try/catch, which is how a network blip ends up telling a real customer their real code is bad.

Each rejection has its own reason: unknown_code, disabled, expired, not_yet_valid, below_minimum, not_provisioned. A shopper who mistyped one character and one whose code genuinely ran out should not be sent down the same path.

Use our discount_amount rather than calculating your own. We round half-up to the halala. Rounding the other way puts you a riyal out on every order of a certain shape, which shows up later as a reconciliation gap nobody can explain.

ERRORS

When something is wrong

Every failure has the same shape, including authentication failures.

400 Bad Request

{
  "error": {
    "code": "invalid_amount",
    "message": "'total_amount' is not a decimal amount: SAR 240",
    "field": "total_amount",
    "hint": "Send money as a decimal string. Use \"240.00\", not 240.0 and not \"SAR 240\". JSON numbers are doubles and lose halalas at volume.",
    "documentation_url": "https://www.trendly.com.sa/developers#orders"
  },
  "request_id": "8f14d2a1-7c3b-4e55-9a20-3d6b1f0c9e47"
}

Branch on error.code. It is stable. error.message and error.hint are written for whoever reads your logs, and error.field names the part of your payload that is wrong.

HTTPMEANSWHAT TO DO
400The payload cannot be usedPermanent. Fix it and resend. Do not retry as-is.
401Key missing, malformed, revoked or expiredThe body says which. Check the key, or ask the brand to issue a new one.
403Valid key, wrong scopeAsk for a key carrying the scope named in the message.
404No such object in this programCheck you are using the key for the right program.
409Idempotency key reused with a different bodyUse a fresh key for a new request.
5xxOur problemSafe to retry. Every write is idempotent. Quote the request id if it persists.

Every response carries a request id

In the X-Request-Id header and in the error body. Quote it to us, or look it up yourself with GET /v1/events.

Idempotency

Optional. Send Idempotency-Key on a write and a replay returns the original response rather than a fresh one. Writes are already idempotent on order_id, so this is for your retry logic rather than the ledger. Without it a retry still cannot double-pay, it just reports created: false the second time.

TESTING

Testing, and what go-live looks like

A test key runs the same engine as a live one. Same parser, same code lookup, same commission basis, same rate chain. No fixture data and no mock. A clean 200 from a test key proves your payload works, not that a sandbox was willing to accept it.

Test orders are recorded and appear in Trendly flagged as test. Send one, then open the brand's dashboard and look at it. They are excluded from the program's figures and from every payout, and a brand can clear them from Developers whenever they want a clean slate.

Replay from the dashboard

Any order you report shows up in the request log with its full request and response. If one fails, fix your mapping and press Replay. The same payload is sent again with the same key, and both attempts stay in the history.

GET

/v1/events

Your own call history for the last 30 days, with request and response bodies. This is the same log the brand sees in Trendly, reachable with your key. You do not need a Trendly account to debug your own integration.

Your last ten failures

curl "https://api.trendly.com.sa/v1/events?failures_only=true&limit=10" \
  -H "Authorization: Bearer trk_test_YOUR_KEY"

Before you switch to a live key

CHECKWHY IT MATTERS
An order reports and attributes to the right creatorConfirms your code field reaches us and matches.
The commission block matches what you expectAgree the basis, rate, and rounding while it is still cheap to change.
A cancellation reverses itProves your change events reach us, not just your create events.
A partial refund reverses proportionallyThe case most integrations forget.
The same order sent twice changes nothingProves your retries are safe.
An order placed at 23:50 lands on the right dayCatches timestamps sent without an offset.
A lowercase code still attributesCustomers do not type codes the way your admin stores them.

Run one cycle without paying

Before real money moves, run a full payout cycle in parallel and reconcile our totals against your finance team's. A discrepancy found in a shadow cycle costs nothing. The same one found during a real payout costs a week.

PRIVACY

What we do and do not want

We do not want customer personal data. No names, emails, phone numbers or addresses, in any field, ever. We reject a customer_ref that looks like an email or a phone number rather than storing it.

customer_ref must be pseudonymous: a hash or an internal key, stable per customer and meaningless outside your system. We use it for the new vs returning split and to detect a creator buying through their own code. Omit it if you prefer. You lose those two features and nothing else.

Order data reaches the brand that owns the program. The creator whose code was used sees their own orders only: date, status, order value, and their commission. Never another creator's orders, and never anything about the customer. Keys are stored hashed, excluded from logs, and scoped to one program.

REFERENCE

Everything, in one place

ENDPOINTDOES
GET /v1/pingVerify a key and read the program it belongs to.
POST /v1/ordersReport one order. The only endpoint you must implement.
POST /v1/orders/batchReport up to 500, with per-order results.
GET /v1/orders/{order_id}Read back what we recorded, including commission.
GET /v1/codesList codes to mirror into your store.
GET /v1/codes/{code}Read one code.
POST /v1/codes/validateValidate a code against a cart, at checkout.
GET /v1/eventsYour own call history, with bodies.
GET /v1/events/{request_id}One call in full.
GET /v1/openapi.jsonThe machine-readable spec. No key needed.

Something not covered here?

Send your Trendly contact the request id from a failing call and we can see exactly what you sent and exactly what we said. That is usually a one-message conversation.

Open the OpenAPI spec