← All docs

The merchant API

Everything the app shows you about your affiliate program, readable from your own code, plus the handful of decisions worth automating. It is a plain HTTPS API: one header, JSON in, JSON out, no SDK to install and nothing to sign.

The base URL is https://app.sproutaffiliate.com, and every path below hangs off /api/v1/.

The whole thing, end to end
1. Create a key in Settingsonce
2. Send it as Authorization: Bearer sk_sprout_…every call
3. Read ok, then data or errorevery reply

The key decides which store you reach. There is no shop parameter anywhere in this API, because a key that could name a different store would be a key that could read one.

What it is for

Three jobs, roughly. Getting your affiliate figures into somewhere else you already look, a warehouse, a Google Sheet, an internal dashboard. Keeping another system in step, a CRM that should know who your affiliates are and what they have earned. And automating a decision you currently make by hand, most often approving referral orders that meet a rule of your own.

It is a merchant API. Every call is made by you, about your own store, with a key you created. There is nothing here for an affiliate: affiliates have their portal and their own app, and neither goes through this.

The phone apps use different endpoints, and you should not. The two iPhone apps talk to /api/mobile/, which exists to serve those apps and changes shape whenever they need it to. It is not documented, not versioned, and not stable. Everything on this page is under /api/v1/, which is the opposite promise: see What v1 promises.

A few things the API deliberately does not do. It cannot create or edit a program, because programs are built in the admin where the plan gates, the product pickers, and the discount rewrites live, and a program written from outside those is a program the admin would refuse to save. It cannot create an affiliate: people join through your signup page. And it does not hand out an affiliate’s stored payout details, the PayPal address, bank account, or postal address they entered in their own portal. The affiliate endpoints tell you only whether an account is on file. The one place a destination does appear is a payout you already made, where it is a record of your own payment rather than a lookup of their details.

Creating a key

  1. Open Sprout Affiliate in your Shopify admin, then Settings in the left menu.
  2. Along the top of the Settings page, click the Developer tab.
  3. In the API keys card, type a name that says what will be using it, "Warehouse sync" or "Zapier", not "key 2". A leaked key has to be findable, and the name is what you will be looking at.
  4. Set Access to Read only or Read and write. Pick Read only unless the thing you are building actually needs to change something.
  5. Click Create key.
  6. Copy the secret. It looks like sk_sprout_ followed by 32 characters.
The secret is shown once and never again. Sprout Affiliate stores only a sha256 of it and the first few characters, which is enough to tell two keys apart in a list and nowhere near enough to use. Nobody can read it back to you, us included. Lose it and you revoke that key and create another.

The list shows each key's name, its prefix, its scope, when you made it, and when it was last used. Last used is stamped at most once a minute, so it answers "is anything still calling with this" without a database write on every request. Check it before you revoke something.

Revoking is immediate and permanent. The next call with that key gets a 401. Revoked keys are kept rather than deleted, because an audit of what happened has to be able to name the key that did it.

Make one key per integration. They are free, and the point of separating them is that you can kill one without taking down the others, and the per-key rate limit means one integration going wrong cannot lock the rest out.

Authenticating

One header, on every request:

Authorization: Bearer sk_sprout_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The examples below assume you have put the key in your shell:

export SPROUT_KEY=sk_sprout_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Nothing else authenticates. No shop parameter, no signature, no OAuth dance. A missing header, a header that is not a Bearer token, or a token that does not start with sk_sprout_ is a 401 before anything is looked up.

A revoked key and a key that never existed return the same message, "Invalid API key.", on purpose: the endpoint must not be usable to find out which keys a store ever had.

Treat it like a password, because it is one. Server side only. A key in browser JavaScript, a mobile app binary, or a public repository is a key anyone can read your affiliates and, on a write key, move your money with.

The response envelope

Every reply from every endpoint, success or failure, is JSON in one of two shapes. There is no third.

Success

{ "ok": true, "data": { ... } }

Failure

{ "ok": false, "error": "This key is read only." }

So ok is the only thing you have to branch on, and error is always a sentence, not a code you have to look up. The HTTP status carries the same verdict: any 2xx has data, anything else has error.

The content type is always application/json; charset=utf-8. Endpoints that only read refuse other methods inside the envelope too, so a POST where a GET belongs gets a 405 with an error string, never Remix's own HTML error page with a stack trace in it.

Money is a plain number, rounded to cents, in your store's payout currency, unconverted. Every response that carries an amount also carries the currency it is in, so you never have to assume. Dates are YYYY-MM-DD in your shop's timezone, the same calendar day the admin shows for the same row: an evening sale in a store behind UTC is that day's sale, not tomorrow's.

Read keys and write keys

A key is one or the other, chosen when you create it and not changeable afterwards. To change a key's scope, revoke it and make a new one.

ScopeCan doCannot do
readEvery GET on this pageAnything that changes a stored value. Refused with 403 This key is read only.
writeEverything a read key can, plus the five endpoints that change data, including running a payoutEdit a program, create an affiliate, or read an affiliate’s stored payout details. A write key is powerful, but it is not unlimited.

Five endpoints need a write key, and every one of them touches money:

