Vital Guides

A KEAP WRITE OFF LOOKS EXACTLY LIKE A PAYMENT

Pull every order out of Keap, add up total_paid on the PAID ones, and you have built a revenue report that counts your customer's bad debt as sales. It will look right until somebody holds it against the bank statement. Then it will look like you cannot count.

Updated September 2, 2026· Measured across 1,487 live orders, August 2026· Keap, Revenue, Reporting· Intermediate

THE TOTAL THAT DOES NOT MATCH THE BANK

Here is how it arrives. Somebody needs revenue out of Keap: an agency dashboard, a spreadsheet export, a Zapier flow into a business intelligence tool. You list the orders, keep the ones marked PAID, sum total_paid, and ship it. The number is plausible. It goes on a slide.

A month later the owner puts the slide next to the bank deposits and the slide is higher. Not by a rounding error, by thousands. And the difference is not random noise you can shrug at. It is made of specific invoices the owner already knows about, because they are the ones that never got paid, and the report has counted every one of them as money.

A naive Keap revenue report is confidently wrong, and the overcount is the customer's own bad debt. That is the worst possible shape for an error, because the person reading it is the one person who can spot it in four minutes.

We were paid to build this report properly, and we measured everything below across 1,487 orders on a live customer app in August 2026. There are four traps, and you meet them in a fixed order because each fix makes the next one worse. Then there are two more that decide whether you saw every order at all. Take them in sequence.

TRAP ONE: A PAID ORDER WITH NOTHING PAID ON IT

The first thing you write is the obvious thing: where status is PAID, sum total_paid. The first thing you find is PAID orders with total_paid of zero.

On one live account it was 18 of 27 PAID orders, dated from 2015 through 2026. The money was real. It was taken on an outside merchant account, and somebody set the order to PAID by hand without recording a Keap payment. So the rollup is empty and the status is the only evidence there is.

The rule that survives this: PAID with an empty rollup means total is the amount. Only a status that is not PAID needs total_paid as proof that money moved. A DRAFT with zero paid is a receivable nobody has been asked for yet, and if you are creating orders by API that is exactly what POST /v1/orders gives you until the payment call lands. Guide 2 covers that chain.

Two small things while you are here. total_due does not fall when a payment is recorded; we have watched an order sit at total 3497, paid 3497, due 3497, so never read it as an outstanding balance. And having just fixed trap one, you now trust status more than you did an hour ago. That is precisely the wrong moment for trap two.

TRAP TWO: THE WRITE OFF THAT IS RECORDED AS A PAYMENT

A write off is an invoice nobody paid, closed by a bookkeeper so it stops showing as owed. Every accounting system has a way to do this. Keap's way is to write a payment row.

On a write off, Keap adds a payment whose pay_status is Written Off (or Partially Written Off). That payment rolls up like any other. So total_paid equals total, the order's status is PAID, and refund_total is null. On the v1 order read, a written off invoice is byte for byte indistinguishable from one that was genuinely paid.

Exactly one order level field says otherwise: refund_status. It is v2 only. The v1 order carries no refund status at all, so if your report reads v1 orders, which is where most Zapier and spreadsheet integrations start, you cannot see this from the order at all.

refund_status across the accountmeasured
// GET /crm/rest/v2/orders, every order on a live customer app, August 2026
NONE               1478
WRITE_OFF             6
PARTIAL_WRITE_OFF     3
REFUNDED              2
PARTIAL_REFUND        1
// 1,487 orders. Twelve are not what total_paid says they are.

Twelve orders in 1,487 is under one percent, which is why it looks ignorable and why it is not. Counting them credited the client with $4,929 of his own written off invoices. Several of those payment rows carried a note reading "Batch write off by" followed by his own name. He wrote them off. Our report handed them back to him as revenue.

The customer will find this one. They will not find it by auditing your code. They will find it because the total on your dashboard is a few thousand higher than the deposits, and the first invoice they check is one they personally decided to stop chasing. From that moment every other number on the page is suspect.

