← 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 API is on the Professional plan. Creating a key needs it, and so does every call: a store below Professional gets 402 on every path on this page, with "The API is on the Professional plan. Your keys are kept and start working again as soon as this store is on Professional." in error. A plan change never revokes a key, so an integration that stops on a downgrade starts again on the upgrade with the same key and no code change. A subscription we cannot read at that moment is treated as entitled, not as a downgrade, so a billing blip does not stop your calls. Plans and billing.
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 iPhone and Android 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. It cannot change how an affiliate is paid: the payout method is theirs, set by them in their portal. 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

Creating a key needs the Professional plan. Below it the API keys card shows the plan name and the Create key button is off, but nothing is taken away: keys already on the store stay listed, and you can still revoke one.

  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. A path no endpoint answers on gets a 404 in the same envelope. Nothing under /api/v1/ can hand you an HTML page or a third shape, whatever you send 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 eleven calls that change data, including running a payoutEdit a program, create an affiliate, change an affiliate’s payout method, or read an affiliate’s stored payout details. A write key is powerful, but it is not unlimited.

Eleven calls need a write key, and nine of them touch money or who gets it:

The other two, POST /api/v1/hooks and DELETE /api/v1/hooks/:id, add and remove webhook endpoints.

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.
402Your plan does not include this. Below the Professional plan every path on this page returns it, because the API itself is a Professional feature. Creating a bonus and a "paypal" payout run keep their own plan checks underneath that one. A subscription that could not be read is treated as entitled rather than refused.
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. A path under /api/v1/ that is not one of the ones below is a 404 too, in the same envelope: "No such endpoint."
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

Twenty-three calls across sixteen 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/affiliateswriteChange up to 250 affiliates at once: rate, status, or note
PATCH /api/v1/affiliates/:refwriteChange their rate, status, or note
POST /api/v1/affiliates/:ref/approvewriteApprove a pending application
POST /api/v1/affiliates/:ref/rejectwriteDecline a pending application
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/eventsreadCustom events you reported, and whether each is paid
POST /api/v1/eventswriteReport a custom event: a booked call, an app signup, a form, a trial start
GET /api/v1/deductionsreadUnpaid deductions
POST /api/v1/deductionswriteTake an amount off an affiliate's next payout
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/payouts/previewreadWhat the next payout would send, before you send it
GET /api/v1/payouts/schedulereadWhen automatic payouts run
PATCH /api/v1/payouts/schedulewriteChange when they run, or turn them off
GET /api/v1/analyticsreadThe four headline figures, the chart behind them, and the three tables
GET /api/v1/hooksreadYour webhook endpoints
POST /api/v1/hookswriteAdd a webhook endpoint for one event or several
GET /api/v1/hooks/:idreadOne webhook endpoint
DELETE /api/v1/hooks/:idwriteRemove a webhook endpoint

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, by the day they joined. Two people who joined on the same day hold a fixed order between them, so a page boundary falls in the same place every time you page.

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.
emailOnly the affiliate with this email address, matched exactly, ignoring case and spaces around it. An address nobody has is an empty list, not an error. Leave it off for everyone.
limit1 to 250, default 100. Anything higher is clamped to 250, anything lower or unreadable becomes the default.
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/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,
    "total": 38,
    "byStatus": { "unverified": 1, "pending": 4, "active": 38, "inactive": 3 },
    "hasMore": true,
    "nextCursor": "MjAyNi0wNS0wMiBjbTRxOHgydjcwMDAybDkwOGYyYnE0dzhu"
  }
}
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, store-credit, gift-card, cashapp, zelle, payoneer, revolut, skrill, bitcoin, or usdc, or a method of your own as own- and a name made from the one you gave it, such as own-interac-e-transfer. 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.
totalHow many affiliates match status and program across every page. The same on every page.
byStatusEveryone on program (or every program) counted by status, whatever status you asked for. To know how many affiliates you have, ask for limit=1 and read this: there is no need to page through them.
hasMoreWhether more affiliates matched than this page carried.
nextCursorPass it back as cursor to get the next page. null on the last page, so you can loop until it is null instead of comparing counts.
This list pages. limit still caps at 250, and a store with more affiliates than that reaches the rest with cursor: send back the nextCursor of the page before, and loop until nextCursor is null. status and program narrow the list before it is paged, so a filtered loop pages only the rows that matched.
cursor=""
while :; do
  page=$(curl -s "https://app.sproutaffiliate.com/api/v1/affiliates?limit=250&cursor=$cursor" \
    -H "Authorization: Bearer $SPROUT_KEY")
  echo "$page" | jq -c '.data.affiliates[]'
  cursor=$(echo "$page" | jq -r '.data.nextCursor // empty')
  [ -z "$cursor" ] && break