Each of those says so again in its own section. If what you are building only reads, use a read key and the question never comes up.

The rate limit

120 requests a minute, per key. Go over it and you get a 429 with "Too many requests. The limit is 120 a minute."

Per key rather than per store, deliberately. One integration in a retry loop must not lock a merchant out of their own other integrations, so a runaway key exhausts its own allowance and nothing else's. That is also the practical argument for a key per integration.

The window is a fixed sixty seconds that starts on your first request, not a rolling one. There is no Retry-After header and no remaining-quota header; wait a minute and carry on. If you are paging a large list, a small pause between pages is plenty: 120 a minute is well above what an honest sync needs.

Errors

Every failure is the same envelope with a sentence in error. The statuses you will actually meet:

StatusMeans
400Something in your request does not make sense. A range the merchant could not have picked, a date that is not YYYY-MM-DD, a bonus amount with three decimal places. The message names the field.
401No key, a malformed header, or a key that is revoked or was never real.
402The feature is on a higher plan. Only creating a bonus returns this.
403A read key was used on a write endpoint.
404The affiliate, program, or order you named is not on this store. It is the same answer for "does not exist" and "belongs to somebody else", which is the point.
405Wrong method for that path.
409The request is well formed but the thing cannot be done in its current state: an order that is not pending any more, an affiliate whose application has not been approved, an order number that matches more than one referral row.
429Over 120 requests in a minute on this key.
503Your live Shopify orders could not be read just now. Retry shortly.
500Something broke on our side. The message is always the bare "Server error.", with no internal detail in it.
A 503 is doing you a favour, and so is a null. Pending, approved, and rejected referrals are all computed from your live Shopify orders. When that read fails, the honest answer is not an empty list, because an empty list is indistinguishable from "there are none" and would be acted on. So GET /api/v1/orders refuses with a 503 rather than answering short, GET /api/v1/affiliates/:ref reports pending and approved as null with ordersAvailable: false, and analytics and programs answer from paid history with a flag saying so. Check those flags before you file a number as fact.

Every endpoint

Thirteen calls across ten paths. That is the whole surface.

EndpointScopeWhat it does
GET /api/v1/shopreadWhich store this key reaches, its plan, currency, and timezone
GET /api/v1/affiliatesreadYour affiliates
GET /api/v1/affiliates/:refreadOne affiliate, and what they are owed
PATCH /api/v1/affiliates/:refwriteChange their rate, status, note, or payout method
GET /api/v1/programsreadYour programs, their commission and discount setup, and their totals
GET /api/v1/ordersreadReferral orders and your decision on each
POST /api/v1/orders/:id/approvewriteApprove a pending referral
POST /api/v1/orders/:id/rejectwriteReject a pending referral
GET /api/v1/bonusesreadUnpaid bonuses
POST /api/v1/bonuseswriteGrant a bonus
GET /api/v1/payoutsreadPaid payout batches, and who was in each
POST /api/v1/payoutswriteRun a payout, recorded by hand or sent through PayPal
GET /api/v1/analyticsreadThe four headline figures, the chart behind them, and the two tables

GET /api/v1/shop read

The credentials check, and the call to make first. It tells you which store the key in your hand actually reaches, what that key may do, and the currency and timezone every other figure in this API is expressed in. No parameters.

Request

curl -s https://app.sproutaffiliate.com/api/v1/shop \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "shop": "kodak-supply.myshopify.com",
    "name": "Kodak Supply",
    "storefrontDomain": "kodaksupply.com",
    "plan": "Growth",
    "currency": "USD",
    "timezone": "America/New_York",
    "country": "US",
    "key": { "id": "cm4q8x2v70001l908h3fz2k1p", "scope": "read" }
  }
}
FieldWhat it is
shopYour .myshopify.com domain. This is the store the key reaches, and there is no way to make it reach another.
name, storefrontDomainYour store name and public domain, as your affiliates see them. Both are synced from Shopify, so they can lag a rename by one admin page load.
planFree, Growth, Professional, or Enterprise. null means your subscription could not be read at that moment, which is a billing blip and not the same as being on Free. Do not treat a null as a downgrade.
currencyEvery money amount anywhere in this API is in this currency, unconverted.
timezoneThe calendar every date in this API is cut on. UTC until Shopify's timezone has synced once.
countryYour store's country code, or null.
keyThe id and scope of the key you just used. A quick way for a script to check it has the write key it thinks it has before it tries to write.

GET /api/v1/affiliates read

Your affiliates, oldest first, in the order they joined.

ParameterWhat it does
statusOnly affiliates with this status: unverified, pending, active, or inactive. Case is ignored. Leave it off for all of them.
programOnly affiliates on this program, by slug, matched exactly. Leave it off for all programs.
limit1 to 250, default 100. Anything higher is clamped to 250, anything lower or unreadable becomes the default.

Request