TRAP THREE: ONE FIELD, TWO SIGNS, TWO UNITS

So you switch to v2 to get refund_status, and now you meet refund_total properly. It is one field name that means two different things depending on which API answered.

Order 229, same order, both APIsmeasured
// GET /crm/rest/v2/orders/229
"refund_total": { "amount": 25000 }     // positive, CENTS

// GET /crm/rest/v1/orders/229
"refund_total": -250.0                  // negative, DOLLARS

Positive cents on v2. Negative dollars on v1. Opposite sign, a hundred times different in magnitude, for the same refund on the same order, and neither response says which convention it is using. Mix the two in one codebase and a $250 refund becomes a $25,000 credit or a $2.50 debit depending on which branch ran.

The useful half: on a refund the field is populated, and total_paid minus refund_total is the honest figure. Take that over the payment ledger for this family. Order 579's ledger sums to positive 197 across three human entries, while Keap reports the order fully refunded, and Keap is right. Humans typed the ledger. The rollup did the arithmetic.

Hold that thought, because the write off family is about to need the opposite rule.

TRAP FOUR: THE STATUS THAT NEVER GOES BACK

The tempting fix for trap two is one line: if refund_status is in the write off family, drop the order. Six write offs and three partials, gone, $4,929 corrected. It is wrong in the other direction.

Order 2800 was written off. Later an Adjustment row of negative $697 cleared the write off, and then a real, APPROVED $697 payment was collected. The customer paid. The order still reads WRITE_OFF today, because refund_status never goes back.

So dropping on the status alone deletes real revenue exactly as confidently as trusting total_paid invents it. Same field, two failure directions. The status is good for one thing only: telling you which orders need a second look. It cannot tell you the amount.

The amount lives in the ledger, at GET /v1/orders/{id}/payments, and reading it has a rule of its own. Skip rows whose pay_status matches written off, and also skip rows that match adjustment. Both, not either. Count the adjustment while ignoring the write off it reverses and order 2800 settles to zero: the write off is skipped, the negative adjustment counts, the real payment counts, and they cancel. Skip both and the real $697 is what remains, which is what happened.

THE SETTLEMENT ALGORITHM

Putting the four together, an order's revenue depends on its refund_status family, and each family has a different source of truth. NONE trusts the rollup, with the empty rollup rule from trap one. The refund family trusts order level arithmetic. The write off family trusts the ledger with two kinds of row removed.

settle.jsillustrative
const WRITE_OFF_FAMILY = new Set(["WRITE_OFF", "PARTIAL_WRITE_OFF"]);
const REFUND_FAMILY    = new Set(["REFUNDED", "PARTIAL_REFUND"]);

// order is the v2 read. Every amount on it is CENTS. refund_status exists only on v2.
// Returns settled cents, or null when the honest answer is "we do not know".
async function settle(order) {
  const total  = cents(order.total);
  const paid   = cents(order.total_paid);
  const family = order.refund_status || "NONE";

  if (REFUND_FAMILY.has(family)) {
    // v2 refund_total is positive cents. The rollup beats the ledger here (order 579).
    return paid - cents(order.refund_total);
  }

  if (WRITE_OFF_FAMILY.has(family)) {
    // The status never goes back (order 2800), so only the ledger can say.
    // One request per written off order. Nine in 1,487, so this is cheap.
    let rows;
    try { rows = await keap(`/crm/rest/v1/orders/${order.id}/payments`); }
    catch { return null; }                 // unreadable ledger: drop the order, never invent a number

    let dollars = 0;                          // this endpoint answers in DOLLARS, unlike every order total
    for (const row of rows) {
      const s = String(row.pay_status || "").toLowerCase();
      if (s.includes("written off")) continue;   // the write off itself, full or partial
      if (s.includes("adjustment"))  continue;   // and the row that reverses it. Skip BOTH or 2800 settles to zero.
      dollars += dollarsOf(row);
    }
    return Math.round(dollars * 100);
  }

  // NONE, the ordinary case, carrying trap one.
  if (order.status === "PAID") return total;   // PAID with an empty rollup: total is the amount
  return paid;                                  // anything else needs total_paid as proof
}