done

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"
    },
    "royalty": {
      "rate": 5,
      "amountPerItem": null,
      "products": [
        { "id": "gid://shopify/Collection/4417", "type": "collection", "title": "Sarah's capsule line", "since": "2026-09-01T00:00:00.000Z" }
      ]
    },
    "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).

Beside it, this endpoint returns one setting from the affiliate's page (see Royalties):

FieldWhat it is
royaltyThe products and collections this affiliate earns on with every sale, referred or not, and what each sale pays: rate (a percentage of each matching line) or amountPerItem (an amount for each item sold), never both. since is when each item was added: orders placed before it pay nothing on it. null when the affiliate has no royalty.
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.
payoutMethod is read only, here and everywhere else in this API. A PATCH carrying payoutMethod or payoutDetails is refused with 400 A payout method can only be set by the affiliate, in their portal. It is read only here., and nothing else in the same body is applied, because the whole body is checked before anything is written. A method is only half of a payout instruction: the account behind it is the affiliate's own, entered by them and never returned to you, so switching someone from PayPal to bank from out here would leave the next payout run holding an instruction it cannot carry out. The affiliate changes it in their portal, under Settings and then Payouts. Every affiliate read still reports the method they chose.

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:

POST /api/v1/affiliates/:ref/approve write

Approve an affiliate whose status is pending: somebody who applied and is waiting on you. It does exactly what Approve does in the app. The affiliate becomes active, the discount code your program and plan call for is created in your Shopify, and they are emailed that they are in. There is no body.

Only a pending application can be approved. Anybody else answers 404 with "Application not found."; an active affiliate is changed with PATCH instead. An applicant whose program has since been deleted is refused with 400 and a message saying to move them to a program first, because there is no discount to put on their code.

Request

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

Response

{
  "ok": true,
  "data": { "ref": "k7m2p9xd", "status": "active", "code": "SARAH10" }
}

code is the discount code that was created, or null when the program gives out none.

POST /api/v1/affiliates/:ref/reject write

Decline a pending application. The application is removed and the applicant is emailed that it was declined, the same as Decline in the app. This cannot be undone: they would have to apply again. There is no body, and anybody who is not a pending applicant answers 404.

Request

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

Response

{ "ok": true, "data": { "ref": "k7m2p9xd", "status": "declined" } }

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 programs taking signups right now, false for the rest. Leave it off for all of them. A program is taking signups when it is switched on and today is inside its start and end dates, the same rule its signup page follows: a program that has not opened yet, or has ended, comes back under false with paused ones. 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,
        "startDate": "2026-10-12",
        "endDate": null,
        "status": "open",
        "isDefault": true,
        "isReferral": false,
        "cookieDays": 30,
        "cookieHours": null,
        "stopAfterPurchase": false,
        "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%",
          "firstOrderOnly": false,
          "renewalMonths": null,
          "orderRules": [
            { "field": "tag", "values": ["vip"], "rate": 15 },
            { "field": "email", "values": ["@brightside.example"], "rate": 5 }
          ],
          "clickSplit": { "first": 40, "last": 40 }
        },
        "eventTypes": [
          { "key": "booked_call", "name": "Booked call", "amount": 25, "dailyCap": 2 }
        ],
        "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
