Vital Guides

GET EMAIL OPENS AND CLICKS OUT OF THE KEAP API

Every Keap agency has been told the API carries no engagement data, and most of them built tag instrumentation to fake it. There is an endpoint. It returns the last send, the last open and the last click for any address, and it was there the whole time. Here is what it gives, what it does not, and how to repair the addresses your old integration left unmailable.

Updated September 2, 2026· Measured across 25 live addresses, August 2026· Keap, Email, REST v2· Intermediate

THE ANSWER EVERYBODY WAS GIVEN

You want one column on a report. Last opened. Or one list: everybody who has stopped opening. Or one automation that waits until a contact has actually read something before it sends the next thing. Every one of them needs the same fact, which is when this person last engaged with an email.

So you go looking in the API. The v1 contact has no engagement fields. The v2 contact has none either. You search the forums and the answer comes back the same from everybody, us included: Keap does not expose email engagement through the API. Our own internal notes read "no email engagement data at all through any endpoint" until August 2026. We were confident about it. We were wrong.

Being wrong is not the expensive part. The expensive part is what the whole ecosystem built on top of the wrong answer, which is tag instrumentation. Every broadcast gets a link click goal that applies a tag, every campaign email gets a tagging step, and a saved search on those tags gets called engagement reporting. It produces data. It has two problems that never go away.

It only produces data from the day it is set up. A contact who opened everything for three years and then went quiet looks identical to one who never opened anything, because the tags did not exist for the years that mattered. And it is per campaign, forever. The first email that ships without the step is a hole in the report nobody sees.

THE ENDPOINT NOBODY HAD FOUND

The pointer came from someone at Keap, in conversation rather than off a documentation page, which tells you roughly how findable it is. The engagement data is not on the contact. It is on the email address, which is its own resource in the v2 API, keyed by the address rather than by a contact id.

Read an addressmeasured, timestamps changed
GET /crm/rest/v2/emailAddresses/jane%40example.com/status
X-Keap-API-Key: <service account key>

// 200
{
  "email":          "jane@example.com",
  "opted_in":       true,
  "status":         "UNENGAGED_MARKETABLE",   // one of 17 values
  "last_sent_time":  "2026-07-30T14:02:11.000Z",
  "last_open_time":  "2026-06-18T09:41:57.000Z",
  "last_click_time": null                        // never clicked, or not recorded
}

Five things come back. last_sent_time, last_open_time and last_click_time are what you came for. opted_in is a boolean. status is a value from an enum of seventeen, and it is the one that carries the compliance half of this story, which the second part of this guide is about.

Note the shape of the key. You read by address, so a contact with three addresses is three reads, and there is no contact id in the path. We read one address per request, which is how the numbers below were gathered. If a bulk form exists we have not measured it, so plan on one request per address.

WHAT 25 LIVE ADDRESSES CAME BACK WITH

An endpoint that exists and an endpoint that is populated are different things, and Keap has a habit of the first without the second. So before building on it we read 25 addresses off a live account and counted what was there.

All 25 read. No 404s, no empty bodies. 18 of the 25 carried a last_sent_time, 16 carried a last_open_time, and 7 carried a last_click_time. On status, 16 came back UNENGAGED_MARKETABLE and 9 came back SINGLE_OPT_IN. Those were the only two statuses in the sample, which says something about that account and nothing about the other fifteen values.

Read those numbers the right way round. 16 of 18 sent addresses had an open on record, which is dense enough to build on. That is not an open rate, because the endpoint cannot give you one, and we will get to why. It says the field is populated on most addresses that have received anything, so a column built from it will mostly have something in it.

The statuses are Keap's own words for the address, and the sample only proves two of them. UNENGAGED_MARKETABLE is what it sounds like: Keap still considers the address mailable, and considers it unengaged by whatever rule Keap uses. We have not measured that rule and this guide does not guess at it. Treat the timestamps as the facts and the status as Keap's opinion.

WHAT IT IS NOT: NO HISTORY, NO COUNTS, NO BOUNCES

Here is the boundary, and it is worth being precise about because the endpoint is good enough that you will be tempted to ask it for more than it has.

It returns the last of each thing. The last send, the last open, the last click. One timestamp per field. No per send events, so you cannot see that Tuesday's broadcast was opened and Thursday's was not. No counts, so you cannot say a contact opened four of the last ten. No bounce list. Open rate, click rate, engagement over time: none of it, because all of those need the events and this only ever holds the newest one.

What that leaves you is recency, and recency turns out to be most of what anybody actually wanted. The column was "last opened". The list was "gone quiet". The automation was "has read something lately". All three are recency questions, and the endpoint answers them on the first read, for every contact, including the ones whose history predates anything you set up.

If you genuinely need history, the endpoint still beats the tags. Poll it on a schedule, store the three timestamps beside the address, and write a new row when one changes. That builds an event stream from today onward, the same limitation the tags always had, with one difference: on day one you already have recency for everybody, and you never add a step to another email.

BUILD LAST OPENED AND GONE QUIET FROM IT

