Vital Guides

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.

Updated September 2, 2026· Measured on a live 9,022 deal board, August 2026· Keap, Pipelines, REST v2· Advanced

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.

Base pathmeasured
// 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.

Same board, five walksmeasured
// 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.

The enum names itselfmeasured, message verbatim
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.

The oraclemeasured
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.

A walk that checks itselfillustrative
// 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.

Readmeasured
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": "..."
}
Writebody measured, verb and path per the spec
// 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.

Three waysmeasured
// 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

Why does /crm/rest/v2/deals return HTML instead of JSON?
Because deals do not live there. They are served from 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.
Why does my deal count match Keap exactly but deals are missing?
You are paging on a sort key that repeats. Inside a run of equal values the cursor cannot tell rows apart, so it serves some twice and skips others, and the two errors cancel. Sorted by NAME, a 9,022 deal board gave us 9,022 rows holding 7,889 distinct deals. Count distinct ids, never rows.
Which order_by should I use to list Keap deals?
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.
Is there a webhook for Keap deals?
No, verified three ways: the spec never mentions hooks, /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.
Why did my deal value update return 200 and change nothing?
You wrote the shape you read. Value comes back nested under 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.
THIS IS THE KIND OF THING WE GET HIRED FOR

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