curl -s "https://app.sproutaffiliate.com/api/v1/affiliates?status=active&limit=2" \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "affiliates": [
      {
        "ref": "k7m2p9xd",
        "name": "Sarah Chen",
        "email": "sarah@example.com",
        "status": "active",
        "program": "creators",
        "code": "SARAH10",
        "rate": 12,
        "payoutMethod": "paypal",
        "createdAt": "2026-03-14"
      },
      {
        "ref": "nicklaus",
        "name": "Nicklaus Reed",
        "email": "nick@example.com",
        "status": "active",
        "program": "creators",
        "code": null,
        "rate": 10,
        "payoutMethod": "store-credit",
        "createdAt": "2026-05-02"
      }
    ],
    "count": 2
  }
}
FieldWhat it is
refTheir link name, the value in ?ref=. This is the id every other endpoint takes for an affiliate.
statusunverified (signed up, email not confirmed), pending (waiting on your approval), active, or inactive (paused: cannot sign in, stops earning, history kept).
programThe program slug they are on. Names and settings for it are in GET /api/v1/programs.
codeTheir personal discount code, or null when they are link-only.
rateTheir commission rate as a percentage, so 12 means 12%.
payoutMethodpaypal, venmo, bank, check, or store-credit. null if they have not chosen. The account itself is never returned.
createdAtThe date they were created, YYYY-MM-DD.
countHow many rows this response carried. It is not how many you have.
This list does not page. There is no cursor here, and limit caps at 250, so a store with more than 250 affiliates cannot reach the rest from this endpoint. Narrow with program or status and make several calls. Referral orders, the list that genuinely grows without limit, does page.

GET /api/v1/affiliates/:ref read

One affiliate, plus the four money figures their own page in the admin shows for them, plus any bonus they are owed.

:ref is the link name. A ref that has since been renamed still resolves, to whoever moved away from it, so an integrator that stored the old name is not broken by you renaming someone. A ref that is currently somebody's live link name always wins over an alias.

Request

curl -s https://app.sproutaffiliate.com/api/v1/affiliates/k7m2p9xd \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "affiliate": {
      "ref": "k7m2p9xd",
      "name": "Sarah Chen",
      "email": "sarah@example.com",
      "status": "active",
      "program": "creators",
      "programName": "Creators",
      "code": "SARAH10",
      "rate": 12,
      "payoutMethod": "paypal",
      "payoutSet": true,
      "note": "Introduced by Nicklaus. Ships her own samples.",
      "createdAt": "2026-03-14"
    },
    "currency": "USD",
    "ordersAvailable": true,
    "totals": {
      "pending": 84.5,
      "approved": 212.4,
      "paid": 1940.15,
      "imported": 610,
      "unpaidBonuses": 50
    },
    "bonuses": [
      { "id": "cm4qa1p3k0007l908d5yv8w2r", "amount": 50, "note": "Best April post" }
    ]
  }
}

The affiliate object is the same field names the list endpoint returns for the same things, so a client can read a row from either one, with three additions: programName (the program's display name), payoutSet (whether there is an account on file to pay, without returning the account), and note (your private note, which the affiliate never sees).

TotalWhat it counts
pendingReferred, not yet approved by you.
approvedApproved and owed, waiting for a payout run.
paidEverything this store has ever paid them, whichever app was tracking at the time.
importedThe part of paid that came over from a previous affiliate app, money Sprout Affiliate never handled. Called out separately so you can reconcile the two figures without guessing why they differ.
unpaidBonusesGranted money no payout has covered yet. It is owed on top of approved, not part of it.
ordersAvailable: false means pending and approved are null. Both come from a live read of your Shopify orders, and when that read fails the answer is null rather than 0. "We could not ask" and "you are owed nothing" are different answers and only one of them is safe to pay on. Nothing else on the response is affected.

PATCH /api/v1/affiliates/:ref write

Change one affiliate. Send a JSON object with only the fields you want changed; anything you leave out is left exactly as it was. At least one recognised field is required, or you get a 400 Nothing to change.

FieldAccepts
rateA number from 0 to 100, as a percentage. This is money: every referral of theirs from now on is calculated at it. Referrals already recorded keep the rate they were locked in at.
status"active" or "paused". A paused affiliate cannot sign in and stops earning; their history is kept. "inactive" is accepted as the same thing, and is the word a read returns.
noteText. Your private note, never shown to the affiliate. Stored truncated at 2000 characters, and the response carries what was kept so you never have to assume.
payoutMethodpaypal, venmo, bank, check, or store-credit. This changes how they are paid, never where: the account on file belongs to them, was only ever entered by them, and is carried across untouched.

Request

curl -s -X PATCH https://app.sproutaffiliate.com/api/v1/affiliates/k7m2p9xd \
  -H "Authorization: Bearer $SPROUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rate": 15, "note": "Bumped for Q3 campaign."}'

Response

{
  "ok": true,
  "data": {
    "changed": ["rate", "note"],
    "affiliate": {
      "ref": "k7m2p9xd",
      "name": "Sarah Chen",
      "email": "sarah@example.com",
      "status": "active",
      "program": "creators",
      "programName": "Creators",
      "code": "SARAH10",
      "rate": 15,
      "payoutMethod": "paypal",
      "payoutSet": true,
      "note": "Bumped for Q3 campaign.",
      "createdAt": "2026-03-14"
    }
  }
}