activeThe program's own Status switch in the admin: false when you paused it. Its dates are separate, below.
startDate, endDateThe days the program opens and last takes signups, as YYYY-MM-DD on your store's calendar, or null for none.
statusWhere today falls against those dates, in your store's timezone: upcoming before the start date, ended after the end date, open otherwise. A program with no dates is always open. Read it with active: a paused program can still be open by its dates, and only one that is both active and open takes signups.
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.
cookieHoursThe attribution window in hours, when the program sets it in hours (for example 12). It then decides the window, and cookieDays is those hours rounded up to whole days. null means the window is in days.
stopAfterPurchasetrue when a click on the program's links credits one order, and a later order needs a new click. See Stop tracking after a purchase.
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.
commission.firstOrderOnlytrue when the program pays commission only on a customer's first order with your store. A returning customer's order earns nothing.
commission.renewalMonthsHow many months a referred subscription's renewals earn commission, counted from the subscription's first referred order. null means for as long as the subscription renews.
commission.orderRulesThe program's commission rules by customer, in order. field is tag (the customer has one of these Shopify tags) or email (the order's email is one of these addresses, or ends in one of these @domain entries). The first rule an order matches sets its percentage, unless the order used a discount code with a rate, is a renewal with a renewal rate, falls on a commission tier, or belongs to an affiliate with a rate of their own. [] when there are none.
commission.clickSplitThe program's split commission by clicks: when a customer clicked more than one of this program's affiliates before buying, first and last are the first and last click's shares, in percent of the commission, and the rest is shared evenly by the affiliates clicked in between. null when the program pays as your attribution setting credits.
eventTypesThe custom events the program pays for, each with the key you send to POST /api/v1/events as type, its name, the flat amount, and dailyCap, the most paid to one affiliate in one day (null is no limit). Empty when the program pays for none.
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, as payoutMethod above names them. Empty means the six every store started with: paypal, venmo, bank, check, store-credit, and gift-card.
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,
        "part": "order",
        "splitByClicks": false
      }
    ],
    "count": 1,
    "total": 6,
    "totals": {
      "pending": { "orders": 6, "sales": 1043.5, "commission": 125.22 },
      "approved": { "orders": 11, "sales": 1802, "commission": 216.24 },
      "paid": { "orders": 140, "sales": 23114.75, "commission": 2773.77 },
      "rejected": { "orders": 2, "sales": 310, "commission": 37.2 }
    },
    "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. One order can also carry rows for other affiliates: mlm: for a recruiter's share, split:<n>: for share n of a commission the seller shares, and royalty:<n>: for a royalty on the order's products. Each of those is one affiliate's part of the order, carries no sale, and is approved or rejected on its own.
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, in the words the admin shows. Blank when it is simply unjudged. The money reasons: Test order, Order canceled, and Payment pending or another payment state such as Payment voided. The fraud rules: Chargeback open, Possible self-referral, Possible self-referral (same address), Shopify rates this order high risk, Shopify rates this order medium risk, Many orders from this affiliate in 24 hours, One of this affiliate's first orders, and Repeat order from one IP address. A program that pays only on a customer's first order: Could not tell whether this is the customer's first order, when the order has no customer to judge by. A program with commission rules by customer: Could not read the customer's tags or email for this program's commission rules, while the order waits to be priced. A program that splits commission by clicks: Waiting for the links the customer clicked, for up to 15 minutes after the order while the Web Pixel's report of the customer's clicks arrives. Your approval settings: Unapproved by you, Automatic approval is off for this affiliate, Automatic approval is off for this program, Automatic approval is on the Growth plan when automatic approval is on but the store is on Free, and Approves automatically on YYYY-MM-DD while your wait before automatic approval runs.
paidAtWhen it was paid, or null.
partWhat the row is: order for the order's own commission (and its corrections), share for one affiliate's part of an order split by clicks, royalty, or override for a recruiter's share.
splitByClickstrue on every row of an order whose commission was split between every affiliate the customer clicked: the order's own row, which is the part of the affiliate the order counts for, and each other affiliate's share.
totalHow many orders match status, from, and to across every page. The same on every page.
totalsEvery order between from and to by status: how many, their sales, and their commission, in currency, whatever status you asked for. For a commission summary, ask for limit=1 and read this: there is no need to page through the orders.
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. Deductions are not listed here; they have their own list.

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/events read

The custom events you reported, newest first, each with whether a payout has paid it. A custom event is an action Sprout Affiliate cannot see on its own, reported by your own system: see POST /api/v1/events.

