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