changed lists only what actually moved. Setting a field to the value it already had is not a change and will not appear, so a request whose every field already matched returns 400 Nothing to change. rather than a silent no-op. The affiliate object is the row as it now stands.

Three refusals worth planning for:

GET /api/v1/programs read

Every program, with its commission setup, its customer discount setup, and its all-time totals. Read only: programs are created and edited in the admin.

ParameterWhat it does
slugJust this one program.
activetrue for live programs, false for paused ones. Leave it off for all of them. Only those two literal words filter; anything else is ignored rather than quietly meaning "paused", so a typo cannot hide your live programs.
limit1 to 250, default 100.

Request

curl -s "https://app.sproutaffiliate.com/api/v1/programs?slug=creators" \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "programs": [
      {
        "slug": "creators",
        "name": "Creators",
        "active": true,
        "isDefault": true,
        "isReferral": false,
        "cookieDays": 30,
        "commission": {
          "type": "percent",
          "rate": 10,
          "flatAmount": 0,
          "tiers": null,
          "tierBasis": null,
          "tierReset": null,
          "cycleEvery": null,
          "resetDate": null,
          "resetWeekday": null,
          "resetMonthday": null,
          "resetTime": null,
          "productScope": "all",
          "products": [],
          "productRates": [],
          "typeLabel": "Percent of sale",
          "amountLabel": "10%"
        },
        "discount": {
          "mode": "code",
          "percent": 10,
          "scope": "all",
          "products": [],
          "usageLimit": 0,
          "oncePerCustomer": false,
          "requiresApproval": false
        },
        "payoutMethods": ["paypal", "store-credit"],
        "stats": {
          "affiliates": 24,
          "referralOrders": 391,
          "salesDriven": 28104.6
        }
      }
    ],
    "count": 1,
    "currency": "USD",
    "defaultProgram": "creators",
    "unpaidOrdersIncluded": true
  }
}
FieldWhat it is
isDefaultThe program a bare signup link joins when it names none.
isReferralA customer referral program (refer-a-friend), whose signups are approved instantly, rather than a vetted affiliate program.
cookieDaysThe attribution window in days. 0 means no expiry, null means it uses your shop-wide setting.
commission.typepercent of the sale, or flat per referred order.
commission.rateThe percentage, and the first rung's rate when the program is tiered.
commission.tiersThe ladder, as { upTo, rate }, or null when there is no ladder. Present only when tiered, so an empty ladder and a flat rate can never be confused for one another.
commission.tierBasisWhat the ladder measures: count of orders or their value. null without a ladder.
commission.tierResetnever, weekly, monthly, quarterly, yearly, or cycle. The four reset* fields and cycleEvery spell out when.
commission.productScopeall products earn, or include for only the items in products. productRates holds per-product overrides of the rate.
typeLabel, amountLabelThe exact two strings the admin's own Programs table prints, for example "Tiered by order count" and "8-15%". Use them and your screen describes the program the way the merchant's screen does.
discount.modenone (no customer discount), code (a personal code to share), or link (that code auto-applies through the share link).
discount.usageLimitRedemptions allowed per code. 0 is unlimited.
payoutMethodsThe methods this program's signup form offers. Empty means all of them.
statsOver all time, in your shop's own calendar: affiliates on the program (active and inactive, not applicants), referral orders, and sales driven. Counted by the one definition the admin's Programs page shares, so your number and the merchant's are the same number.
unpaidOrdersIncluded: false means the stats understate. Shopify could not be reached, so the totals cover paid history only and the live unpaid orders are missing from them. It is said out loud rather than quietly returning a smaller number.

GET /api/v1/orders read

Every referral order on the store with your decision on each, newest first. This is the list that grows forever, so it is the one with a cursor.

ParameterWhat it does
statuspending, approved, paid, rejected, or all (default). Anything else is a 400.
from, toYYYY-MM-DD, both inclusive, on the order's own day in your shop's timezone. A from after a to is a 400.
limit1 to 250, default 100.
cursorThe nextCursor from the previous page. A cursor that is not one of ours is a 400, rather than quietly handing you page one again, which a loop would read as an endless list.

Request

curl -s "https://app.sproutaffiliate.com/api/v1/orders?status=pending&limit=1" \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "currency": "USD",
    "orders": [
      {
        "orderId": "gid://shopify/Order/5512847360101",
        "order": "#1842",
        "date": "2026-08-24",
        "createdAt": "2026-08-24T19:41:08Z",
        "affiliateRef": "k7m2p9xd",
        "affiliateName": "Sarah Chen",
        "customer": "Dana Whitfield",
        "sale": 168,
        "commission": 20.16,
        "status": "pending",
        "holdReason": "",
        "paidAt": null
      }
    ],
    "count": 1,
    "hasMore": true,
    "nextCursor": "MjAyNi0wOC0yNFQxOTo0MTowOFogZ2lkOi8vc2hvcGlmeS9PcmRlci81NTEyODQ3MzYwMTAx"
  }
}

To read the whole list, loop until nextCursor is null. It is null on the last page precisely so you can loop on that instead of comparing counts.