ParameterWhat it does
refOnly this affiliate's events.
limitRows to return, 1 to 250. Defaults to 100.
{
  "ok": true,
  "data": {
    "events": [
      {
        "externalId": "calendly-8812",
        "entryId": "event:3f9c0a1b2d4e5f6a7b8c9d0e",
        "type": "booked_call",
        "name": "Booked call",
        "ref": "maya",
        "amount": 25,
        "occurredAt": "2026-10-06T14:30:00.000Z",
        "recordedAt": "2026-10-06T14:30:04.112Z",
        "note": "Intro call",
        "paid": false
      }
    ],
    "count": 1,
    "currency": "USD"
  }
}

amount and name are what the event earned when it was recorded. Editing or removing the event type in the program afterward never changes an event already recorded. type is the key the type has now, and "" once the type was removed.

POST /api/v1/events write

This moves money. The affiliate earns the flat amount the program pays for this event type, owed once it is approved. Needs a write key and the Professional plan. First add the event type to the program, in the program's Custom events card: its key is what you send as type.
FieldAccepts
typeRequired. The event type's key, such as booked_call, on the program the affiliate is on.
refThe affiliate's link name. Send this or click.
clickThe visit the event came from, instead of ref: the click number the Clicks page shows (1042 or #1042), or the ref|time value the storefront keeps for the visit (the order's Sprout Ref attribute has the same form). The event has to fall inside the program's attribution window after that visit. Sending both ref and click is allowed when they name the same affiliate.
externalIdRequired, up to 200 characters. Your own id for this event, such as the booking's id. One earning per externalId per store, ever: a second POST with the same id is refused with 409, whatever its type, so a retry is always safe.
occurredAtOptional. When the event happened, as an ISO 8601 time with its offset, such as 2026-10-06T14:30:00Z. Defaults to now. Not in the future, and not more than 90 days ago. It dates the earning and decides which day's limit it counts against, in your store's time zone.
noteOptional. Trimmed to 200 characters.

Request

curl -s -X POST https://app.sproutaffiliate.com/api/v1/events \
  -H "Authorization: Bearer $SPROUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type": "booked_call", "ref": "maya", "externalId": "calendly-8812", "note": "Intro call"}'

Response, 201 Created

{
  "ok": true,
  "data": {
    "event": {
      "externalId": "calendly-8812",
      "entryId": "event:3f9c0a1b2d4e5f6a7b8c9d0e",
      "type": "booked_call",
      "name": "Booked call",
      "ref": "maya",
      "amount": 25,
      "occurredAt": "2026-10-06T14:30:00.000Z",
      "recordedAt": "2026-10-06T14:30:04.112Z",
      "note": "Intro call"
    },
    "currency": "USD"
  }
}

The amount and name are locked when the event is recorded. From there the event is an earning like any other: it waits on the Payouts page or approves automatically as your approval settings say (the waiting period before automatic approval counts from when you reported the event, not from occurredAt), you can approve or reject it there, and it is paid, undone, stated, and reported on the 1099-NEC with the affiliate's commission. It is not a referral sale, so it carries no maintenance fee and counts toward no commission tier. entryId is the id it has on the Payouts page and in the affiliate's ledger.

RefusalWhy
400 An event needs an externalId: your own id for it, so the same event is never paid twice.externalId is missing or blank.
400 An event needs the affiliate's ref or a click id.Neither was sent.
404 No such affiliate.That ref is not on your store.
404 No visit has that click id.No recorded visit has that click number or ref|time.
400 The ref and the click id name two different affiliates.Both were sent and they disagree.
400 That affiliate is not active.Pending, unverified, and paused affiliates cannot earn.
404 This affiliate's program has no custom event with the key booked_call.Add the event type to the affiliate's program first. Each program has its own event types and amounts.
422 The event happened outside the attribution window of that visit.Sent with click, and the event is before the visit or after the program's attribution window closed.
409 An event with this externalId was already recorded on 2026-10-06. Each externalId is paid once.A retry, or a second report of the same event. Nothing new was recorded.
422 maya already has 2 booked_call events on 2026-10-06, the most this event pays in one day.The event type's daily limit per affiliate is reached for that day. Events you rejected do not count toward it. Nothing was recorded.
400 occurredAt must be an ISO 8601 time with its offset, such as 2026-10-06T14:30:00Z.A bare date, or a time with no offset, would be read in nobody's time zone.
402 The API is on the Professional plan.Custom events are Professional, as the API is.

