Every new APIVerve account goes through three checks before we create it: is the email deliverable and not disposable, is the IP a Tor exit, and is the IP on a blacklist. Between September 23 and October 22, 2026, 535 signup attempts reached that gate: 436 became accounts and 99 were refused, from 36 IP addresses. All 99 refusals came from the IP blacklist. The email check and the Tor check didn’t fire once.
Until August there was a fourth check. It was blocking universities, and we took it out.
The check, in about 50 lines of Node
Ours runs inside our backend, but the logic is short enough to show as a standalone Express server. It calls three APIVerve endpoints in parallel and blocks on the first one that objects, in a fixed order: email, then Tor, then the blacklist.
// signup-gate.js
const express = require('express');
const API = 'https://api.apiverve.com/v1';
const KEY = process.env.APIVERVE_API_KEY; // server-side only, never in the browser
async function call(path) {
const res = await fetch(`${API}/${path}`, {
headers: { 'x-api-key': KEY },
signal: AbortSignal.timeout(4000),
});
if (!res.ok) throw new Error(`${path.split('?')[0]} returned ${res.status}`);
return (await res.json()).data;
}
// The same order our dashboard uses: email, then Tor, then the IP blacklist.
async function checkSignup({ email, ip, viaOAuth }) {
const [mail, tor, blacklist] = await Promise.all([
viaOAuth ? null : call(`emailvalidator?email=${encodeURIComponent(email)}`),
call(`tordetect?ip=${encodeURIComponent(ip)}`),
call(`ipblacklistlookup?ip=${encodeURIComponent(ip)}`),
]);
if (mail && (mail.isDisposable || !mail.isMxValid)) return { ok: false, reason: 'email' };
if (tor.isTor) return { ok: false, reason: 'tor' };
if (blacklist.isIPBlacklisted) return { ok: false, reason: 'ip' };
return { ok: true, reason: null };
}
const app = express();
app.use(express.json());
app.post('/signup', async (req, res) => {
// Behind Cloudflare the visitor is in CF-Connecting-IP. req.ip would be Cloudflare's edge.
const ip = req.get('cf-connecting-ip') || req.ip;
let verdict;
try {
verdict = await checkSignup({ email: req.body.email, ip, viaOAuth: false });
} catch (err) {
// Fail open: an outage on our side should not turn away real people.
console.warn('signup check failed, allowing:', err.message);
verdict = { ok: true, reason: null };
}
console.log(JSON.stringify({ email: req.body.email, ip, ...verdict }));
if (!verdict.ok) {
return res.status(400).json({ error: 'SIGNUP_BLOCKED', reason: verdict.reason });
}
// ...create the account here
res.status(201).json({ created: true });
});
module.exports = { checkSignup };
if (require.main === module) app.listen(3000, () => console.log('listening on 3000'));
We ran it against four signups. The Tor address is the first entry on the Tor Project’s public exit list that day.
jane.doe@gmail.com 8.8.8.8 {"ok":true,"reason":null} 900 ms
jane.doe@gmail.com 8.8.8.8 {"ok":true,"reason":null} 158 ms
jane.doe@gmial.com 8.8.8.8 {"ok":false,"reason":"email"} 239 ms
jane.doe@gmail.com 171.25.193.25 {"ok":false,"reason":"tor"} 130 ms
The first check took 900 ms and the identical repeat took 158. The first request from a fresh process pays for DNS and a new TLS connection, and signups arrive one at a time, so plan for the slow number rather than the fast one.
The IP header comes first. If your app sits behind Cloudflare, a load balancer or any proxy, req.ip is the proxy, and every visitor shares it. Check one wrong IP and you either block nobody or block everybody. Trust CF-Connecting-IP only when Cloudflare really is in front of you; otherwise anyone can send that header and pick their own IP. Express’s trust proxy setting is the right tool when you’re behind your own load balancer.
Then there’s failing open. The catch lets the signup through. While we were testing this post, the first run hit a network error (fetch failed) and the check let a signup through unchecked, which is the behaviour we want. A fraud check that fails closed turns every hiccup at your vendor into an outage on your signup page. Our dashboard does the same, with a 15-second budget instead of 4.
OAuth signups skip the email check. If someone signs up with Google, GitHub or Microsoft, the provider has already verified the address. We still check the IP.
And the key stays on the server. One of the top-ranking tutorials for IP checks puts its API key in client-side JavaScript, where anyone can read it out of the page and spend the credits.
What 30 days of refusals looked like
We log every refusal with the IP, the blacklist’s verdict, a country from our own IP lookup and the email domain. Cloud Logging keeps 30 days, so this is the whole window available on October 23.
| Count | |
|---|---|
| Signup attempts that reached the check | 535 |
| Accounts created | 436 |
| Refused attempts | 99 |
| Distinct IPs behind those attempts | 36 |
| Refused by the IP blacklist | 99 |
| Refused by the email check | 0 |
| Refused by the Tor check | 0 |
| Countries (by IP) | 20 |
| Days with at least one refusal | 21 of 30 |
The gap between 99 attempts and 36 addresses is retries. One address tried 11 times, another 8, and ten more tried four times each. People who get refused try again, which is worth knowing before you decide what your error message says.
Every one of the 99 had the same blacklist profile: listed for inbound attacks, not outbound, threat level high. The blacklist has a stricter “critical” tier for IPs listed in both directions. Not one signup attempt came from a critical IP, so a rule that blocked only on critical would have blocked nothing. If you’re tuning a similar gate, look at what your blocks actually contain before picking a threshold that sounds safer.
Geography was spread out. Indonesia and the US had four addresses each, Bangladesh and Nigeria three, India, Japan, Pakistan and Canada two, and twelve other countries one. When we traced a sample of earlier refusals in September, some sat in hosting hubs such as Amsterdam and Hong Kong, and several were mobile carriers, Indosat in Jakarta among them. Carriers put many customers behind one public IP, so a blacklist entry earned by one of them lands on all of them.
31 of the 36 addresses signed up with a Gmail account. That’s why the email check catches nothing here: the people behind a flagged IP aren’t using throwaway addresses. Two attempts came from company email domains. Those are almost certainly real people, and we turned them away.
Two checks that did nothing, and why we keep them
Zero refusals from the email check sounds like a reason to remove it. We’re keeping it. It runs in parallel with the IP checks, so it costs no time, and the zero is partly because so few signups reach it: of the 436 accounts created in the window, 314 used Google and 50 GitHub, leaving 72 that went through the email check at all.
It also catches typos. jane.doe@gmial.com above was refused as disposable, which is deliberate (the email validation post explains why typo domains sit on those lists). A refusal message that says “check for a typo” turns that block into a fix.
Tor at zero matches what we saw in an earlier 45-day sample (July to September 2026): no Tor signups at all. People who want to abuse a free API tier don’t need Tor; a VPN or a cheap server is faster.
The rule we removed: blocking datacenter IPs
Until August 4, 2026, the gate had a fourth rule. Any IP inside a hosting or datacenter range was refused. It sounds like an easy win, since bots run on servers and people don’t.
Over the 28 days before we removed it, that rule refused 128 attempts from about 48 addresses. When we looked at them, a lot weren’t bots. University and institutional networks egress through ranges registered to hosting providers, so people on those networks were refused. We estimated roughly 50 real signups a month were lost, to protect a free tier that was then worth 100 calls.
It was worse than the numbers suggest, because of how the refusal looked. Firebase had already created the account in the browser, so the page thought signup succeeded, then the server said no, and the page spun until a failsafe dropped the visitor back on an empty form with no error. Every retry then failed with “email already in use”, forever, because the half-created account was still there. One person in the logs tried three times under the same account before giving up.
We fixed that in the same change that removed the rule. The page now deletes the orphaned account and tells the visitor why they were refused.
Why a VPN detector can’t be a second opinion
When the blacklist fires, the obvious move is to ask a second service whether it agrees. We tried that with our own VPN detector, and it doesn’t work, because the two answer different questions.
Here’s the Tor exit from the test above, checked against three of our endpoints on October 23:
// tordetect
{ "ipAddress": "171.25.193.25", "isTor": true }
// ipblacklistlookup
{ "ipAddress": "171.25.193.25", "isIPBlacklisted": true }
// vpndetector
{ "ip": "171.25.193.25", "is_vpn": false, "is_datacenter": false }
The VPN detector says the address is not a VPN and not a datacenter, and that’s correct: Tor isn’t a VPN. In September we checked a sample of eight addresses our blacklist had refused at signup, and the VPN detector called all eight clean. The blacklist is about what an address has been seen doing; the VPN detector is about who the address belongs to. They rarely agree, so neither works as a check on the other.
What we’d do with a refused signup instead
The gate blocks. If we were building it today, we’d degrade instead: let the account through, require email verification with a code before the first API key works, hold the free credits until that’s done, and flag the account for a look. A real person behind a shared carrier IP loses a minute, and a bot never gets the free credits it signed up for.
We haven’t built it yet, so the evidence for it is the two company-domain refusals above and the retries. If you’re starting from nothing, build the degrade path first and make the hard block the exception.
Whatever you build, log the full verdict with each refusal: inbound, outbound, threat level, country, email domain. We used to log only the IP, and answering “who are we blocking?” meant paying for lookups after the fact. The table above came from one log query.