cursor=""
while :; do
  page=$(curl -s "https://app.sproutaffiliate.com/api/v1/orders?limit=250&cursor=$cursor" \
    -H "Authorization: Bearer $SPROUT_KEY")
  echo "$page" | jq -c '.data.orders[]'
  cursor=$(echo "$page" | jq -r '.data.nextCursor // empty')
  [ -z "$cursor" ] && break
done
FieldWhat it is
orderIdThe id approve and reject take. Keep it: a refunded order can carry adjustment rows that share its order number, and only the full id names one row unambiguously.
orderThe Shopify order number, as printed, for example #1842.
dateThe order's day in your shop's timezone. This is the date from and to filter on, and the date the admin shows.
createdAtThe exact timestamp, or null.
sale, commissionThe sale and what the affiliate earns on it, in currency.
statuspending, approved, paid, or rejected. An approved referral that owes nothing, a free-product referral, reads as paid, which is the word the admin and the phone both use for it: counting paid referrals here must not give a different answer than the merchant is looking at.
holdReasonWhy it is waiting on something other than you. Test order, Order canceled, Possible self-referral, or Payment pending and the like. Blank when it is simply unjudged.
paidAtWhen it was paid, or null.
This endpoint returns 503 rather than an empty list. Pending, approved, and rejected are all computed from your live Shopify orders, and paid includes the settled ones, so a failed live read would empty every bucket at once. A short list here would be taken as fact and acted on, so it refuses instead: 503 Live orders couldn't be read right now. Retry shortly.

POST /api/v1/orders/:id/approve write

This moves money. Approving does not send anything by itself. It moves the referral into the payable queue, which is exactly what the next payout run pays out. Needs a write key.

:id is the orderId from GET /api/v1/orders. A plain Shopify order number is accepted too, as long as it names exactly one referral row; when it matches more than one you get a 409 asking for the full id, because guessing which row was meant would move the wrong commission.

Only a pending order can be approved. There is no body.

Request

curl -s -X POST \
  "https://app.sproutaffiliate.com/api/v1/orders/gid%3A%2F%2Fshopify%2FOrder%2F5512847360101/approve" \
  -H "Authorization: Bearer $SPROUT_KEY"

The order id contains slashes, so URL-encode it. A plain order number needs no encoding:

curl -s -X POST https://app.sproutaffiliate.com/api/v1/orders/1842/approve \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "order": {
      "orderId": "gid://shopify/Order/5512847360101",
      "order": "#1842",
      "affiliateRef": "k7m2p9xd",
      "affiliateName": "Sarah Chen",
      "commission": 20.16,
      "sale": 168,
      "status": "approved"
    }
  }
}
RefusalWhy
404 That order isn't on this store.No referral row on your store carries that id. The same answer covers an id from another store.
409 A paid order can't be approved.It is not pending any more. The word in the message is the status it is actually in.
409 That order number matches more than one referral row.Use the full orderId.
503Live orders could not be read, so nothing can be decided. Nothing was written. Retry shortly.
405 Use POST.A GET on this path, answered in the envelope rather than as an HTML 404.

POST /api/v1/orders/:id/reject write

This moves money. Rejecting takes the commission off the affiliate, and credits your 2% usage fee on that sale back. It is reversible from the admin, where restoring the referral makes the fee owed again, but not from here. Needs a write key.

Identical to approve in every other respect: same :id, same "pending only" rule, no body, same refusals. Only a pending order can be rejected, and a paid one especially cannot: the affiliate already has the money, but the rejection would credit the fee back anyway.

Request

curl -s -X POST https://app.sproutaffiliate.com/api/v1/orders/1842/reject \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "order": {
      "orderId": "gid://shopify/Order/5512847360101",
      "order": "#1842",
      "affiliateRef": "k7m2p9xd",
      "affiliateName": "Sarah Chen",
      "commission": 20.16,
      "sale": 168,
      "status": "rejected"
    }
  }
}
A success can carry a warning, and you should surface it. The rejection landed, but crediting the 2% fee behind it did not. That is a real money difference: you are still paying a fee on a sale you rejected. The retry has already been attempted once before you are told. The message reads "Rejected, but crediting the 2% fee on it didn't go through. Check billing, or restore and reject it again." Do not treat a response carrying it as a clean call.
{
  "ok": true,
  "data": {
    "order": { "...": "..." },
    "warning": "Rejected, but crediting the 2% fee on it didn't go through. Check billing, or restore and reject it again."
  }
}

GET /api/v1/bonuses read

Unpaid bonuses, oldest first. Once a payout run pays one it leaves this list and turns up in that affiliate's ledger instead, so this is a list of what you still owe outside commission.

ParameterWhat it does
refOnly this affiliate's bonuses.
limit1 to 250, default 100.

Request

curl -s https://app.sproutaffiliate.com/api/v1/bonuses \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "bonuses": [
      {
        "id": "cm4qa1p3k0007l908d5yv8w2r",
        "ref": "k7m2p9xd",
        "name": "Sarah Chen",
        "amount": 50,
        "note": "Best April post"
      },
      {
        "id": "cm4qb7t2m0009l908k1qz4n6h",
        "ref": "nicklaus",
        "name": "Nicklaus Reed",
        "amount": 25,
        "note": ""
      }
    ],
    "count": 2,
    "matched": 2,
    "total": 75,
    "currency": "USD"
  }
}