GET /api/v1/deductions read

Unpaid deductions, oldest first: amounts you took off an affiliate's next payout that no payout has taken off yet. Amounts are positive, the amount that comes off. Once a payout takes one off it leaves this list and turns up in that affiliate's ledger as a negative line named Deduction.

ParameterWhat it does
refOnly this affiliate's deductions.
limitRows to return, 1 to 250. Defaults to 100.
{
  "ok": true,
  "data": {
    "deductions": [
      { "id": "cm1x...", "ref": "maya", "name": "Maya Chen", "amount": 25, "note": "Order #1042 was returned after it was paid out" }
    ],
    "count": 1,
    "matched": 1,
    "total": 25,
    "currency": "USD"
  }
}

POST /api/v1/deductions write

This changes what the next payout sends. A deduction takes an amount off an affiliate's next payout: an overpayment, a returned gift, a correction. It sends and moves no money. While it is more than the affiliate is owed, nothing is sent to them and the rest carries to the payout after. The affiliate is emailed the amount and your note. Needs a write key, and the Professional plan.
FieldWhat it is
refRequired. The affiliate.
amountRequired. What comes off, as a positive number in your store's currency, up to 2 decimal places and at most 100000.
noteRequired, up to 200 characters. The affiliate sees it on their statement and in the email.
curl -s -X POST https://app.sproutaffiliate.com/api/v1/deductions \
  -H "Authorization: Bearer sk_sprout_..." \
  -H "Content-Type: application/json" \
  -d '{"ref":"maya","amount":25,"note":"Order #1042 was returned after it was paid out"}'

Returns 201 with the deduction as stored, its amount positive. There is no idempotency key, so a retried POST takes a second deduction: check GET /api/v1/deductions before retrying one that timed out.

ErrorWhy
400 A deduction needs a note. The affiliate sees it on their statement.The note is missing or blank.
400 Deduction amount must be greater than 0. Send what comes off as a positive number.Zero, or a negative figure.
402 Affiliate bonuses are on the Professional plan.Deductions share the bonus gate.
400 That affiliate is not active.Pending and paused affiliates cannot be given a deduction.

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.

GET /api/v1/payouts/preview read

Who a payout run would pay right now, and how much, without paying anybody. paypalPayable and paypalTotal are what a "paypal" run would send; holds says why anyone owed is held back. Optional ref previews paying one affiliate.

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

/api/v1/payouts/schedule read write

GET reads the automatic PayPal payout schedule. PATCH changes it with any of cadence ("off", "weekly", "monthly"), weekday (0 Sunday to 6), monthday (1 to 31), hour (0 to 23), and minute, in the store's timezone. A field left out keeps its value. Turning it on is the Growth plan or above, and never pays for a time already past today.

curl -s -X PATCH https://app.sproutaffiliate.com/api/v1/payouts/schedule \
  -H "Authorization: Bearer $SPROUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cadence": "weekly", "weekday": 5, "hour": 9}'

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.
expectedTotalOptional with a key, required from a connected AI assistant, in "paypal" mode. The PayPal total from the preview. If the total has changed since (a new approval, a bonus, a paused affiliate), the run is refused with 409 and nothing is sent.

"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, By program and Products sold tables. products is null rather than empty when the live orders could not be read, and carries showing, total, ordersCounted (how many of the window's referred orders still had line items, which is fewer than the referred-order total once orders are paid) and rows. 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. To add one from code instead, use POST /api/v1/hooks.
  4. Copy the signing secret. It is shown once and never again, the same as an API key.

Ten endpoints per store, counting the ones added from code. 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.

Webhooks are on the Professional plan, the same as the API, because they are the same integration seen from the other side. Below it no endpoint can be added, and nothing is delivered to the endpoints already on the store: no request goes out, and no delivery is recorded to replay later. The endpoints themselves are kept, not deleted, and delivery starts again on the upgrade. As with the API, a subscription we cannot read at that moment keeps delivering.

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.

