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/.
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.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. 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.
- 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. 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.
| 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 eleven calls that change data, including running a payout | Edit 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:
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 the usage fee you were charged on that sale back (2% on Growth, 1.5% on Professional, 1% on Enterprise).POST /api/v1/bonusescreates a debt your store owes, which the next payout run sends.POST /api/v1/deductionstakes an amount off what the next payout run sends.POST /api/v1/eventsrecords a custom event, which earns the affiliate the amount the program pays for it.PATCH /api/v1/affiliateschanges up to 250 affiliates at once: their rate, status, or note.PATCH /api/v1/affiliates/:refchanges one affiliate's rate, status, or note. The rate is money: it is what every referral of theirs from that moment on is calculated at.POST /api/v1/affiliates/:ref/approveapproves an application, which is what lets that person start earning.POST /api/v1/affiliates/:ref/rejectdeclines one.PATCH /api/v1/payouts/schedulechanges when automatic payouts run, or turns them off.
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:
| 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 | Your 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. |
| 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. A path under /api/v1/ that is not one of the ones below is a 404 too, in the same envelope: "No such endpoint." |
| 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
Twenty-three calls across sixteen 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 | write | Change up to 250 affiliates at once: rate, status, or note |
| PATCH /api/v1/affiliates/:ref | write | Change their rate, status, or note |
| POST /api/v1/affiliates/:ref/approve | write | Approve a pending application |
| POST /api/v1/affiliates/:ref/reject | write | Decline a pending application |
| 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/events | read | Custom events you reported, and whether each is paid |
| POST /api/v1/events | write | Report a custom event: a booked call, an app signup, a form, a trial start |
| GET /api/v1/deductions | read | Unpaid deductions |
| POST /api/v1/deductions | write | Take an amount off an affiliate's next payout |
| 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/payouts/preview | read | What the next payout would send, before you send it |
| GET /api/v1/payouts/schedule | read | When automatic payouts run |
| PATCH /api/v1/payouts/schedule | write | Change when they run, or turn them off |
| GET /api/v1/analytics | read | The four headline figures, the chart behind them, and the three tables |
| GET /api/v1/hooks | read | Your webhook endpoints |
| POST /api/v1/hooks | write | Add a webhook endpoint for one event or several |
| GET /api/v1/hooks/:id | read | One webhook endpoint |
| DELETE /api/v1/hooks/:id | write | Remove 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" }
}
}
| 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, 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.
| 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. |
email | Only 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. |
limit | 1 to 250, default 100. Anything higher is clamped to 250, anything lower or unreadable becomes the default. |
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/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"
}
}
| 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, 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. |
createdAt | The date they were created, YYYY-MM-DD. |
count | How many rows this response carried. It is not how many you have. |
total | How many affiliates match status and program across every page. The same on every page. |
byStatus | Everyone 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. |
hasMore | Whether more affiliates matched than this page carried. |
nextCursor | Pass 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. |
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):
| Field | What it is |
|---|---|
royalty | The 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. |
| 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 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:
409 Approve their application first, with POST /api/v1/affiliates/:ref/approve.The affiliate is stillpendingorunverified. A pending application is approved with POST /api/v1/affiliates/:ref/approve; an unverified signup has not confirmed their email yet and cannot be approved until they do.400 A rate has to be between 0 and 100.The rate is a percentage, not a fraction: send15, not0.15.400 A payout method can only be set by the affiliate, in their portal. It is read only here.The body carriedpayoutMethodorpayoutDetails. Refused out loud rather than ignored, so nobody goes away believing an affiliate's payouts were rerouted.
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.
| Parameter | What it does |
|---|---|
slug | Just this one program. |
active | true 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. |
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,
"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
}
}
| Field | What it is |
|---|---|
active | The program's own Status switch in the admin: false when you paused it. Its dates are separate, below. |
startDate, endDate | The days the program opens and last takes signups, as YYYY-MM-DD on your store's calendar, or null for none. |
status | Where 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. |
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. |
cookieHours | The 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. |
stopAfterPurchase | true 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.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. |
commission.firstOrderOnly | true when the program pays commission only on a customer's first order with your store. A returning customer's order earns nothing. |
commission.renewalMonths | How 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.orderRules | The 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.clickSplit | The 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. |
eventTypes | The 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, 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, as payoutMethod above names them. Empty means the six every store started with: paypal, venmo, bank, check, store-credit, and gift-card. |
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,
"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
| 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. 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. |
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, 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. |
paidAt | When it was paid, or null. |
part | What 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. |
splitByClicks | true 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. |
total | How many orders match status, from, and to across every page. The same on every page. |
totals | Every 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. |
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. Deductions are not listed here; they have their own list.
| 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/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.
| Parameter | What it does |
|---|---|
ref | Only this affiliate's events. |
limit | Rows 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
type.| Field | Accepts |
|---|---|
type | Required. The event type's key, such as booked_call, on the program the affiliate is on. |
ref | The affiliate's link name. Send this or click. |
click | The 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. |
externalId | Required, 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. |
occurredAt | Optional. 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. |
note | Optional. 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.
| Refusal | Why |
|---|---|
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.
| Parameter | What it does |
|---|---|
ref | Only this affiliate's deductions. |
limit | Rows 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
| Field | What it is |
|---|---|
ref | Required. The affiliate. |
amount | Required. What comes off, as a positive number in your store's currency, up to 2 decimal places and at most 100000. |
note | Required, 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.
| Error | Why |
|---|---|
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.
| 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.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
| 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. |
expectedTotal | Optional 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..."
}
}
| 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, 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.
| 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. To add one from code instead, use POST /api/v1/hooks. - 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
| 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.
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
}
}
| Field | What it is |
|---|---|
id | What DELETE /api/v1/hooks/:id takes. |
events | The events this endpoint receives. An endpoint that takes every event lists all of them and has allEvents: true. |
source | Who added it: settings (Settings > Developer), zapier (a Zap, labeled Zapier in Settings), or api (anything else that called POST /api/v1/hooks). |
createdAt | The 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 field | What it does |
|---|---|
url | Required. The https:// address to post to. |
event or events | Required, 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. |
source | Optional. 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/:
- 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 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.