count is what this page returned. matched and total cover every unpaid bonus the filter selects, not just this page, so a truncated page can never understate what your store owes. name is null when the affiliate has since been deleted: the debt outlives them.

POST /api/v1/bonuses write

This moves money. A bonus is a one-off amount you grant an affiliate. It creates a debt your store owes, and it joins that affiliate's next payout. Needs a write key, and the Professional plan.
FieldAccepts
refRequired. The affiliate's link name, which must be an active affiliate on your store.
amountRequired. Greater than 0, at most 100000, at most 2 decimal places. A number or a numeric string.
noteOptional. Trimmed to 200 characters.

Request

curl -s -X POST https://app.sproutaffiliate.com/api/v1/bonuses \
  -H "Authorization: Bearer $SPROUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ref": "k7m2p9xd", "amount": 50, "note": "Best April post"}'

Response, 201 Created

{
  "ok": true,
  "data": {
    "bonus": {
      "id": "cm4qa1p3k0007l908d5yv8w2r",
      "ref": "k7m2p9xd",
      "amount": 50,
      "note": "Best April post"
    },
    "currency": "USD"
  }
}
There is no idempotency key, so a retried POST grants a second bonus. If a call times out and you do not know whether it landed, do not fire it again blindly. Call GET /api/v1/bonuses first, and retry only if the bonus is genuinely missing.
RefusalWhy
402 Affiliate bonuses are on the Professional plan.Your plan does not include bonuses. The same gate the admin and the phone apply, so the three surfaces cannot disagree about who may grant one. An unreadable subscription is treated as a billing blip, not a refusal.
404 No such affiliate.That ref is not on your store.
400 That affiliate is not active.Pending, unverified, and paused affiliates cannot be granted a bonus.
400 Bonus amount can have at most 2 decimal places.Refused rather than rounded: paying out a figure you never asked for, with nothing downstream saying so, is worse than an error.

GET /api/v1/payouts read

The payout batches this store has paid, newest first, each with the affiliates in it and what each one was paid.

A batch here is money that left your account through Sprout Affiliate. History imported from a previous affiliate app is left out, exactly as it is on the Payouts page: including it would manufacture batches that never happened.

ParameterWhat it does
batchOne batch id. Returns just that batch, and adds the individual order lines behind each affiliate's amount. A full list of every line in every batch is a payload nobody asked for, so those appear only here.
limit1 to 100, default 25.

Request

curl -s "https://app.sproutaffiliate.com/api/v1/payouts?limit=1" \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "batches": [
      {
        "id": "cm4qc9w1x0003l908r2mp7d4s",
        "paidAt": "2026-08-19T18:00:04.512Z",
        "date": "2026-08-19",
        "currency": "USD",
        "via": "paypal",
        "method": "PayPal",
        "total": 486.2,
        "gross": 486.2,
        "taxTotal": 0,
        "taxType": "none",
        "orderCount": 31,
        "affiliateCount": 4,
        "affiliates": [
          {
            "ref": "k7m2p9xd",
            "name": "Sarah Chen",
            "method": "paypal",
            "destination": "sarah@example.com",
            "amount": 212.4,
            "gross": null,
            "tax": null,
            "orderCount": 14
          }
        ]
      }
    ],
    "count": 1,
    "currency": "USD"
  }
}
FieldWhat it is
viapaypal when Sprout Affiliate sent it, manual when you paid outside the app and recorded it here.
methodThe payout method's display label, or Mixed when one batch settled more than one method.
total, gross, taxTotalThe batch's net, its gross, and the tax between them. Equal, with a zero tax, when no payout tax was in force.
taxTypenone, vat_add, withholding, or mixed when affiliates in one batch were taxed differently.
affiliates[].amountWhat actually left for that affiliate: the net where a payout tax applied, the gross otherwise.
affiliates[].gross and .taxnull unless a payout tax applied to that affiliate. When it did, tax is { type, rate, amount } and gross is what they earned before it.
affiliates[].destinationWhere the payment went, as recorded on the batch: a PayPal address, or a readable summary for the other methods. null when nothing was recorded.
affiliates[].ordersOnly on a single-batch request. Each line is { order, commission }; a bonus has no order of its own and carries its reason instead.
The order lines add up to the gross, not to amount. That is deliberate, not a rounding bug: the ledger records the commission each order earned, while a payout tax changes what was sent. It is the same split the merchant's own batch page and the affiliate's invoice show, so all three agree with a PayPal statement.

POST /api/v1/payouts write

This moves real money, and one of its two modes cannot be undone by anybody. Nothing about this call is implied: the body must name the mode, there is no default, and a request that does not name one is refused.
FieldAccepts
modeRequired. "manual" records everyone currently owed as paid, because you paid them yourself outside the app. Nothing is sent anywhere, and the batch it returns can be undone from the admin. "paypal" sends the money through PayPal Payouts, immediately. It is gone the moment PayPal accepts the batch, and there is no undo, here or in the admin.
refOptional. One affiliate's ref, to pay only them. Omit to pay everyone approved and over the minimum. An unknown ref is a 404, not a run that quietly pays nobody and reports success.