Three things about that block are load bearing. The ledger endpoint answers in dollars while every order total is in cents, so the multiply by a hundred is not cosmetic. The null on an unreadable ledger is deliberate: an order we cannot settle is dropped and counted as dropped, because a guessed number is worse than a missing one on a report that will be checked against a bank. And the cost is one extra request per written off order, which is affordable only because the status is rare. Nine in 1,487 is nine requests. If it were nine hundred you would need a different design, and you should check the distribution on your account before assuming it looks like ours.

Count what you refuse. A report that silently drops the orders it cannot settle has the same problem as one that silently counts write offs: the reader cannot see where the truth stops. Show the dropped count next to the total. Nobody has ever been angry at a dashboard for saying "two orders could not be settled". They get angry at the one that claimed 100% and was wrong.

THE COUNT FIELD IS NOT A COUNT

Settling each order correctly assumes you have every order, and Keap gives you two ways to be wrong about that. The first is the v1 list's count field, which looks like the obvious completeness check and is not.

On the account above, count reads 1,387. Walking the v1 list and collecting ids only ever produces 1,376 distinct orders, at limit=500 and limit=1000 alike. A completely separate v2 walk, resumed the way the next section describes, arrives at exactly 1,376 as well. Two methods agreeing against the field is what settles it. Deleted orders are the likely explanation, and that part is unproven.

The rule: use a walked id set, never count, when the question is "did we get them all". A count that is eleven high will make a complete walk look short for ever, and a developer who trusts it will keep hunting for eleven orders that do not exist.

THE WALK THAT ENDS EARLY WITH NO ERROR

The second way to miss orders is worse, because nothing tells you. The v2 orders walk ends with an empty next_page_token while orders remain, and it presents that exactly like a complete result.

Measured on a live app: an unbounded v2 walk ended after 499 orders, five pages, empty token, looking finished. Asking again with order_time>= the last row's time resumed it. Then it stopped again 296 rows later, so the cap is not even a constant you could plan around. Walk to null is not a complete walk on this endpoint.

It stayed invisible for weeks because a three year window fit under the cap. The first full history walk shipped a dashboard missing the newest three years of revenue, and the thing that caught it was a guard on our own revenue store refusing to let a total shrink. Nothing in the API response would have.

walk.jsillustrative
// Disbelieve the empty token. Resume from the newest order_time seen, past the ids at that second.
let floor = null, seenAtFloor = new Set(), all = new Map();

while (true) {
  const filter = floor ? `order_time>=${floor}` : undefined;
  let token = undefined, fresh = 0;

  do {
    // The token remembers the query that minted it. Send the IDENTICAL filter with every follow, or it is a 400.
    const page = await keap("/crm/rest/v2/orders", { filter, page_token: token, page_size: 100 });
    for (const o of page.orders) {
      if (!all.has(o.id)) { all.set(o.id, o); fresh++; }
      if (floor === null || o.order_time > floor) { floor = o.order_time; seenAtFloor = new Set([o.id]); }
      else if (o.order_time === floor) seenAtFloor.add(o.id);
    }
    token = page.next_page_token || undefined;
  } while (token);

  if (fresh === 0) break;   // only a resumed walk that surfaces nothing new is the end
}

// The 400 body for forgetting the filter on a token follow, verbatim from Keap:
const FILTER_MISMATCH = "Invalid pagination key - Query parameters from the pagination key do not match the query parameters from the request";

