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.total is the full count.
  • Errors: { "error": { "code", "message", "details?" } } with a matching HTTP status. Codes: unauthorized 401, plan_limit 403, not_found 404, validation_error 400, bad_request 400, reason_required 400, invalid_state 409, order_locked 409, conflict 409, nothing_to_pay 409, currency_required 409, rate_limited 429, internal_error 500.
  • Requests are rate limited per address, per store, and failed keys per address. Over a limit the answer is rate_limited 429 with a Retry-After header in seconds. See Rate limits.

Brand

GET/me

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

GET/products?active=true

List products. Filters: active, q (text in the title, SKU, handle or vendor) and category (a kind and a key, e.g. collection:serums).

POST/products

Create one. Body: title, url, optional external_id, sku, price_cents, image_url, categories, commission_type, commission_value.

GET/products/:id
PATCH/products/:id

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.

DELETE/products/:id

Links to the product keep working and lose the association.

POST/products/import

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 in categories through the API or a CSV (the key is the name as a slug: Gift sets is gift-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 public https://{shop}/products.json feed. 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.json and each collection's products.json, leaving out all and frontpage, 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.warnings says so, and products keep the collections that were not read.
  • admin_api: the Admin REST API, when the brand saved a custom-app token (scopes read_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_cents and sku are null and external_id is the handle. data.warnings says 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.

GET/affiliates?status=pending

Members with profile and status.

GET/affiliates/:id
PATCH/affiliates/:id

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.

GET/links?affiliate_id=

Every tracked link with tracked_url and lifetime stats.

GET/clicks?link_id=&from=&to=&include_bots=false

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). currency is 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: true for orders from a signed platform webhook, the order API or the brand's own entry; false for 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_by and reversal_reason: who reversed the order, brand (by hand or through the API), platform (a store refund or cancellation) or system, 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; a subscription_id sent on a first order is kept as metadata.subscription_id. The snippet's postback keeps no metadata and ignores subscription_id. The commission breakdown and the rates the order was recorded with are added under metadata.commission; a converted order carries metadata.original, and an order waiting for an exchange rate metadata.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.

GET/conversions?status=&affiliate_id=&from=&to=
GET/conversions/:id
POST/conversions

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 replaces price × quantity as the line's base.
  • line_items[].gift_card (optional): true for 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 (also 1, yes, on), renewal_of (the subscription id) or renewal_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.
POST/conversions/:id/approve

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

POST/conversions/:id/reverse

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

GET/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.

POST/payouts

{ "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.

GET/payouts/:id

The payout and the conversions it covers.

PATCH/payouts/:id

{ "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.

GET/campaigns?status=open

List campaigns, each with stats (human clicks, orders, revenue_cents, commission_cents through campaign links) and applications (total, pending, accepted).

POST/campaigns

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

GET/stats?from=&to=&days=30

Totals, commission by status, a daily series, and top affiliates.

GET/events?outcome=unattributed

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_…. Topics orders/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 once orders/paid arrives. 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 on checkout_completed (shown on the Install page) reports the click for Buy it now and express checkouts to /api/track/conversion with 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 in X-Shopify-Hmac-Sha256.
  • POST https://partnely.app/api/webhooks/woocommerce?key=pk_…. Topics order.created, order.updated, order.deleted, and subscription.created on stores running WooCommerce Subscriptions. Orders in processing or completed are recorded; pending and on-hold orders (awaiting payment) are recorded when an order.updated moves them to processing or completed. cancelled, refunded and failed reverse a pending order, so does a deleted or trashed one, and refunds listed on an order.updated count as refunds. Attribution reads a creator's code in coupon_lines, then pn_click/pn_ref order meta (copied from the snippet's cookies by the order meta code on the Install page), then the _wc_order_attribution_session_entry landing URL, which WooCommerce keeps only for the visit that ends in the order. A renewal finds its first order through _subscription_renewal with the pn_subscription_id and pn_subscription_parent meta that code writes, or the subscription.created webhook. WooCommerce's unsigned URL check (webhook_id=…) is answered without recording anything. Signature: base64 HMAC-SHA256 in X-WC-Webhook-Signature.
  • POST https://partnely.app/api/webhooks/stripe?key=pk_…. Events checkout.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.paid links a subscription payment to its order so charge.refunded finds it. A test-mode event is logged and records nothing. Attribution reads a creator's code in metadata.coupon or a promotion code whose id (promo_…) is saved on the creator's membership, then metadata.pn_click, metadata.pn_ref, then client_reference_id (a click id when 20+ characters, otherwise a link code). Signature: Stripe-Signature with 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_id from the API or snippet, the pn_click order attribute written to the Shopify cart or WooCommerce order meta, and pn_click in the landing URL, Shopify landing_site or 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 by sku. 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 the rate with its level (product, category, campaign, affiliate or brand), product_key and sku, plus base (the rate of lines without a product or category rate), fixed_on_free_orders and fee_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.original keeps the order as it came: currency, subtotal_cents and gift_card_cents in that currency, rate_micros (units of the store's currency per unit of the order's, × 1,000,000), from_eur_micros and to_eur_micros (units of each currency per euro, × 1,000,000, which the amounts are converted with), rate_date and source (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 409 exchange_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.