The last opened column is the easy one. Read the status for each address, take last_open_time, format it. If it is null, the column is blank, and blank is the honest answer rather than a fallback to the send date.

Gone quiet takes more thought, because "has not opened" describes three different people and only one of them has gone anywhere. The contact never sent anything is not quiet, they are unmailed. The contact sent things who never opened one is a different problem, possibly a dead address, possibly an image blocker. The contact who used to open and stopped is the one the list is for. The timestamps tell them apart, which the tags never did.

Bucket an addressillustrative
function bucket(s, quietAfterDays = 90) {
  if (!s.opted_in)          return "unmailable";   // no content fixes this one
  if (!s.last_sent_time)    return "never_sent";   // not quiet, unmailed
  if (!s.last_open_time)    return "never_opened"; // dead address or blocker

  const days = (Date.now() - Date.parse(s.last_open_time)) / 86400000;
  return days > quietAfterDays ? "gone_quiet" : "engaged";
}

The first branch is the one people forget. An address with opted_in false has not gone quiet, it has been switched off, and no subject line brings it back. Put those in their own bucket so they never pollute a re engagement campaign, and so the count of them is visible. On an account with a broken integration behind it, which is the second half of this guide, that bucket can be most of the list.

Ninety days is a starting point, not a finding. Pick the window from the sending cadence: a business that mails weekly can call thirty days quiet, one that mails quarterly cannot.

For a report, keep the result in the reporting store and refresh on a schedule. If you want it back on the contact as a custom field so a Keap automation can act on it, count the field model before you create anything: the ceiling is real and it is not the documented number, which is guide 04. It is also what we use for engagement recency on a contact card inside MyVitalAssistant, read live rather than written back, because a value one request away does not need a field.

WHY YOUR API CREATED CONTACT NEVER GETS EMAIL

Now the other half, which starts with a support ticket you have probably seen. A contact was created or updated by an integration, their address is right, and they never receive anything. Not the campaign, not the broadcast, not the magic link they asked for twice. Keap shows no error. The email simply does not go.

Read the address status and you will find NON_MARKETABLE. The integration wrote the address with no opt in, and Keap will not send to an address with no opt in. We verified this on a contact on a live app in August 2026: a v1 PATCH to email_addresses stored the new address perfectly, as NON_MARKETABLE, and Keap refused to send it anything, magic links included. The v1 write has no way to say "this person consented", so it says nothing, and nothing means no.

The same is true on create. We tested it in a sandbox app, two contacts side by side: POST /crm/rest/v2/contacts with opt_in_reason inside the email entry landed SINGLE_OPT_IN with is_opt_in true, and the identical create without it was NON_MARKETABLE from birth. Same endpoint, same body but for one string, and one of the two contacts will never hear from you.

Write an address that stays mailablemeasured, sandbox
// Create: the opt in rides inside each email entry, or it does not happen.
POST /crm/rest/v2/contacts
{
  "given_name": "Jane",
  "email_addresses": [
    { "email": "jane@example.com", "field": "EMAIL1",
      "opt_in_reason": "Submitted the quote request form on example.com" }
  ]
}
// lands SINGLE_OPT_IN, is_opt_in: true. Omit opt_in_reason and it is NON_MARKETABLE from birth.

// Update: update_mask names the WHOLE field. Read the contact first and send every slot.
PATCH /crm/rest/v2/contacts/12345?update_mask=email_addresses
{
  "email_addresses": [
    { "email": "jane@example.com",     "field": "EMAIL1", "opt_in_reason": "..." },
    { "email": "jane@work.example",    "field": "EMAIL2", "opt_in_reason": "..." }
  ]
}

Two rules fall out of this, and they point in opposite directions on purpose. Anything that creates a contact from a form submission must pass opt_in_reason. The person typed their address into your form and pressed the button. That is the consent, and an integration that drops it has broken the thing the form was for. Anything that creates a contact without consent evidence must omit it, deliberately. A sync from an accounting system has no idea whether that person agreed to hear from you, and an opt in reason it cannot back up is not a shortcut, it is a false record.

The update_mask trap is its own ticket. update_mask=email_addresses means "replace the whole email addresses field", not "merge this entry in". Send one slot and the contact's other addresses are gone. Read the contact, carry every address they should keep, then write. Never blind write the slot you changed.

X-Keap-API-Key works on /crm/rest/v2 for all of this, so a Service Account Key is enough and you do not need an OAuth flow to fix an account.

REPAIR THE ADDRESSES YOUR OLD INTEGRATION BROKE

If the previous section described your integration, that account has a population of addresses sitting at NON_MARKETABLE for no reason except that the write forgot to say why. Real people who filled in a real form and have never had an email from you. The fix is the same endpoint as the read, with a different verb.

Opt an address inmeasured, sandbox
PATCH /crm/rest/v2/emailAddresses/jane%40example.com/status
X-Keap-API-Key: <service account key>

{
  "opted_in": true,
  "reason":   "Quote request form, 3 November 2025. Address written by the old sync without an opt in."
}
// both fields are required

