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/.
Authorization: Bearer sk_sprout_…every callok, then data or errorevery replyThe 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.
/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
- Open Sprout Affiliate in your Shopify admin, then Settings in the left menu.
- Along the top of the Settings page, click the Developer tab.
- 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.
- Set Access to Read only or Read and write. Pick Read only unless the thing you are building actually needs to change something.
- Click Create key.
- Copy the secret. It looks like
sk_sprout_followed by 32 characters.
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.
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.
| Scope | Can do | Cannot do |
|---|---|---|
| read | Every GET on this page | Anything that changes a stored value. Refused with 403 This key is read only. |
| write | Everything a read key can, plus the five endpoints that change data, including running a payout | Edit 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:
POST /api/v1/payoutsruns a payout. Inpaypalmode it sends real money immediately, and nobody can undo it.POST /api/v1/orders/:id/approvemoves a referral into the payable queue, which is what the next payout run pays.POST /api/v1/orders/:id/rejecttakes a commission off an affiliate, and credits your 2% usage fee on that sale back.POST /api/v1/bonusescreates a debt your store owes, which the next payout run sends.PATCH /api/v1/affiliates/:refchanges one affiliate's rate, status, note, or payout method. The rate is money: it is what every referral of theirs from that moment on is calculated at.
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:
| Status | Means |
|---|---|
| 400 | Something 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. |
| 401 | No key, a malformed header, or a key that is revoked or was never real. |
| 402 | The feature is on a higher plan. Only creating a bonus returns this. |
| 403 | A read key was used on a write endpoint. |
| 404 | The 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. |
| 405 | Wrong method for that path. |
| 409 | The 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. |
| 429 | Over 120 requests in a minute on this key. |
| 503 | Your live Shopify orders could not be read just now. Retry shortly. |
| 500 | Something broke on our side. The message is always the bare "Server error.", with no internal detail in it. |
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.
| Endpoint | Scope | What it does |
|---|---|---|
| GET /api/v1/shop | read | Which store this key reaches, its plan, currency, and timezone |
| GET /api/v1/affiliates | read | Your affiliates |
| GET /api/v1/affiliates/:ref | read | One affiliate, and what they are owed |
| PATCH /api/v1/affiliates/:ref | write | Change their rate, status, note, or payout method |
| GET /api/v1/programs | read | Your programs, their commission and discount setup, and their totals |
| GET /api/v1/orders | read | Referral orders and your decision on each |
| POST /api/v1/orders/:id/approve | write | Approve a pending referral |
| POST /api/v1/orders/:id/reject | write | Reject a pending referral |
| GET /api/v1/bonuses | read | Unpaid bonuses |
| POST /api/v1/bonuses | write | Grant a bonus |
| GET /api/v1/payouts | read | Paid payout batches, and who was in each |
| POST /api/v1/payouts | write | Run a payout, recorded by hand or sent through PayPal |
| GET /api/v1/analytics | read | The 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" }
}
}
| Field | What it is |
|---|---|
shop | Your .myshopify.com domain. This is the store the key reaches, and there is no way to make it reach another. |
name, storefrontDomain | Your 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. |
plan | Free, 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. |
currency | Every money amount anywhere in this API is in this currency, unconverted. |
timezone | The calendar every date in this API is cut on. UTC until Shopify's timezone has synced once. |
country | Your store's country code, or null. |
key | The 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.
| Parameter | What it does |
|---|---|
status | Only affiliates with this status: unverified, pending, active, or inactive. Case is ignored. Leave it off for all of them. |
program | Only affiliates on this program, by slug, matched exactly. Leave it off for all programs. |
limit | 1 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
}
}
| Field | What it is |
|---|---|
ref | Their link name, the value in ?ref=. This is the id every other endpoint takes for an affiliate. |
status | unverified (signed up, email not confirmed), pending (waiting on your approval), active, or inactive (paused: cannot sign in, stops earning, history kept). |
program | The program slug they are on. Names and settings for it are in GET /api/v1/programs. |
code | Their personal discount code, or null when they are link-only. |
rate | Their commission rate as a percentage, so 12 means 12%. |
payoutMethod | paypal, venmo, bank, check, or store-credit. null if they have not chosen. The account itself is never returned. |
createdAt | The date they were created, YYYY-MM-DD. |
count | How many rows this response carried. It is not how many you have. |
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).
| Total | What it counts |
|---|---|
pending | Referred, not yet approved by you. |
approved | Approved and owed, waiting for a payout run. |
paid | Everything this store has ever paid them, whichever app was tracking at the time. |
imported | The 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. |
unpaidBonuses | Granted 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.
| Field | Accepts |
|---|---|
rate | A 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. |
note | Text. 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 | paypal, 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:
409 Approve their application first.The affiliate is stillpendingorunverified. Applications are approved in the admin, not here.400 A rate has to be between 0 and 100.The rate is a percentage, not a fraction: send15, not0.15.409on a payout method change when the stored account cannot be read back. The change is refused rather than made with an empty account, because writing one would erase the real one. The affiliate can set the method themselves in their portal.
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.
| Parameter | What it does |
|---|---|
slug | Just this one program. |
active | true 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. |
limit | 1 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
}
}
| Field | What it is |
|---|---|
isDefault | The program a bare signup link joins when it names none. |
isReferral | A customer referral program (refer-a-friend), whose signups are approved instantly, rather than a vetted affiliate program. |
cookieDays | The attribution window in days. 0 means no expiry, null means it uses your shop-wide setting. |
commission.type | percent of the sale, or flat per referred order. |
commission.rate | The percentage, and the first rung's rate when the program is tiered. |
commission.tiers | The 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.tierBasis | What the ladder measures: count of orders or their value. null without a ladder. |
commission.tierReset | never, weekly, monthly, quarterly, yearly, or cycle. The four reset* fields and cycleEvery spell out when. |
commission.productScope | all products earn, or include for only the items in products. productRates holds per-product overrides of the rate. |
typeLabel, amountLabel | The 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.mode | none (no customer discount), code (a personal code to share), or link (that code auto-applies through the share link). |
discount.usageLimit | Redemptions allowed per code. 0 is unlimited. |
payoutMethods | The methods this program's signup form offers. Empty means all of them. |
stats | Over 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.
| Parameter | What it does |
|---|---|
status | pending, approved, paid, rejected, or all (default). Anything else is a 400. |
from, to | YYYY-MM-DD, both inclusive, on the order's own day in your shop's timezone. A from after a to is a 400. |
limit | 1 to 250, default 100. |
cursor | The 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
| Field | What it is |
|---|---|
orderId | The 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. |
order | The Shopify order number, as printed, for example #1842. |
date | The order's day in your shop's timezone. This is the date from and to filter on, and the date the admin shows. |
createdAt | The exact timestamp, or null. |
sale, commission | The sale and what the affiliate earns on it, in currency. |
status | pending, 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. |
holdReason | Why 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. |
paidAt | When it was paid, or null. |
503 Live orders couldn't be read right now. Retry shortly.POST /api/v1/orders/:id/approve write
: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"
}
}
}
| Refusal | Why |
|---|---|
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. |
503 | Live 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
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"
}
}
}
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.
| Parameter | What it does |
|---|---|
ref | Only this affiliate's bonuses. |
limit | 1 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
| Field | Accepts |
|---|---|
ref | Required. The affiliate's link name, which must be an active affiliate on your store. |
amount | Required. Greater than 0, at most 100000, at most 2 decimal places. A number or a numeric string. |
note | Optional. 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"
}
}
| Refusal | Why |
|---|---|
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.
| Parameter | What it does |
|---|---|
batch | One 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. |
limit | 1 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"
}
}
| Field | What it is |
|---|---|
via | paypal when Sprout Affiliate sent it, manual when you paid outside the app and recorded it here. |
method | The payout method's display label, or Mixed when one batch settled more than one method. |
total, gross, taxTotal | The batch's net, its gross, and the tax between them. Equal, with a zero tax, when no payout tax was in force. |
taxType | none, vat_add, withholding, or mixed when affiliates in one batch were taxed differently. |
affiliates[].amount | What actually left for that affiliate: the net where a payout tax applied, the gross otherwise. |
affiliates[].gross and .tax | null 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[].destination | Where 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[].orders | Only on a single-batch request. Each line is { order, commission }; a bonus has no order of its own and carries its reason instead. |
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
| Field | Accepts |
|---|---|
mode | Required. "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. |
ref | Optional. 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..."
}
}
| Field | What it is |
|---|---|
ran | Whether 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. |
undoable | true only for a manual batch, which can be undone from the admin. A PayPal batch is never undoable. |
summary | One line saying what happened. |
details | The 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. |
ran: false rather than paying anybody twice.| Refusal | Why |
|---|---|
400 on mode | The 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. |
404 | The ref is not an affiliate on this store. |
409 | Either the store's Shopify connection is not available (open the app in the Shopify admin once), or the run failed. No money moved. |
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.
| Parameter | What it does |
|---|---|
range | 1 (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, to | Required when range=custom, as YYYY-MM-DD. |
affiliate | A ref, to narrow everything to one affiliate. 404 if they are not on your store. |
program | A program slug. 404 if it is not on your store. |
limit | How 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
}
}
| Field | What it is |
|---|---|
range, previousRange | The 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.referredOrders | Referred 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.affiliatesWithSales | Affiliates who sold in the window. This is the number behind the tile the admin labels "Active affiliates". |
granularity | hour for a single day, day, or month past about four months. Chosen the same way the admin's chart chooses it. |
series[].key | The 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. |
partialLast | true 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[].program | The 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.
- Open Sprout Affiliate in your Shopify admin, then Settings in the left menu.
- Click the Developer tab along the top of the page.
- In the Webhooks card, paste your
https://URL, tick the events you want, and click Add endpoint. - 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
| Event | When it fires |
|---|---|
affiliate.created | Someone signed up or was added as an affiliate. |
affiliate.approved | An affiliate was approved and can earn commission. |
referral.created | A referred order came in. |
referral.approved | A referred order was approved, so its commission is owed. |
referral.rejected | A referred order was rejected, so no commission is owed. |
payout.paid | A payout was recorded as paid to one affiliate. |
bonus.created | A 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/:
- A field that exists will not be removed, and will not change type. If
commissionis a number today it is a number forever. Ifcodecan benulltoday, it will not start being""instead. - A field's meaning will not change under you.
ratewill not quietly become a fraction.paidwill not quietly start excluding imported history. - New fields may be added to any response, at any time. Parse what you need and ignore what you do not; a strict parser that rejects unknown keys will break, and that is the one thing you have to build for.
- New endpoints and new optional parameters may be added. Defaults never change, so a call that omits a parameter today gets the same answer tomorrow.
- Enumerated values may gain members. A new payout method or a new order status can appear. Handle an unrecognised value rather than assuming the list on this page is closed forever.
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.
/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.