"paypal" needs PayPal connected for the store and the Growth plan or above. Who is held back, and why, is decided by the same rules as every other payout run: the minimum payout, missing payout details, a missing tax form, an affiliate who is not active. Those rules are described in Payouts, and this endpoint does not change any of them.

Request

curl -s -X POST https://app.sproutaffiliate.com/api/v1/payouts \
  -H "Authorization: Bearer $SPROUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "manual"}'

Response

{
  "ok": true,
  "data": {
    "mode": "manual",
    "ref": null,
    "ran": true,
    "batchId": "manual-1755624004512",
    "undoable": true,
    "summary": "Recorded 486.20 to 4 affiliates across 31 orders.",
    "details": "Sarah Chen  $212.40 (14 orders)\nNicklaus Reed  $150.20 (9 orders)\n..."
  }
}
FieldWhat it is
ranWhether the run did anything. false with batchId: null is a success, not an error: either nothing was owed, or another run already holds the lock. No money moved.
undoabletrue only for a manual batch, which can be undone from the admin. A PayPal batch is never undoable.
summaryOne line saying what happened.
detailsThe full run breakdown: who was paid, and who was held back and why, whether that was no payout method, no tax form, under the minimum, or not active.
Only one payout can run for a store at a time, across every surface: this API, the admin, the phone app, and the schedule. A run that arrives while another is paying comes back ran: false rather than paying anybody twice.
RefusalWhy
400 on modeThe body did not name "manual" or "paypal". There is no default mode on a money endpoint.
400 PayPal not connected"paypal" mode on a store with no PayPal connection. Connect it in the Shopify admin first.
402"paypal" mode below the Growth plan. Pay by hand and record it with "manual", or upgrade. An unreadable subscription is treated as entitled rather than stopping a paying merchant's payout over a billing blip.
404The ref is not an affiliate on this store.
409Either the store's Shopify connection is not available (open the app in the Shopify admin once), or the run failed. No money moved.
A 502 here is the one error you must never answer with a retry. It means PayPal sent the batch and Sprout Affiliate could not record it: the money has already left, and the app does not know who it went to. The message names the batch id. Check that batch in PayPal, then contact support with the id. Running it again pays everybody a second time. The store's payout schedule is switched off automatically when this happens, for the same reason.

GET /api/v1/analytics read

The same four figures the Analytics page shows for the same window, the same chart series behind them, and the same Top affiliates and By program tables. Nothing is recomputed here: the rows come from the one definition of "a referred order" that the admin page and the Programs table both use, and the window and buckets from the code every chart in the product goes through. Your total and the merchant's total cannot drift apart.

ParameterWhat it does
range1 (today), 7, 30 (default), 90, 365, any day count up to 730, payout (since your last payout), all, or custom. Anything else is a 400 rather than a silent fallback: a page can afford to re-render on a hand-edited URL, an API cannot, because you would believe the dates you asked for.
from, toRequired when range=custom, as YYYY-MM-DD.
affiliateA ref, to narrow everything to one affiliate. 404 if they are not on your store.
programA program slug. 404 if it is not on your store.
limitHow many rows of topAffiliates: 1 to 100, default 15. byProgram is never truncated.

Request

curl -s "https://app.sproutaffiliate.com/api/v1/analytics?range=7&limit=2" \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{
  "ok": true,
  "data": {
    "range": {
      "from": "2026-08-18",
      "to": "2026-08-24",
      "days": 7,
      "preset": "7",
      "label": "Aug 18 - Aug 24"
    },
    "previousRange": {
      "from": "2026-08-11",
      "to": "2026-08-17",
      "label": "Aug 11 - Aug 17"
    },
    "timezone": "America/New_York",
    "currency": "USD",
    "filters": { "affiliate": "all", "program": "all" },
    "totals": {
      "referredOrders": 41,
      "referredSales": 6218.4,
      "commission": 683.02,
      "affiliatesWithSales": 9
    },
    "granularity": "day",
    "partialLast": true,
    "series": [
      { "key": "2026-08-18", "label": "Aug 18", "referredSales": 812.5, "commission": 89.38, "referredOrders": 6 },
      { "key": "2026-08-19", "label": "Aug 19", "referredSales": 1104, "commission": 121.44, "referredOrders": 8 }
    ],
    "topAffiliates": [
      { "ref": "k7m2p9xd", "name": "Sarah Chen", "referredOrders": 14, "referredSales": 2410.8, "commission": 289.3 },
      { "ref": "nicklaus", "name": "Nicklaus Reed", "referredOrders": 9, "referredSales": 1502, "commission": 150.2 }
    ],
    "byProgram": [
      { "program": "creators", "name": "Creators", "referredOrders": 34, "referredSales": 5488.4, "commission": 604.12 }
    ],
    "liveOrdersUnavailable": false
  }
}
FieldWhat it is
range, previousRangeThe window this response is about, and the equal-length window before it for a like-for-like comparison. label is the string the admin prints.
totals.referredOrdersReferred orders in the window. Adjustment rows carry money but are not orders, so a refund nets against the sale it corrects without adding a second one.
totals.affiliatesWithSalesAffiliates who sold in the window. This is the number behind the tile the admin labels "Active affiliates".
granularityhour for a single day, day, or month past about four months. Chosen the same way the admin's chart chooses it.
series[].keyThe machine-readable bucket. YYYY-MM-DD for a day, the month for a month, and YYYY-MM-DDTHH:00 for an hour. label is the human string the chart draws on the axis.
partialLasttrue when the window runs to today, so the last bucket is still filling. Say so on your own chart: read as finished, a half-done day looks like a collapse.
byProgram[].programThe program slug, or null for rows from affiliates on no program.
liveOrdersUnavailable: true means every figure here understates. The live unpaid orders could not be read, so the response covers paid history only. It is a flag rather than an error because the paid figures are still true; check it before you file any of these numbers as fact.