referral.approved and referral.rejected are sent the moment you decide, before the order is priced, so their sale and commission are null. referral.created carries the sale, and the commission wherever the program's rules settle it as the order arrives; it is null where something on the order could still change it, such as a tier, a coupon rate, a product rate, or a rule by customer. GET /api/v1/orders has both for every order. orderId is the form POST /api/v1/orders/:id/approve takes.

On a program that splits commission by clicks, nobody's part is known when the order arrives, so referral.created waits, as the affiliate's "You earned a commission" email does. It is sent once the split is decided: a few minutes after the Web Pixel reports the links the customer clicked, or when the 15-minute wait for that report is over. Its affiliateRef is the affiliate the order counts for, and its commission is that affiliate's own part, the figure on the order's own row in GET /api/v1/orders. Each other affiliate's part is a row of its own there, with part share, and is not in 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.

GET /api/v1/hooks read

Your webhook endpoints, newest first, whether they were added in Settings, by a Zap, or from code. Never a signing secret. No parameters.

Request

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

Response

{
  "ok": true,
  "data": {
    "hooks": [
      {
        "id": "cm9h2k4xq0003l7083w1bq9zd",
        "url": "https://hooks.zapier.com/hooks/standard/1234567/abcdef/",
        "events": ["referral.created"],
        "allEvents": false,
        "source": "zapier",
        "active": true,
        "createdAt": "2026-10-03"
      }
    ],
    "count": 1
  }
}
FieldWhat it is
idWhat DELETE /api/v1/hooks/:id takes.
eventsThe events this endpoint receives. An endpoint that takes every event lists all of them and has allEvents: true.
sourceWho added it: settings (Settings > Developer), zapier (a Zap, labeled Zapier in Settings), or api (anything else that called POST /api/v1/hooks).
createdAtThe day it was added, YYYY-MM-DD.

POST /api/v1/hooks write

Add an endpoint from code: subscribe a URL to the events you name, and get its signing secret back once. This is a REST hook, the way an integration asks to be told rather than polling: subscribe when your integration switches on, and delete when it switches off.

The endpoint is the same one Settings adds, under the same rules: https:// only, no address on your own network, no address twice, and ten per store. Deliveries are signed, retried, and listed in Settings > Developer, with Replay, exactly as above. Revoking the key that added an endpoint deletes the endpoint.

Body fieldWhat it does
urlRequired. The https:// address to post to.
event or eventsRequired, one or the other or both: one event name, or a list of them, from the events. Naming none is a 400, not a subscription to everything.
sourceOptional. zapier labels the endpoint Zapier in Settings. Anything else is recorded as api.

Request

curl -s -X POST https://app.sproutaffiliate.com/api/v1/hooks \
  -H "Authorization: Bearer $SPROUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/sprout", "event": "referral.created"}'

Response

{
  "ok": true,
  "data": {
    "hook": {
      "id": "cm9h2k4xq0004l708dk2c8s1a",
      "url": "https://example.com/sprout",
      "events": ["referral.created"],
      "allEvents": false,
      "source": "api",
      "active": true,
      "createdAt": "2026-10-03"
    },
    "secret": "whsec_sprout_6mQ1..."
  }
}

The answer is 201. secret is the key to verify signatures with, and this is the only time it is returned. An address already on the list, an eleventh endpoint, a plain http:// address, or an event Sprout Affiliate does not send is a 400 with the reason in error.

/api/v1/hooks/:id read write

GET returns one endpoint, in the shape GET /api/v1/hooks lists it. DELETE needs a write key and removes the endpoint and its delivery history, wherever it was added.

Request

curl -s -X DELETE https://app.sproutaffiliate.com/api/v1/hooks/cm9h2k4xq0004l708dk2c8s1a \
  -H "Authorization: Bearer $SPROUT_KEY"

Response

{ "ok": true, "data": { "id": "cm9h2k4xq0004l708dk2c8s1a", "deleted": true } }

An id that is not one of your store's endpoints is a 404, including one already deleted. Treat that as done.

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