A user spent a day confirming an email address that was already confirmed, because my API kept telling him to.
He was right and my error message was wrong. Here is the shape of the bug, because I think it is common and almost invisible.
The report
He found me on LinkedIn — not through support, because my support address bounced, which is a second bug I will get to.
My account is connected, but the API keeps returning: "Confirm your email first". I've confirmed the email multiple times, but the verification state never updates.
My first instinct was that verification was broken. It wasn't. His account showed verified_at set to one minute after signup. He had confirmed it correctly, the first time, and everything since had been theatre.
The line
if (!account.verified_at || (account.key_scope || "full") !== "full")
return res.status(403).json({ error: "Confirm your email first — we sent you a link." });
Two conditions, one message.
The first is "your mailbox is unproven". The second is "this key is a preview key and cannot publish". They are unrelated failures with completely different fixes, and for months every caller hitting either one got told to go check their email.
He had hit the second. At signup the dashboard hands you a preview key so you can look around before verifying, and he had — reasonably — copied that key straight into his n8n workflow. Verifying his email did not upgrade it. So the key stayed limited, the API kept refusing, and the message kept pointing at the one thing that was already fine.
You can watch him try in the database: seven login tokens between 18:08 and 18:51. That is somebody confirming an email over and over because a computer told him to, each time getting a fresh working key he had no reason to know he needed.
Two fixes, and the second is the one that matters
Say which thing is wrong.
function unverifiedError(account) {
if (account?.verified_at) {
return {
error: "This is a preview key, which cannot publish. Your email is already confirmed — " +
"open API & MCP in the dashboard and use the key shown there.",
code: "preview_key",
};
}
return { error: "Confirm your email first — we sent you a link.", code: "email_unverified" };
}
Then remove the situation entirely. A better message would still have left him editing config. The preview key is limited for exactly one reason: we do not know the mailbox is his. The moment he proves it, that reason is gone — and the key he already pasted into n8n is the one that should start working.
export async function markVerified(accountId) {
await db().from("pw_accounts").update({ verified_at: new Date().toISOString() })
.eq("id", accountId).is("verified_at", null);
await promotePreviewKeys(accountId); // the reason for the restriction just expired
}
His existing setup now works without him touching anything.
The part I find uncomfortable
This bug was invisible from the inside. No exception, no 500, no alert. The API answered 403 with a clear, well-written, grammatically perfect sentence — that happened to be about the wrong problem. Every dashboard I had was green.
It surfaced because one user went out of his way to find me on a social network after his email to support@ bounced. That address appeared in eight places on my site and had never existed: the domain forwards mail, and nobody ever created the alias. So the one person motivated enough to report it had to work for the privilege.
I have since checked, and his was the only preview key in that state in the entire database. The bug found the one user it could hurt, and he still had to fight through a broken support channel to tell me.
What I would take from it
- An error that covers two causes will send someone to fix the wrong one. They will repeat it, because nothing changes, and conclude your product is broken. Name the specific cause.
- A restriction should expire when its reason does. If you gate something on an unproven condition, lift the gate the instant the condition is proven — including on credentials already issued.
- Test the address on your own contact page. Mine bounced for months while I printed it in eight places.
I write PostWire, which publishes one draft natively to every social network. He was trying to post TikTok videos from n8n, which is exactly what it is for, and it told him to check his email instead.
Top comments (0)