Two details in there earn their lines. The page token remembers the query that minted it, and following one without the identical filter is a 400 naming an invalid pagination key. We found that the first time a resumed segment fetched its second page, which is to say the first time the fix ran on real data. And the resume keeps the ids seen at the floor second, because >= will hand them back again and a walk that counts them twice is no better than one that misses them.

One honesty note on the floor itself. Resuming from order_time>= is only safe if everything older than the floor has already arrived, and the walk is not sorted: order=order_time returns 200 and is silently ignored, so you get Keap's natural order. In the first 499 rows on that account, 207 arrive out of order_time sequence, with backward steps up to 619 days. It works anyway because those inversions are local: natural order tracks id, id tracks creation, and every order older than the floor has been delivered by the time the floor gets there. The full walk returns 1,376 of 1,376. What would break it is an order backdated below a floor already passed, and no such order exists on any account we have measured. Worth knowing before you trust the walk on an account that backdates invoices.

WHAT AN HONEST NUMBER LOOKS LIKE

Put it together and the report that survives the bank statement follows six rules, none of them long.

This is the logic MyVitalAssistant's Keap connector runs on every account it reads, and every rule in it was paid for by a number that was wrong on a real customer's dashboard first. If you are building your own, pull the refund_status distribution on your account and look at what the write offs did to last quarter. It takes ten minutes and it changes how you read every total you have shipped so far.

COMMON QUESTIONS

Why does my Keap revenue total not match the bank?
Because a naive sum of total_paid on PAID orders counts money that never arrived. Keap records a write off as a payment row, so a written off invoice reads PAID with total_paid equal to total and refund_total null, exactly like a paid one. On one live account twelve of 1,487 orders were write offs or refunds and they added $4,929 of the customer's own bad debt to the report. Refunds cut the other way, and the v2 walk can end early with orders remaining, so the total can be short as well as fat. Settle the write off family against the ledger and walk the orders to a real end before you compare anything to the bank.
Is a PAID order with total_paid 0 real money?
Usually yes. On one live account 18 of 27 PAID orders carried total_paid 0, spanning 2015 to 2026, because the payments were taken on an outside merchant account and the status was set by hand with no Keap payment record. PAID with an empty rollup means total is the amount. Only a status that is not PAID needs total_paid as proof. A DRAFT with zero paid is a receivable, not a sale.
Why is refund_total negative in one API and positive in the other?
Because the two order APIs disagree on sign and unit for the same field on the same order. Order 229 reads {"amount": 25000}, positive cents, on v2, and -250.0, negative dollars, on v1, and neither response says which convention you are holding. Stay on v2 for the refund family: total_paid minus refund_total is the honest figure, and it beats the ledger, which on order 579 summed to positive 197 across three human entries while Keap correctly reported the order fully refunded.
Can I trust refund_status?
Only as a signal to look harder. It is the one order level field that distinguishes a write off from a payment, and it is v2 only, so you need it. But it never goes back: order 2800 was written off, an Adjustment row of negative $697 cleared it, a real APPROVED $697 was collected, and it still reads WRITE_OFF. Drop on the status alone and you delete real revenue as confidently as trusting total_paid invents it. Use the status to pick which ledgers to read, then let the ledger decide.
Why did my orders walk stop early with no error?
Because the v2 orders list ends with an empty next_page_token while orders remain, and presents that exactly like a complete result. An unbounded walk on a live account stopped after 499 orders, resumed with order_time>= the last row's time, and stopped again 296 rows later, so the cap is not a constant. Treat an empty token as a prompt to resume from the newest order_time seen, send the identical filter with every token follow or you get a 400, and treat only a resume that surfaces nothing new as the end.
THE NUMBER HAS TO SURVIVE THE BANK STATEMENT

Every rule on this page came from a total that was wrong on somebody's dashboard before it was right. We have been reading money out of Keap since 2010, and the part the documentation leaves out is always the part that costs you the customer's trust. If your revenue report and your deposits disagree and you would like to know why before the owner asks, that is a conversation worth having.

Book a Free Consult