A single day is bucketed by hour and cut at the current hour on your shop's clock, so you never get hours that have not happened yet drawn as zeros.

Webhooks

The API is how you ask Sprout Affiliate something. A webhook is how it tells you, without being asked. Add an endpoint and Sprout Affiliate posts to it when something happens on your store.

  1. Open Sprout Affiliate in your Shopify admin, then Settings in the left menu.
  2. Click the Developer tab along the top of the page.
  3. In the Webhooks card, paste your https:// URL, tick the events you want, and click Add endpoint.
  4. Copy the signing secret. It is shown once and never again, the same as an API key.

Ten endpoints per store. Plain http:// is refused, and so are addresses on your own network, because a signed request is worth something and it should only ever leave for a host you control.

The events

EventWhen it fires
affiliate.createdSomeone signed up or was added as an affiliate.
affiliate.approvedAn affiliate was approved and can earn commission.
referral.createdA referred order came in.
referral.approvedA referred order was approved, so its commission is owed.
referral.rejectedA referred order was rejected, so no commission is owed.
payout.paidA payout was recorded as paid to one affiliate.
bonus.createdA bonus was granted to an affiliate.

Each payload carries the same shape the API returns for that object, so an integrator reading this page and an integrator handling a webhook are looking at one vocabulary. No API key, no PayPal credential, no password, and no tax identification number is ever sent, whatever the event.

What arrives

POST https://your-endpoint.example.com/sprout
Content-Type: application/json; charset=utf-8
Sprout-Affiliate-Event: referral.approved
Sprout-Affiliate-Delivery: 7f1c0f2a-...
Sprout-Affiliate-Timestamp: 1787000000
Sprout-Affiliate-Signature: sha256=9c1f...

{
  "id": "7f1c0f2a-...",
  "event": "referral.approved",
  "shop": "your-store.myshopify.com",
  "createdAt": "2026-08-27T19:20:00.000Z",
  "data": { }
}

Verifying it is really us

The signature is an HMAC-SHA256 of the exact bytes of the request body, keyed with that endpoint's signing secret, hex encoded, prefixed with sha256=.

Verify it against the raw body before you parse. Parsing and re-serializing changes the bytes, and the signature will not match a body your framework has rebuilt.

const crypto = require("crypto");

function verify(rawBody, header, secret) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  // Constant time, so a wrong signature cannot be found one character at a time.
  return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Two more things travel with it. Sprout-Affiliate-Timestamp is the same instant as createdAt inside the signed body, so you can cheaply reject anything older than a few minutes and then confirm the header against the signed copy. Sprout-Affiliate-Delivery does not change when we retry, so the same id arriving twice is the same event and the second one should be ignored.

Retries, and what failure looks like

We wait 8 seconds for an answer, and try three times in all, 1 second and then 4 seconds apart. A 2xx is a success and anything else is not.

We retry a timeout, a 408, a 429, and any 5xx. We do not retry other 4xx responses: those are your endpoint saying the request itself is wrong, and sending identical bytes again cannot fix that.

After that the delivery stays failed, with the status code and the error, and it is listed under the endpoint in the Developer tab with a Replay button. A replay sends the stored bytes exactly, same id and same signature, so it is safe to press twice.

Nothing in Sprout Affiliate waits on your endpoint. An affiliate is approved and a payout is recorded in exactly the same way whether you answer in 50 milliseconds, time out, or are down for a day. A slow endpoint costs you a retry, never a merchant a broken page.

Return quickly, then do the work. Acknowledge with a 200 as soon as you have the body, and process it afterwards. An endpoint that finishes its own job before answering is the one that hits the 8 second timeout and gets sent the same event twice.

What v1 promises

The version is in the path for one reason: so that something you build today keeps working tomorrow, without you watching a changelog.

Inside /api/v1/:

Anything that would break one of those goes in /api/v2/, on its own path, while v1 keeps answering. Your integration does not move until you move it.

The mobile endpoints carry no promise at all. /api/mobile/ exists to serve the two iPhone apps and changes shape whenever they need it to, sometimes in the same week. It is not part of this contract. If you find yourself calling one because v1 does not expose something you need, tell us instead: that is a gap in v1, and the answer is a v1 endpoint, not a private one you cannot rely on.

Rate limits and refusal wording are the exception to all of the above. Both may be tuned, so read ok and the HTTP status rather than matching on the text of error.