Partnely API
One REST API per brand. Everything the brand dashboard does is available here, plus a server-to-server way to record conversions. Base URL: https://partnely.app/api/v1.
Authentication
Generate a key under Install & API in the brand dashboard. Send it as a bearer token. Keys start with pn_live_; only a hash is stored, so copy it when it is shown.
The order postback, POST /conversions, works on every plan. The other endpoints come with the Growth plan and above; on other plans they answer plan_limit 403.
curl https://partnely.app/api/v1/me \ -H "Authorization: Bearer pn_live_…"
The tracking snippet uses a separate public key (pk_…) that is safe to embed in a web page. It can only report conversions, and those still require the brand’s approval.
Conventions
- JSON in and out. Field names are
snake_case. - Money is in integer cents (
subtotal_cents,commission_cents). The conversion postback accepts major units (subtotal: 129.00) for convenience. - Percent commissions are basis points:
1000= 10.00%. - Timestamps are ISO 8601 strings in UTC.
- Successful responses wrap the payload:
{ "data": …, "meta": { … } }. Lists paginate with?limit=(max 200) and?offset=;meta.totalis the full count. - Errors:
{ "error": { "code", "message", "details?" } }with a matching HTTP status. Codes:unauthorized401,plan_limit403,not_found404,validation_error400,bad_request400,reason_required400,invalid_state409,order_locked409,conflict409,nothing_to_pay409,currency_required409,rate_limited429,internal_error500. - Requests are rate limited per address, per store, and failed keys per address. Over a limit the answer is
rate_limited429 with aRetry-Afterheader in seconds. See Rate limits.
Brand
The brand that owns the key. No secrets, API key hash, billing provider ids or billing email; has_webhook_secret and has_shopify_admin_token say whether a webhook secret and a Shopify Admin API token are saved.
Catalog
List products. Filters: active, q (text in the title, SKU, handle or vendor) and category (a kind and a key, e.g. collection:serums).
Create one. Body: title, url, optional external_id, sku, price_cents, image_url, categories, commission_type, commission_value.
Partial update; same fields as create plus is_active. categories replaces the product's category entries; null clears them. Collections and types from a store import stay.
Links to the product keep working and lose the association.
Bulk upsert keyed on (brand, external_id); Shopify rows also match on handle so a later import fills in what an earlier one lacked.
{ "source": "shopify", "shop_domain?": "…" } // see the three paths below
{ "source": "csv", "csv": "title,url,price\n…" }
{ "source": "json", "products": [{ "external_id": "…", "title": "…", "url": "…", "categories?": ["Serums"] }] }Every product carries categories, a list of { kind, key, name }, or null when it has none. A category rate matches on the kind and the key:
collection: a Shopify collection; the key is its handle.type: the Shopify product type; the key is the type in lower case.category: a WooCommerce category (the key is its slug), or a name sent incategoriesthrough the API or a CSV (the key is the name as a slug:Gift setsisgift-sets). The API takes at most 50 names per product.
An import writes only the kinds it reads and keeps the rest: products_json reads collections and the type, the Admin API the type, WooCommerce and a JSON import category, and a CSV the kinds of its category columns. The sitemap path and a CSV without a category column keep every saved category. A CSV names them in a categories column (or category, collection, collections, product_type), several in one cell split by ; or |.
A Shopify import tries three paths in order and reports which one worked in data.catalog_source:
products_json: the publichttps://{shop}/products.jsonfeed. Prices, SKUs, images and the product type; no token. Off on stores that disabled the storefront JSON endpoints or sit behind a password. Collections come from/collections.jsonand each collection'sproducts.json, leaving outallandfrontpage, up to 60 collections, 2,500 products in each, 200 requests and 60 seconds. A collection past 2,500 products is read in part and the next one is still read. When that reading fails or stops at a limit, the import still succeeds,data.warningssays so, and products keep the collections that were not read.admin_api: the Admin REST API, when the brand saved a custom-app token (scopesread_products,write_discounts) under Settings. Every active product, paginated; preferred over the sitemap. The product type is read; the saved collections stay.sitemap:sitemap_products_N.xml, which every store serves. Titles, handles and images only;price_centsandskuare null andexternal_idis the handle.data.warningssays so and suggests a CSV or the Admin API to fill prices.
Affiliates
An affiliate’s relationship with your program is a membership. Ids in this section are membership ids.
Members with profile and status.
status (pending | approved | paused | rejected), commission_type, commission_value, coupon_code, stripe_promotion_code_id (the Stripe promotion code behind the creator's code, promo_…; unique per store, 409 conflict), note. A field left out stays as saved; null clears it. commission_type and commission_value are set together: null for either clears the creator's own rate, one sent alone must complete the saved one, and a percent value is basis points up to 10000.
Links & clicks
Every tracked link with tracked_url and lifetime stats.
Raw clicks. IPs are stored hashed. Bots are flagged, not deleted.
Conversions
Each conversion carries subtotal_cents, commission_cents, status, attribution_method, source, the matched coupon_code, timestamps, and these fields worth knowing:
subtotal_cents: the commission and fee base, what the shopper paid for products after discounts, excluding tax and shipping. Gift cards are left out unless the store pays commission on gift cards (a Settings choice).currencyis the store's currency, into which an order in another currency is converted (see Currency); an order still waiting for an exchange rate, or on a store whose currency is not confirmed, keeps its own.platform_fee_cents: Partnely's fee on that order's subtotal at the brand's plan rate when the order was recorded. Zero during the free trial; reversed orders carry no fee.risk_flags: an array, usually empty, of signals worth a human look before approval.self_referral(the customer email hashes to the creator's own login),repeat_customer(the same customer bought through the same creator within 30 days),fast_order(the order landed within a minute of the click),code_spike(the daily check saw the creator's code on far more orders than usual without a click; added to pending orders after they are recorded). Flags never block recording; the order lands as pending and the brand decides.verified:truefor orders from a signed platform webhook, the order API or the brand's own entry;falsefor orders the snippet reported, since anyone holding the public key can send one. Bulk approval and auto-approval skip unverified orders; approve each by hand after checking it against the store's orders.reversed_byandreversal_reason: who reversed the order,brand(by hand or through the API),platform(a store refund or cancellation) orsystem, and the reason the creator sees.metadata: from the order API, up to 20 scalar values (strings, numbers, booleans) whose keys do not look personal; anything else is dropped; asubscription_idsent on a first order is kept asmetadata.subscription_id. The snippet's postback keeps no metadata and ignoressubscription_id. The commission breakdown and the rates the order was recorded with are added undermetadata.commission; a converted order carriesmetadata.original, and an order waiting for an exchange ratemetadata.fx_pending: true(see Currency).
Every order starts pending. Approval makes its commission final: an approved or paid order is never reversed, by hand or by a later refund.
Record an order. Idempotent on order_id: a repeat returns 200 with outcome: "duplicate". A new order returns 201. An order with no matching affiliate returns 200 with outcome: "unattributed" and a reason; it is also written to the event log.
POST /api/v1/conversions
{
"order_id": "1042",
"order_number": "#1042",
"subtotal": 129.00, // products after discounts, without tax and shipping
"currency": "USD", // optional: the store's currency when left out; another currency is converted
"occurred_at": "2026-09-10T14:02:00Z",
"click_id": "…", // from pn_click on the landing URL
"ref": "…", // or the link code from pn_ref
"coupon_codes": ["MAYA10"], // a creator's code wins over click_id and ref
"customer_email": "…", // stored hashed
"subscription_id": "sub_…", // optional, on the order that starts a subscription
"line_items": [
{ "product_id": "8000000001", "sku": "MS-VITC", "title": "Vitamin C Serum", "quantity": 1, "price": 129.00, "line_total": 116.10 },
{ "title": "Gift card", "quantity": 1, "price": 50.00, "gift_card": true }
]
}The order API keeps the occurred_at it is sent (the current time when it is left out) and its metadata as described above; customer_email is stored only as a hash. A currency left out, null or blank is the store's currency.
line_items[].line_total(optional): what the shopper paid for the line, after its discounts and without tax. Sent, it replacesprice×quantityas the line's base.line_items[].gift_card(optional):truefor a gift card line. Gift card lines are left out of commission and fee unless the store pays commission on gift cards; an order of gift cards only then records nothing.subscription_id(optional): the subscription the order starts. Send it on the first order so later renewals can find the creator who was credited with it.- Renewals:
renewal: true(also1,yes,on),renewal_of(the subscription id) orrenewal_of_order(the subscription's first order id) marks a renewal order. A renewal records nothing unless the store turned on commission for renewals in Settings; then it goes to the creator credited with the subscription's first order, unless a creator code or click on the renewal itself says otherwise.
Pending → approved. The commission is final from then on. An order still waiting for an exchange rate into the store's currency answers 409 exchange_rate_pending (see Currency).
Body { "reason": "Refunded" }. reason is required (400 reason_required), at most 300 characters, and shown to the creator. Only a pending conversion can be reversed: an approved or paid one answers 409 order_locked, because approved commission is final, and one already reversed answers 409 invalid_state. The conversion records reversed_by: "brand".
Payouts
History, plus meta.owed: approved, unpaid commission per affiliate and currency, each row with its currency. Rows in the store's currency come first, largest first; then the other currencies alphabetically, largest first within each. Amounts in different currencies are never added up.
{ "affiliate_id", "currency?", "method?", "reference?", "note?", "mark_paid": true }. Bundles every approved, unpaid conversion for that affiliate in one currency into one payout. With mark_paid true (the default; a boolean, or the text "true" or "false") they are marked paid. The currency is currency when given (a 3-letter code; 409 nothing_to_pay when nothing is owed in it), else the store's currency, else the only currency the affiliate is owed in. An affiliate owed in several other currencies with none given answers 409 currency_required.
The payout and the conversions it covers.
{ "status": "paid", "reference?" } for payouts created with mark_paid: false.
Campaigns
One offer sent to many creators: gifting, commission_boost, flat_fee or content. Creators apply in the app; accepting one creates a campaign link, and orders through it roll up to the campaign. A commission_type / commission_value on the campaign overrides the affiliate and brand rates for those links (product and category rates still win). Once the campaign is closed, orders placed after it closed earn the creator's normal rate (product rate, then category rate, then the affiliate's own rate, then the brand default); orders placed before keep the campaign rate.
List campaigns, each with stats (human clicks, orders, revenue_cents, commission_cents through campaign links) and applications (total, pending, accepted).
Create one. Saved as a draft unless status is open; product_ids must be in your catalog.
deliverable_kinds says how each deliverable is handed in, keyed by the line's exact wording: post (a post or video), story, link_in_bio, video_link (a link in a video description) or other. A line left out is read from its wording, and kinds for lines not in deliverables are dropped. Responses return deliverable_kinds for every line, or null when the campaign lists no deliverables.
{
"title": "Autumn routine gifting",
"type": "gifting", // gifting | commission_boost | flat_fee | content
"summary": "Two products, one Reel within 14 days of delivery.",
"brief": "…",
"product_ids": ["prod_…"],
"commission_type": "percent", "commission_value": 1500, // boost: basis points or cents
"flat_fee_cents": 15000, // flat_fee: per accepted creator
"slots": 12, "deadline": "2026-10-01",
"requirements": ["10k+ followers"], "deliverables": ["1 Reel", "Tracked link in bio for 30 days"],
"deliverable_kinds": { "1 Reel": "post", "Tracked link in bio for 30 days": "link_in_bio" }, // optional: read from the wording when left out
"is_public": true, "status": "open"
}Stats & events
Totals, commission by status, a daily series, and top affiliates.
The inbound tracking log: every webhook, pixel call, and API postback with its outcome (processed | duplicate | unattributed | rejected | error) and message. Start here when an order is missing.
Tracking endpoints
Redirect
GET https://partnely.app/r/{code} logs a click and 302-redirects to the link’s destination with pn_ref={code} and pn_click={click id} appended. On a Stripe store, and for a Payment Link destination, the click id is also set as client_reference_id when the destination has none. Incoming query parameters ride along: utm_* replace the destination's own, others are added only when the destination has none, and pn_* are never taken from the visitor. A switched-off link, or one whose creator is not approved in the program, redirects without logging a click or adding pn params. HEAD redirects without logging a click.
Snippet
<script src="https://partnely.app/pn.js?k=pk_…" async></script> keeps the latest pn_click and pn_ref from the landing URL for the brand’s attribution window, writes them to the Shopify cart as note attributes, and exposes window.Partnely.track(order), which posts the order with the stored reference to POST /api/track/conversion with the public key. It sends when a click or link code is stored or the order carries coupon_codes, and retries once after Retry-After or a network error. The confirmation page usually runs before the async snippet has loaded, so queue the order instead of calling track: window.Partnely = window.Partnely || {}; (window.Partnely.q = window.Partnely.q || []).push(order). The snippet sends queued orders when it loads, and the queue keeps working afterwards.
An optional inline line before the tag, <script>window.Partnely = Object.assign(window.Partnely || {}, { landing: location.href });</script>, hands the landing address to the snippet, which Safari's private browsing otherwise hides from a script served by another site. With consent, the click is also kept in first-party cookies pn_click, pn_ref and pn_store on the store's own site (its website or Shopify domain and their subdomains) for the window, so another subdomain and server-side plugins can read it. On stores other than Shopify, the stored click is added as client_reference_id to Stripe Payment Link links, Buy Buttons and Pricing Tables that have none.
Consent: without an attribute the snippet stores the reference on the shopper's device at once, except on a Shopify store, where it follows Shopify's Customer Privacy API and waits until the store's banner reports that marketing is allowed. With data-consent="wait" on the script tag that loads pn.js (not on the inline line before it) it stores nothing until the page calls Partnely.grantConsent(); the click from the landing URL is held in memory until then. Partnely.revokeConsent() removes what was stored: storage, the cookies and the Shopify cart attributes; on Shopify, a banner answer that does not allow marketing does the same. If the cookie banner answers before the async snippet has loaded, set window.Partnely = window.Partnely || {}; window.Partnely.consent = true; instead of calling grantConsent(); the snippet reads it when it loads, and a queued order is kept.
Orders reported by the snippet are unverified (verified: false), carry the server's time instead of any occurred_at sent, and keep no metadata. Report each order one way, or send exactly the same order id from each (#1001 and 1001 count as the same): otherwise one sale is recorded twice. When a signed webhook or the order API later sends the same order id for a pending snippet order, the store's amount replaces the snippet's, and so does its attribution when it names a creator.
Webhooks by platform
Three platforms post orders straight to Partnely. Each brand's Install page shows its URL with the brand's public key attached. Every delivery must be signed with the webhook secret saved on the Install page: a store without a saved secret gets 401 webhook_secret_required, a signature that does not match gets 401 unauthorized, and nothing is recorded or reversed. Without ?key=, the ?brand={id} parameter and the X-Shopify-Shop-Domain and X-WC-Webhook-Source headers only match a store when its saved secret verifies the signature.
POST https://partnely.app/api/webhooks/shopify?key=pk_…. Topicsorders/create,orders/paid,orders/updated,orders/cancelled,refunds/create. Paid and authorized orders are recorded; an order awaiting payment (bank transfer, cash on delivery) is recorded onceorders/paidarrives. A test order is recorded and reversed at once, so it shows that tracking works but never earns. Items added to a pending order in the store raise its commission once the order is paid; items removed after payment lower it when the store refunds the difference. A custom pixel oncheckout_completed(shown on the Install page) reports the click for Buy it now and express checkouts to/api/track/conversionwith Shopify's order id, and a signed order that arrives after the report settles it. When the signed order arrived first (within a day), a report that carries a click recorded before that order is recorded unverified with Shopify's order id, amount and currency; any other report of that order records nothing. Signature: base64 HMAC-SHA256 inX-Shopify-Hmac-Sha256.POST https://partnely.app/api/webhooks/woocommerce?key=pk_…. Topicsorder.created,order.updated,order.deleted, andsubscription.createdon stores running WooCommerce Subscriptions. Orders inprocessingorcompletedare recorded;pendingandon-holdorders (awaiting payment) are recorded when anorder.updatedmoves them toprocessingorcompleted.cancelled,refundedandfailedreverse a pending order, so does a deleted or trashed one, and refunds listed on anorder.updatedcount as refunds. Attribution reads a creator's code incoupon_lines, thenpn_click/pn_reforder meta (copied from the snippet's cookies by the order meta code on the Install page), then the_wc_order_attribution_session_entrylanding URL, which WooCommerce keeps only for the visit that ends in the order. A renewal finds its first order through_subscription_renewalwith thepn_subscription_idandpn_subscription_parentmeta that code writes, or thesubscription.createdwebhook. WooCommerce's unsigned URL check (webhook_id=…) is answered without recording anything. Signature: base64 HMAC-SHA256 inX-WC-Webhook-Signature.POST https://partnely.app/api/webhooks/stripe?key=pk_…. Eventscheckout.session.completed,checkout.session.async_payment_succeeded,charge.refunded,invoice.paid,invoice_payment.paid. An unpaid completed session waits for the async payment. Subscriptions: a session or invoice that paid nothing (a free trial) is not an order; the first paid invoice is the subscription's first order, carrying the trial's click; later invoices are renewals.invoice_payment.paidlinks a subscription payment to its order socharge.refundedfinds it. A test-mode event is logged and records nothing. Attribution reads a creator's code inmetadata.couponor a promotion code whose id (promo_…) is saved on the creator's membership, thenmetadata.pn_click,metadata.pn_ref, thenclient_reference_id(a click id when 20+ characters, otherwise a link code). Signature:Stripe-Signaturewith a five-minute tolerance.
Refunds and cancellations
A store refund or cancellation reverses a pending order (reversed_by: "platform"). A partial refund on a pending order lowers its commission and platform fee in proportion to the share of the subtotal refunded, measured on the same basis (tax and shipping out; a Shopify refund of a converted order in the shop's currency, taken off at the order's fixed rate); a redelivered refund is counted once, and on Shopify and Stripe a refund or cancellation that arrives just before its order is applied when the order arrives. Once an order is approved, refunds and cancellations change nothing: they are written to the event log and the commission stays as it was.
Every other platform
BigCommerce, Squarespace, Wix, Magento, plain WordPress and custom stacks use the snippet on the thank-you page or POST /api/v1/conversions from the server. Same attribution rules, same ledger. The Install page shows the exact code for the platform the brand selected. Link tracking needs the confirmation page on the store's own domain or a subdomain of it. The BigCommerce and Squarespace confirmation pages cannot read discount codes, so orders there with only a creator's code need the order API.
Rate limits
The tracking endpoints, the API and the webhooks are limited per minute, well above what real traffic needs. Over a limit the answer is 429 with Retry-After in seconds: rate_limited JSON from /api/v1, /api/track and the webhooks, a short page from /r when one address opens too many links, and a script comment from /pn.js.
/r/{code}: per address (429), and per address on one link: past that the visitor still lands on the store, without attribution and without a click./pn.js: per address only./api/track/conversion: per address. Per store, only postbacks that record nothing (invalid, unattributed or duplicate) count, and past that limit a new attributed order is still accepted./api/v1: per address, per store, and failed keys per address.- Webhooks: only refused deliveries are limited per sending address; signed deliveries are limited per store, counted after the signature verifies.
An IPv4 address counts on its own; an IPv6 address counts by its /64.
Attribution rules
For each incoming order, hints are tried in this order and the first that resolves to an approved creator or affiliate in the program wins:
- a coupon code that belongs to an approved creator's membership in the program (with several, the first on the order), or a Stripe promotion code id saved on one; it wins over any click
- clicks: every click id on the order is checked (explicit
click_idfrom the API or snippet, thepn_clickorder attribute written to the Shopify cart or WooCommerce order meta, andpn_clickin the landing URL, Shopifylanding_siteor WooCommerce_wc_order_attribution_session_entry). The approved one with the latest click inside the brand’s attribution window, measured to the order time, wins; on a tie, the source listed first. - link codes (
ref,pn_ref), only where no click decided: a link code travels with its click id, and when that click is recorded for this brand the click decides, pass or fail. So a click past the window never earns through the link code beside it. A link code counts when its source has no click id, or the click is not recorded, and only while the link is switched on. - a subscription renewal, when the store pays commission on renewals: the creator credited with the subscription's first order, with no window
A general store code that belongs to no approved creator does not count, and the clicks decide. Switching a link off stops new clicks and orders that carry only its link code; orders from clicks made while it was on still earn within the window. Pausing the creator's membership stops all earnings.
Each order is credited to one creator. Orders deduplicate on (brand, order_id). Orders are pending until approved, and approved orders are final. When nothing resolves, the event log records every reason.
Commission rules
Precedence per order line: product rate → category rate → campaign rate → affiliate override → brand default. A closed campaign's rate applies only to orders placed before it closed.
- The base is what the shopper paid for products: after discounts, excluding tax (VAT or sales tax) and shipping, on every platform. Gift cards count only when the store pays commission on gift cards; renewals of a subscription earn only when the store pays commission on renewals.
- Percent rates apply to the line total after its discounts (or the subtotal when no line items are sent). Line totals never add up to more than the subtotal.
- A fixed rate at the product or category level pays per unit on a line the shopper paid something for; at the campaign, affiliate or brand level it pays once per order.
- Orders worth nothing (a base of 0, such as an order fully discounted by a creator's code): a fixed rate at the campaign, affiliate or brand level pays on them while the store's setting “Pay a fixed per-order commission on orders worth nothing” is on, the default. Percent rates and per-unit product and category amounts never pay on them.
- A category rate applies to a line whose catalog product has no rate of its own and is in that collection, type or category. When several match, the one that pays most on that line applies; on a tie, the oldest rate.
- Line items are matched to your catalog by the platform’s
product_id, then bysku. Unmatched lines use the campaign, affiliate or brand rate. - The program's terms in force when the order was placed apply, whenever the order arrives: the default rate, the attribution window, renewals, gift cards and fixed commission on orders worth nothing. An order placed before an announced change takes effect keeps the old terms even when it is recorded after.
- Rates are fixed when the order is recorded. When a pending order is edited in the store, its lines keep the rates recorded for them (matched by product and SKU, so each variant keeps its own) and the platform fee keeps its rate; a line the edit adds, a new variant included, takes its product or category rate, else the order's base rate. Changing a rate or the plan later never changes a recorded order.
- The full breakdown is stored on each conversion under
metadata.commission: per line theratewith itslevel(product,category,campaign,affiliateorbrand),product_keyandsku, plusbase(the rate of lines without a product or category rate),fixed_on_free_ordersandfee_bps.
Currency
The store's currency is the program's currency. Once it is confirmed (chosen under Settings, or set by a Shopify store's first order), an order in another currency is converted into it when the order is recorded, at the ECB euro reference rate published on or before the order's date, crossed through the euro. That rate is fixed for the order. Commission (fixed amounts included), the platform fee, the minimum payout and payouts are then all in the store's currency.
metadata.originalkeeps the order as it came:currency,subtotal_centsandgift_card_centsin that currency,rate_micros(units of the store's currency per unit of the order's, × 1,000,000),from_eur_microsandto_eur_micros(units of each currency per euro, × 1,000,000, which the amounts are converted with),rate_dateandsource(ECB).- No rate from the seven days up to the order's date (a currency the ECB does not publish, or rates not yet fetched): the order is recorded in its own currency with
metadata.fx_pending: true. It cannot be approved until it is converted: bulk and automatic approval leave it, and approving it by hand answers 409exchange_rate_pending. The daily job converts it once a rate is stored, while it is still pending. - A store whose currency is not confirmed records each order in its own currency, and the Install page names orders in other currencies.