KEAP DEALS BY API: THE WRONG BASE PATH AND THE SORT THAT LOSES DEALS
The endpoint you find first serves HTML. The flat list you find next is a 501. And the sort key that finally works handed us 9,022 rows holding 7,889 deals. The row count was exactly right, which is why it shipped for weeks.
THE COUNT MATCHES KEAP AND THE DEALS ARE STILL MISSING
You were asked to get the sales pipeline out of Keap. A dashboard, a warehouse, a weekly report, it does not matter
which. You went to the API reference, found /crm/rest/v2/deals, called it, and got a web page back.
A forum thread told you deals are not in the API. Another told you they were, once, and got removed.
Eventually you found the real endpoint, wrote the walk, and counted the deals it returned. 9,022. You opened the customer's pipeline in Keap and the board said 9,022. You shipped it.
Three weeks later a salesperson asks why a deal she can see on her board is not in the report. You look, and it genuinely is not there. Then you notice another deal is in the report twice. Every total you can think to check still agrees with Keap. Nothing you built is broken in a way a count can see.
We were paid to find out why, on a customer's board of 9,022 deals, in August 2026. This is what came back, from the base path to the write that returns 200 and does nothing.
THE DEALS LIVE ON A DIFFERENT HOST PATH THAN EVERYTHING ELSE
Contacts, orders, tags, custom fields, hooks: all of that is served from api.infusionsoft.com/crm/rest/v1
and /crm/rest/v2. Pipelines and deals are not. They are a separate service, which Keap's own spec calls
SLAAPI, and it is served from api.infusionsoft.com/services/v2. That is the spec's own server line. The
published reference at developer.keap.com/docs/restv2Pipelines
is a viewer over the same spec, and we verified it byte for byte against the copy we keep.
The year of "/v2/deals 404s" lore is a wrong base path. The crm/rest host answers any route it does not
recognise with an HTML tenant page, and on some probes with a bare 404 whose body is
{"message":"","spaApp":false}. Neither response says "wrong host". Both read like "this account does not
have the module", which is exactly the conclusion everybody drew.
// Where the docs lead you. Not a deals endpoint; the host does not know the route.
GET https://api.infusionsoft.com/crm/rest/v2/deals
Authorization: Bearer <token>
HTTP 404 // body is Keap's HTML tenant page, or {"message":"","spaApp":false}
// The service that actually holds deals. Flat list is refused on purpose.
GET https://api.infusionsoft.com/services/v2/deals
Authorization: Bearer <token>
HTTP 501 // "use the overload"
// The overload: scope to a stage (or a pipeline) and say how to sort. Both parameters are required.
GET https://api.infusionsoft.com/services/v2/stages/{stageId}/deals?order_by=CREATED&direction=ASCENDING&page_size=101
Authorization: Bearer <token>
HTTP 200
Three things about that service are worth having before you write a line. Auth is Authorization: Bearer
only. A service account key works there, but only through the Bearer header; send it as X-Keap-API-Key,
which works everywhere on crm/rest, and you get a 401.
The flat GET /services/v2/deals is a 501 whose message says to use the overload, and the overload
means scoping. You list deals per pipeline at /pipelines/{id}/deals or per stage at
/stages/{id}/deals, and both require order_by and direction. Leave either
off and you get a 400. Pipelines and their stages come from /pipelines and
/pipelines/{id}/stages, with ordinary pagination and one trap covered below.
That required order_by is the whole story of this guide. It decides whether you see every deal.
THE SORT KEY DECIDES WHETHER THE WALK SEES EVERY DEAL
The deals list pages by cursor. Each page hands back a next_page_token that encodes where the server
stopped, and the server resumes from that position in the sort order you asked for. That is fine when the sort key
is unique. When it is not, a page boundary can land inside a run of rows with the same value, and the server cannot
tell which row in that run it already gave you. It picks. Some rows get served twice. Others never do.
Deal names are the worst possible key for this, and they are the key the first working example on the internet
uses. The board we measured holds 9,022 deals with only 1,035 distinct names, and 2,330 of them carry the same one,
IN-GROUND POOL. That is not a messy account. That is what an imported CRM looks like at a business that
sells one product.
// 9,022 deals on the board. Each walk read to the end of the cursor.
order_by=NAME direction=ASCENDING 9,022 rows 7,889 distinct ids // 1,133 served twice, 1,133 never served
order_by=NAME direction=DESCENDING 9,022 rows 7,889 distinct ids // the identical 7,889. Reversing recovers nothing.
order_by=AMOUNT 7,376 distinct ids // worse: amounts repeat more than names do
order_by=CREATED 9,022 rows 9,022 distinct ids // zero duplicates
order_by=UPDATED 9,022 rows 9,022 distinct ids // zero duplicates
Read the NAME line again. 9,022 rows. The number of rows was exactly the number of deals on the board, and every one of those rows was a real deal. The duplicates made up for the losses one for one, so every count that could have caught it agreed with Keap. A row count, a sum of amounts by stage, a spot check of ten deals by name, all of it passed. Only counting distinct ids finds it.
Descending is the tell that this is not a race. Reversing the walk returned the same 7,889 deals, not a different 7,889. The loss is deterministic: the same boundaries fall inside the same runs every time, so running the walk again at 3am when nobody is editing changes nothing.
Count distinct ids. Never rows. The row count is the one number that will always agree with Keap on a lossy key, because the cursor's failure is symmetric: for every row it repeats there is one it skips. If your completeness check is "did we get 9,022 rows", it will pass on a walk missing 1,133 deals, and it will keep passing for as long as the board exists.
THERE IS NO ORDER BY ID, SO THE CURSOR ALWAYS LEANS ON A COLUMN THAT REPEATS
The obvious fix is to sort by the primary key. It is unique by definition and the cursor can never land inside a
run. Keap does not offer it. The order_by parameter is an enum called SortOrderBy, and its
full membership is NAME, STAGE, STATUS, AMOUNT,
CREATED, UPDATED, CLOSE_DATE and ESTIMATED_CLOSE_DATE. No
ID, no DEAL_ID.
We know the full list because the endpoint enumerates itself. Send a value it does not recognise and the 400 names
the Java enum class and the value you sent, so a handful of probes reads the whole list off the errors.
STAGE_ASSIGNMENT_TIME and CREATE_TIME, the two spellings you would guess first, both 400.
GET /services/v2/stages/{stageId}/deals?order_by=ID&direction=ASC
HTTP 400
No enum constant com.keap.salespipeline.sdk.model.SortOrderBy.ID
// direction is more forgiving: ASCENDING, DESCENDING, ASC and DESC are all accepted.
So the cursor always rests on a column that can repeat. CREATED and UPDATED came back
clean on this board because creation times rarely collide, but "rarely" is a property of the data, not a guarantee
from the API. Two deals a bulk import created in the same instant could share a key, and the cursor is back to
guessing.
That gives you the shape of a correct walk. Sort on CREATED. Dedupe by deal id as rows arrive, whatever
the sort. Then check the result against something that knows the true count, and if it comes up short, walk again
on UPDATED and merge. Which needs something that knows the true count.
DEALCOUNT IS THE ORACLE THAT CATCHES A SHORT WALK
GET /services/v2/stages/{id}/dealCount exists, and it is exact. It answers a bare integer, not an
object, so parse the body as a number. It costs one request per stage.
On the board we measured, its per stage numbers matched the customer's own Keap board column for column, and they
summed to 9,023 against a board the UI presents as 9,022 plus one. That is the completeness oracle for deals. The
stat_total_deal_count on /pipelines/summaries was only ever a nullable, unmeasured
candidate for the same job; this one we have seen agree with the screen.
GET /services/v2/stages/{stageId}/dealCount
Authorization: Bearer <token>
HTTP 200
<a bare integer> // not {"count": n}. The body IS the number.
Two rules make it safe to lean on.
One unreadable stage poisons the whole total. If a pipeline has eight stages and one
dealCount call fails, do not sum the other seven. A partial sum is smaller than the board, so a walk
that is genuinely short compares equal to it and reports complete. The oracle either has the whole number or it has
no opinion.
The oracle never breaks the walk it checks. A completeness check that throws turns a working sync into a dead one on the day Keap has a bad morning. When the oracle cannot answer, it fails soft to "no opinion", the walk stands on its own dedupe, and the gap gets logged for a person to read.
// Sum the oracle first. Any stage that cannot answer means no opinion at all.
let expected = null;
try {
const counts = await Promise.all(stages.map((s) => keap(`/stages/${s.id}/dealCount`)));
expected = counts.reduce((a, b) => a + Number(b), 0);
} catch { /* no opinion; never fail the walk over the check */ }
// Walk on CREATED, dedupe by id whatever the key.
const seen = new Map();
for await (const deal of walk("CREATED")) seen.set(deal.id, deal);
// Short of the oracle? Walk again on UPDATED and merge. Then say so either way.
if (expected !== null && seen.size < expected) {
for await (const deal of walk("UPDATED")) seen.set(deal.id, deal);
}
log({ rows, distinct: seen.size, expected }); // distinct is the number that matters
PAGE SIZE CAPS AT 101, AND THE PIPELINES LIST NEVER SAYS IT IS DONE
You might hope to sidestep the cursor problem with bigger pages: fewer boundaries, fewer chances to land inside a
run. page_size on the deals list caps at 101 however much you ask for. We sent 250, 500 and 1,000 and
got 101 rows back each time, with no error. So a walk over 9,000
deals is about 91 requests whatever you do, and a bigger page is not a way around a lossy key.
The pipelines list has the opposite problem. /services/v2/pipelines hands back a non empty
next_page_token on the last page. Follow it and you get an empty list and yet another non empty token,
for ever. A walk that loops until the token is empty never stops; ours found this inside a Cloudflare Worker,
which killed it on the subrequest cap. /services/v2/customFields, the deal custom field definitions,
does exactly the same thing.
The stop condition for the pipelines and custom field lists is an empty page, a repeated token, or a bounded page count. Never an empty token. The deals list ended properly on every walk we ran, but the cheap defence is all three stops everywhere on this service.
One more shape before you plan requests. The scoped lists do not include a deal's custom fields.
GET /services/v2/deals/{id} does, and so does
GET /services/v2/deals/-/bulk?id=..&id=.., the economical enrichment path at roughly 50 deals
per call, which has returned 500s on some accounts and needs handling as a real state. Values come back keyed
tenant:field_name, and a dropdown's value is the option id rather than its title, so strip the prefix
and resolve ids against the definitions first.
DEAL VALUE IS DOLLARS, NESTS ON READ AND FLATTENS ON WRITE
An order on the crm/rest API carries money as integer cents: {"amount": 86500, "currency_code": "USD"}
is $865.00. A deal's value on the pipelines service is dollars: 10000.0 is $10,000. Same vendor, same
host, one path segment apart, and neither response says which convention you are holding. Convert at the boundary
and nowhere else, or a $100 deal becomes a $1 deal somewhere in your reporting.
Then there is the write. A deal's value reads as a Money object, nested under value. The update
request has no value member at all. It carries a flat amount and a flat
currency_code at the top level, so the update mask is amount, and the currency field is
even spelled differently on the two sides. We proved the write live on 2026 August 25.
GET /services/v2/deals/{id}
HTTP 200
{
"id": "...",
"name": "IN-GROUND POOL",
"value": { "amount": 10000.0, "currency": "USD" }, // DOLLARS. Orders are cents.
"stage_assignment_time": "..."
}
// UpdateDealRequest has no "value" member. Flat amount, flat currency_code, mask on amount.
PATCH /services/v2/deals/{id}?update_mask=amount
{
"amount": 12500.0,
"currency_code": "USD"
}
// Masking "value" and sending the nested read shape answered 200 and changed nothing.
On this API a wrong write is worse than a 400. Four separate parameters we tried, the nested shape included, answered 200 and were silently ignored. A client that trusts the status code reports a change that never happened and, if it keeps an audit trail, writes a note claiming it. Read the deal back after every write and compare the value you sent to the value you got. The status code is not evidence.
THERE IS NO WEBHOOK FOR DEALS, SO SNAPSHOT THE BOARD
Keap's REST hooks are real and useful. GET /crm/rest/v1/hooks/event_keys lists 47 keys covering
contacts, orders, invoices, payments, tags, subscriptions and products, which is enough to make contact and revenue
sync event driven. Deals have nothing. We checked three ways, because "there is no webhook" is the kind of claim
that gets you a reply from somebody who found one.
// 1. The archived pipelines spec: zero occurrences of hook, webhook, subscription or event.
// 2. The v2 crm/rest hooks resource does not exist.
GET /crm/rest/v2/hooks
HTTP 404
// 3. The pipelines service answers 403 for hooks. And for anything else it does not have.
GET /services/v2/hooks
HTTP 403
GET /services/v2/definitelyNotARealResource
HTTP 403 // so the 403 above is not a permissions hint
The third probe is the one that matters. A 403 on /services/v2/hooks looks like "you are not allowed",
which sends you off to check scopes. A 403 on a route that cannot exist proves the service says 403 for anything
it lacks. There is nothing to be allowed into.
The one stage event in the hook catalogue is opportunity.stage_move, and it belongs to the retired
legacy opportunity model. Do not build on it and do not mention it to a customer. The v2 pipelines model is the
only one that exists going forward.
So if you need stage history, and every velocity, aging or conversion question does, you build it yourself. Keap exposes no stage transition log anywhere. Snapshot each account's raw deal board once a day into a bucket, and accept that history starts on the day you start, which is a reason to start the snapshot before you build anything that reads it.
What you do get today, without history, is stage_assignment_time on every deal. That gives you the
age of the deal in its current stage, which is enough for "what has been sitting in Proposal for 40 days" even on
an account you connected this morning.
AUDIT VALUE POPULATION BEFORE YOU TRUST A SINGLE NUMBER
Keap has two revenue paths and they are not equally reliable. Orders carry a definite amount, a definite date and a definite contact. Deals are what a business that quotes rather than invoices actually uses, and a deal's value is an optional field many accounts leave empty. A pipeline where six deals in ten have no value reports revenue that is six tenths wrong, precisely and with total confidence.
So before you turn on anything that attributes revenue to deals, count. Walk the board with the method above and measure what share of won deals carry a value. If value population is under 70 percent, the answers will be wrong. That account needs data cleanup before it needs a dashboard, and the cleanup is a real project.
Two more things to detect rather than assume. Not every account has the pipelines module, and what a tenant without
it returns from /services/v2 is unobserved, because every account we have measured had at least one
pipeline. Treat a 404 there as absent and degrade to orders. And an account entitled to pipelines that has never
created one looks identical, from outside, to one whose edition lacks them. Tell the customer to create a pipeline,
not to upgrade.
The walk that comes out of all this is short to state and took weeks to learn. Sort on CREATED. Dedupe
by id. Sum dealCount across the stages, refuse a partial sum, and fall back to UPDATED
when the walk comes up short. Never let the oracle break the walk. Stop the pipelines list on an empty page. Convert
dollars at the boundary. Read back after every write. Snapshot the board daily, starting today. It is the walk we
run inside MyVitalAssistant, and if you build your own it is the walk to run, because every shortcut on that list
is one we took first.
COMMON QUESTIONS
api.infusionsoft.com/services/v2,
a separate service, and the crm/rest host answers any unknown route with an HTML tenant page or a bare 404. It
says nothing about whether the account has the module. Change the base path and the same token works.CREATED, with UPDATED as the fallback. Both returned every deal once on
the board we measured; NAME and AMOUNT lost rows and reversing direction recovered nothing. There is no ID in the
enum, so dedupe by id regardless and check the walk against dealCount./crm/rest/v2/hooks is a real
404, and /services/v2/hooks answers 403 exactly like a made up route does. The only stage event in
the catalogue belongs to the retired legacy model. Snapshot the board daily if you need history.value, but the update
takes a flat amount and currency_code, mask on amount. Four wrong
parameters all answered 200 and were ignored. Read the deal back before you believe any write.A pipeline that agrees with Keap on every count and is still wrong is not a bug you find by reading the docs. We have been connecting Keap to the rest of the stack since 2010, and most of what we know about its API came from a customer's account behaving in a way nobody had written down. If your revenue reporting has a number in it you do not quite trust, that is a conversation worth having.
Book a Free Consult