// 200. Was NON_MARKETABLE before the call.
{
  "email":    "jane@example.com",
  "opted_in": true,
  "status":   "SINGLE_OPT_IN"       // the docs say Unconfirmed. The API says this.
}

That is the whole call. Both fields are required. We ran it on a sandbox app against an address at NON_MARKETABLE and it came back SINGLE_OPT_IN with opted_in true and HTTP 200. Keap's documentation says the address becomes Unconfirmed. It does not. Trust the response over the page, and do not check for the string the page promised, or your repair script reports failure on every address it fixed.

The documentation also lists OAuth as the only auth for this path. X-Keap-API-Key works. So a repair run is a script with a service account key, a list of addresses, and a reason per address that says where the consent actually came from.

That reason is not decoration. It is the record Keap keeps of why this address is mailable, and it is what someone reads when a contact asks why they are getting email. "Repaired by script" is a bad answer. The form and the date is a good one, and your integration knows both because it created the contact.

KEAP'S OWN RULE IS WHAT MAKES THIS SAFE TO OFFER

An endpoint that flips addresses to marketable should make you nervous. The obvious failure is a repair script that is a little too enthusiastic and mails somebody who unsubscribed on purpose. We looked for that failure before offering this to anyone, and Keap has already closed it.

The PATCH only moves four statuses. Unengaged Marketable, Unengaged Non Marketable, Non Marketable and Opt Out Manual can be moved to marketable. List Unsubscribe, Opt Out, System and the rest stay exactly where they are, however many times you call it and whatever reason you give. An address that unsubscribed itself through the link in an email cannot be resurrected by this call. Keap enforces that, not your script, so your script does not have to be perfect to be safe.

That is the difference between this and a blind contact PATCH. A repair that rewrites the contact's email field is writing the address rather than asking Keap to change its status. The status endpoint asks, and Keap says no where no is the right answer. Offer the first. Never offer the second.

So the repair loop reads simply. List the contacts your integration created, which you can do because you created them. Read each address's status. For each one at NON_MARKETABLE, and only those, send the PATCH with the real consent evidence in the reason. Read the status again and log what came back. An address that stayed put did so for a reason Keap knows and you do not, and the log is where you find out which ones those were.

Do not blanket flip an account. The rule protects you from mailing an unsubscriber. It does not protect you from opting in an address that never consented, because Keap cannot know that. That knowledge lives in your integration, in the form the person filled in. Repair the addresses you can point at a form for. Leave the rest at NON_MARKETABLE, which is where an address with no consent belongs.

WHAT TO DO WITH THE TAGS

Keep them if they answer a question this endpoint cannot. A tag that fires on a click of one specific link in one specific email is per campaign engagement, and the status endpoint has no idea which link was clicked or in what. That is what campaign goals are for.

Retire everything that was only ever there to approximate recency. The "opened something recently" tag, the saved search on it, the step that had to be added to every email and was forgotten on half of them. One read per address replaces all of that, with history the tags could never have, because Keap recorded the timestamp whether or not anybody set anything up.

And update the answer you give. The next time somebody says Keap has no engagement data in the API, the reply is that it has recency on the address resource, and the reason nobody found it is that nobody looked on the address.

COMMON QUESTIONS

Does the Keap API expose email opens?
Yes. GET /crm/rest/v2/emailAddresses/{email}/status returns last_sent_time, last_open_time, last_click_time, an opted_in boolean and a status value. We read 25 live addresses in August 2026 and all 25 answered, 16 with a last open. It is not documented anywhere you would look first, which is why the ecosystem believed it did not exist.
Can I get open counts or a history?
No. You get the last send, the last open and the last click, one timestamp each. No per send events, no counts, no bounce list. Recency, not a record. If you need history, poll on a schedule and store each value when it changes, which builds one from today onward.
Why is my API created contact not receiving emails?
The address was written without an opt in. A v1 PATCH to email_addresses stores it as NON_MARKETABLE and Keap will not send it anything, magic links included. A v2 create without opt_in_reason does the same from birth. Read the status to confirm, then write addresses through v2 with opt_in_reason inside each email entry.
Can I re opt in an address that unsubscribed?
No, and Keap is the one saying no. The status PATCH moves Unengaged Marketable, Unengaged Non Marketable, Non Marketable and Opt Out Manual to marketable. List Unsubscribe, Opt Out and System stay put however many times you call it. That is exactly what makes it safe to use for repairing what an integration broke.
Why does the docs say Unconfirmed but the API says SINGLE_OPT_IN?
We do not know why the page and the API disagree, only which one to trust. On a sandbox app the response carried SINGLE_OPT_IN and opted_in true with HTTP 200, and the read back agreed. Build against the response. A check for the string Unconfirmed reports failure on every address it fixed.
WE FIND THESE BY BEING PAID TO

Nobody discovers an undocumented endpoint by reading. We have been connecting Keap to the rest of the stack since 2010. If your account has an engagement report built on tags, or a population of contacts who mysteriously never get email, that is a conversation worth having.

Book a Free Consult