Free to read
Lookups, the feed, statistics and delisting appeals need no API key. Only submitting reports does.
A centralised threat-intelligence service for abusive IP addresses, IP ranges, e-mail addresses and e-mail domains. Reads are free and need no account. E-mail data is handled exclusively as SHA-256 digests, and a k-anonymity range endpoint lets you test an address without ever disclosing it.
https://black.majevski.com
Version v1
Format JSON
Reads no key required
M Blacklist collects abuse observations from many independent websites, corroborates them, and publishes the confirmed result as a queryable API and as an incremental feed you can mirror locally. It exists so that a brute-force campaign blocked on one site is already known to the next one.
Lookups, the feed, statistics and delisting appeals need no API key. Only submitting reports does.
Plaintext e-mail addresses are never stored, never logged and never returned through this API — only SHA-256 digests (any operator-side moderation copy is encrypted at rest, key held outside the database).
An entry is published only after independent verified sites agree — or a site whose trust an operator has explicitly pinned reports it.
The cursor-paginated feed lets you answer every lookup from your own database, with zero latency on the hot path.
| Kind | Canonical value | Example |
|---|---|---|
ip | A single IPv4 or IPv6 address | 203.0.113.5 |
ip_range | CIDR reduced to network address plus prefix length | 203.0.113.0/24 |
email_hash | SHA-256 of the normalized address, 64 lowercase hex | ff8d9819…c6d976 |
email_domain | Lowercase hostname, no @, no scheme | spamdomain.test |
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /v1/health | None | Liveness probe |
GET | /v1/stats | None | Aggregate statistics |
GET | /v1/check | None | Check one subject |
POST | /v1/check | None | Check up to 100 subjects |
GET | /v1/range/{prefix} | None | k-anonymity e-mail lookup |
GET | /v1/feed | None | Incremental confirmed feed |
POST | /v1/report | write | Submit observations |
GET | /v1/account | key | Key & site status |
POST | /v1/appeal | None | Request delisting |
GET | /v1/cron/run | cron key | Run scheduled maintenance |
application/json; POST
bodies must be JSON with a matching Content-Type.2026-07-28T09:14:02Z.X-MBL-Version: 1. A breaking change
ships as a new path prefix (/v2), never as a silent change to
/v1.GET endpoints send Access-Control-Allow-Origin: *
and answer OPTIONS preflight, so browser clients work.
Authenticated endpoints deliberately do not, because your API
key must never be embedded in front-end code.The fastest useful call is a single IP lookup. No account, no key, no headers.
curl -s "https://black.majevski.com/v1/check?type=ip&value=203.0.113.5"
<?php
declare(strict_types=1);
/**
* Returns true when the address is listed. Fails OPEN: any transport or
* decoding problem yields false, so a legitimate visitor is never blocked
* because the API was unreachable.
*/
function mbl_ip_listed(string $ip): bool
{
$url = 'https://black.majevski.com/v1/check?' . http_build_query([
'type' => 'ip',
'value' => $ip,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 3,
'ignore_errors' => true,
'header' => "Accept: application/json\r\n"
. "User-Agent: my-app/1.0\r\n",
],
]);
$body = @file_get_contents($url, false, $ctx);
if (false === $body) {
return false;
}
$data = json_decode($body, true);
return is_array($data) && true === ($data['listed'] ?? null);
}
var_dump(mbl_ip_listed('203.0.113.5'));
const BASE = 'https://black.majevski.com';
async function mblIpListed(ip) {
const url = `${BASE}/v1/check?type=ip&value=${encodeURIComponent(ip)}`;
try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return false; // fail open
const data = await res.json();
return data.listed === true;
} catch {
return false; // fail open
}
}
console.log(await mblIpListed('203.0.113.5'));
{
"query": { "type": "ip", "value": "203.0.113.5" },
"listed": true,
"result": {
"kind": "ip",
"status": "confirmed",
"categories": ["bruteforce"],
"score": 72.5,
"report_count": 14,
"distinct_sites": 3,
"first_seen": "2026-05-02T18:21:44Z",
"last_seen": "2026-07-27T23:06:10Z",
"matched": "203.0.113.0/24"
}
}
Never let a threat-intelligence lookup decide the fate of a request when the lookup itself failed. Use short timeouts (2–3 s), treat every network error, non-2xx status and malformed body as “not listed”, and never call the API synchronously on a hot path — mirror the feed instead and answer locally.
Use /v1/check for one-off lookups and its
batch form for up to 100 values per call.
Use /v1/range/{prefix} so the address never
leaves your server — see the worked example.
Pull /v1/feed on a schedule and answer every
lookup from your own database. See mirroring the feed.
Register your domain, verify ownership, get a key and report what you block. Contribution is what keeps the data set useful.
Every subject in the database is one entry. Reports accumulate against it and drive its status.
| Status | Meaning | Returned as listed? |
|---|---|---|
pending | Reported, not yet corroborated. | No |
confirmed | Published. The only status that appears in the feed. | Yes |
expired | No activity within the retention window (90 days by default). | No |
removed | Delisted by an administrator, usually after an appeal. | No |
allowlisted | Permanently exempt — protected infrastructure, CDNs, resolvers. | No |
| Category | Use it for |
|---|---|
bruteforce | Repeated failed authentication — login, XML-RPC, SSH, API credential stuffing. |
spam | Unsolicited content submission — comments, contact forms, trackbacks. |
fake_account | Fraudulent, throwaway or automated registrations. |
abuse | Scanning, exploit probing, path traversal attempts, other malicious traffic. |
A single site's opinion is not enough to list anything. An entry is promoted from
pending to confirmed when either:
policy.corroboration_threshold), orpolicy.auto_confirm_trust). Pinning is a deliberate switch in
the admin panel; a score a site reached on its own never qualifies.
Two kinds are excluded from that shortcut entirely: ip_range and
email_domain always wait for a second, distinct
site — however trusted the reporter. One CIDR or one mail domain can take out a
whole network or provider, so nobody gets to publish one unilaterally.
Every entry carries a score from 0.00 to
100.00 derived from report volume, how many independent sites
corroborated it, and recency. It decays as an entry goes quiet. Use it to grade
your response: a high score justifies an outright block, a low one a CAPTCHA or
extra scrutiny. Treat exact values as advisory, not as a stable API contract.
Each reporting site has a trust score from 0 to 100. It
rises with accepted reports and account age and falls — sharply — when a site
reports protected infrastructure. Low trust means reports carry less weight;
sustained abuse of the reporting endpoint results in suspension.
The automatic formula is deliberately capped below
policy.auto_confirm_trust, so no amount of reporting will earn a
site the right to publish without corroboration: “accepted” only means
well-formed, and volume is trivially farmed with meaningless indicators. That
right comes solely from an operator pinning the score by hand, and is intended
for first-party sites the operator runs themselves.
Reports targeting private, reserved, loopback or link-local space
(RFC 1918, 127.0.0.0/8, 169.254.0.0/16,
::1, fc00::/7) and the published ranges of major
infrastructure providers are rejected with reason
protected_range. Such rejections count against the reporting site.
Filter locally before submitting: never report an address you obtained from an
untrusted proxy header.
The same guard covers major mail providers. An
email_domain report for gmail.com,
googlemail.com, outlook.com, hotmail.com,
yahoo.com, icloud.com, proton.me,
gmx.net, qq.com, the large transactional senders
(sendgrid.net, mailgun.org, …) or
any subdomain of them is refused with
protected_range — blacklisting such a domain would silently blank
a large share of the world's legitimate mail. Report the offending
email_hash instead; individual addresses at those providers list
normally.
An entry expires 90 days after the last corroborating report
(policy.entry_ttl_days). Expired entries disappear from lookups and
from the feed. If you mirror the feed, apply the same window locally so your
copy does not outlive the source.
Reading is anonymous. You need a key only to submit
reports or to read /v1/account. A key
also raises your read budget from 60 to 600 requests per minute.
An API key is a single opaque string:
mbl_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef0123456789abcdef
└┬─┘ └─────┬────┘ └───────────────────────┬──────────────────────┘
│ │ │
│ │ └─ secret, 48 lowercase hex
│ └──────────────────────────────── prefix, 12 lowercase hex (public)
└────────────────────────────────────────── fixed marker
The service stores only the prefix and an HMAC-SHA-256 of the secret. The full key is displayed exactly once, at creation time, and can never be retrieved again — if you lose it, revoke it and issue a new one.
Two equivalent headers are accepted. Send one, never both.
Authorization: Bearer mbl_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef0123456789abcdef
X-API-Key: mbl_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef0123456789abcdef
# Keep the key in the environment, never in a repository.
export MBL_KEY="mbl_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef0123456789abcdef"
curl -s https://black.majevski.com/v1/account \
-H "Authorization: Bearer $MBL_KEY"
<?php
declare(strict_types=1);
$key = getenv('MBL_KEY') ?: '';
$ch = curl_init('https://black.majevski.com/v1/account');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (200 !== $status) {
// Inspect $body['error']['code'] — see the error table.
throw new RuntimeException('Account lookup failed with status ' . $status);
}
$account = json_decode((string) $body, true);
echo $account['site']['domain'], ' — trust ', $account['site']['trust_score'], PHP_EOL;
// Server-side only. Never ship an API key to a browser:
// authenticated endpoints intentionally send no CORS headers.
const res = await fetch('https://black.majevski.com/v1/account', {
headers: {
'Authorization': `Bearer ${process.env.MBL_KEY}`,
'Accept': 'application/json',
},
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const account = await res.json();
console.log(account.site.domain, account.site.trust_score);
| Scope | Grants |
|---|---|
read | Authenticated reads at the higher rate limit, plus /v1/account. |
write | POST /v1/report. Requires a verified site. |
A key missing the required scope gets 403 forbidden_scope. A valid,
correctly scoped key on a site that has not completed domain verification, or that
has been suspended, gets 403 site_unverified.
Sites are registered by an administrator. You supply the domain in canonical
form — lowercase, no scheme, no www., punycode for IDNs — and a
contact address.
Publish either a DNS TXT record at
_mblacklist.<your-domain> with the value
mbl-verify=<token>, or a file at
https://<your-domain>/.well-known/mblacklist-<token>.txt
whose body is the token. DNS is checked first, and always works; the
HTTP method needs ext-curl on the server, which lets the fetch be pinned
to the address that was vetted for SSRF — where it is missing, that check
reports http_verification_unavailable rather than fetching
unpinned, and you should use the TXT record.
On verification a key is issued and shown a single time. Store it in an environment variable or a secrets manager — never in version control, never in client-side code.
Rotate keys periodically and immediately after any suspected exposure. Use a
separate key per deployment so one can be revoked without downtime elsewhere.
Revocation is instant — a revoked key returns 401 unauthorized.
These rules are normative. The server applies exactly them, and any client that wants its hashes to match must apply exactly them too. A single stray space or an uppercase letter produces a completely different digest and a silent miss.
| Kind | Rule | Result |
|---|---|---|
email |
strtolower(trim($email)), then hash('sha256', $normalized).
No Unicode case folding, no IDN conversion, no dot- or plus-address
stripping — the address is used byte-for-byte after trimming and
ASCII-lowercasing. |
64 lowercase hex |
email_domain |
Lowercase, strip a leading @, strip any
scheme://, strip a leading www., strip a
trailing dot. Max 191 characters. IDNs are left exactly as given. |
spamdomain.test |
ip |
Must parse with inet_pton. Canonical text is
inet_ntop(inet_pton($ip)), so an IPv4-mapped IPv6
address such as ::ffff:203.0.113.5 collapses to
203.0.113.5, and IPv6 is compressed to its canonical
lowercase form. |
203.0.113.5 |
ip_range |
CIDR reduced to the network address plus prefix length. Host bits are
cleared, so 203.0.113.5/24 becomes
203.0.113.0/24. Prefix bounds: 0–32 for IPv4, 0–128 for
IPv6. |
203.0.113.0/24 |
Every entry additionally carries two derived values you may need:
value_hash = sha256(kind . ':' . value) — the stable identifier of
an entry, returned by /v1/report. Note the
literal colon and the fact that kind here is the
canonical kind, so an e-mail is hashed as
email_hash:<digest>, never as email:<address>.hash_prefix — the first 5 characters of value for
email_hash, and the first 5 characters of value_hash
for every other kind. This is the bucket the
k-anonymity endpoint queries.<?php
declare(strict_types=1);
/** SHA-256 of a normalized e-mail address (64 lowercase hex). */
function mbl_email_hash(string $email): string
{
return hash('sha256', strtolower(trim($email)));
}
/** Stable entry identifier: sha256("<kind>:<canonical value>"). */
function mbl_value_hash(string $kind, string $value): string
{
return hash('sha256', $kind . ':' . $value);
}
/** Canonical IP text, or null when the input is not an IP address. */
function mbl_normalize_ip(string $ip): ?string
{
$bin = @inet_pton(trim($ip));
if (false === $bin) {
return null;
}
// ::ffff:203.0.113.5 collapses to 203.0.113.5 here.
$out = @inet_ntop($bin);
return false === $out ? null : $out;
}
/** Canonical e-mail domain, or null when it cannot be normalized. */
function mbl_normalize_domain(string $domain): ?string
{
$d = strtolower(trim($domain));
$d = ltrim($d, '@');
$d = (string) preg_replace('#^[a-z][a-z0-9+.\-]*://#', '', $d);
$d = (string) preg_replace('#^www\.#', '', $d);
$d = rtrim($d, '.');
if ('' === $d || strlen($d) > 191) {
return null;
}
if (!preg_match('/^[a-z0-9](?:[a-z0-9\-._]*[a-z0-9])?$/', $d)) {
return null;
}
return $d;
}
/** CIDR reduced to network address + prefix length, or null. */
function mbl_normalize_cidr(string $cidr): ?string
{
$parts = explode('/', trim($cidr), 2);
if (2 !== count($parts)) {
return null;
}
$bin = @inet_pton($parts[0]);
if (false === $bin || !ctype_digit($parts[1])) {
return null;
}
$bits = strlen($bin) * 8; // 32 for IPv4, 128 for IPv6
$len = (int) $parts[1];
if ($len < 0 || $len > $bits) {
return null;
}
// Clear every host bit.
for ($i = 0; $i < strlen($bin); $i++) {
$keep = max(0, min(8, $len - $i * 8));
$mask = 0 === $keep ? 0 : (0xFF << (8 - $keep)) & 0xFF;
$bin[$i] = chr(ord($bin[$i]) & $mask);
}
return inet_ntop($bin) . '/' . $len;
}
/** SHA-256 of a normalized e-mail address (64 lowercase hex). */
async function mblEmailHash(email) {
const normalized = email.trim().toLowerCase();
const bytes = new TextEncoder().encode(normalized);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Stable entry identifier: sha256("<kind>:<canonical value>"). */
async function mblValueHash(kind, value) {
const bytes = new TextEncoder().encode(`${kind}:${value}`);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Canonical e-mail domain, or null. */
function mblNormalizeDomain(domain) {
let d = domain.trim().toLowerCase()
.replace(/^@+/, '')
.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
.replace(/^www\./, '')
.replace(/\.+$/, '');
if (!d || d.length > 191) return null;
if (!/^[a-z0-9](?:[a-z0-9\-._]*[a-z0-9])?$/.test(d)) return null;
return d;
}
// crypto.subtle requires a secure context (https or localhost).
// In Node.js: import { webcrypto as crypto } from 'node:crypto';
import hashlib
def mbl_email_hash(email: str) -> str:
"""SHA-256 of a normalized e-mail address (64 lowercase hex)."""
normalized = email.strip().lower()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def mbl_value_hash(kind: str, value: str) -> str:
"""Stable entry identifier: sha256("<kind>:<canonical value>")."""
return hashlib.sha256(f"{kind}:{value}".encode("utf-8")).hexdigest()
def mbl_normalize_ip(ip: str):
"""Canonical IP text, or None."""
import ipaddress
try:
addr = ipaddress.ip_address(ip.strip())
except ValueError:
return None
# IPv4-mapped IPv6 collapses to dotted IPv4, matching inet_ntop().
if addr.version == 6 and addr.ipv4_mapped is not None:
return str(addr.ipv4_mapped)
return str(addr)
print(mbl_email_hash(" Alice@Example.COM "))
# ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976
# Normalize, then hash. Note printf, not echo: a trailing newline
# would change the digest completely.
email=' Alice@Example.COM '
normalized=$(printf '%s' "$email" \
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
| tr '[:upper:]' '[:lower:]')
printf '%s' "$normalized" | shasum -a 256 | cut -d' ' -f1
# ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976
# GNU/Linux: use sha256sum instead of shasum -a 256.
Verify your implementation against these before going near production. If any of them differs, your lookups will silently return “not listed” forever.
| Input | Normalized | SHA-256 |
|---|---|---|
Alice@Example.COM |
alice@example.com |
ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976 |
BOB@Example.org |
bob@example.org |
686b5e4cf4f963adf8f51468a48028ef8d15bd02fa335f821279a3d1678c9615 |
user@spamdomain.test |
user@spamdomain.test |
9ffe7ae351b8296f3f1e3c9ba373d5b6c334f2d350ca627eb20dc9f19ba07f1d |
value_hash vectors| Kind | Canonical value | value_hash | hash_prefix |
|---|---|---|---|
ip |
203.0.113.5 |
6e3d6eef07ecd39fdcefc089a6dae37cb19c2608de0c0d1d4fa9ba0395592f08 |
6e3d6 |
ip_range |
203.0.113.0/24 |
f1eda52f517f9c86e3578e9ed463211cd505cdc15cc6f821510285b1c7c40258 |
f1eda |
email_domain |
spamdomain.test |
8ecfebd6d364ae359344433ce4ab288483df700661876fe314b3b52aa4351015 |
8ecfe |
email_hash |
ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976 |
b75fc24864e70c6d31a24590a804f761748d95327232ddf264e5f50f066fc43e |
ff8d9 |
For email_hash the hash_prefix comes from the e-mail
digest itself (ff8d9), not from the
value_hash. That is what makes the range endpoint work: your
client only ever needs the e-mail digest.
Liveness probe. Performs no database write and returns immediately. Suitable as an uptime-monitor target.
curl -si https://black.majevski.com/v1/health | head -n 12
<?php
$body = @file_get_contents('https://black.majevski.com/v1/health');
$ok = is_string($body)
&& 'ok' === (json_decode($body, true)['status'] ?? null);
var_dump($ok);
const res = await fetch('https://black.majevski.com/v1/health');
const data = await res.json();
console.log(data.status === 'ok', 'API version', data.version);
{ "status": "ok", "time": "2026-07-28T09:14:02Z", "version": "1" }
Aggregate statistics for the whole data set: totals per kind and per status, plus how many entries were confirmed in the last 24 hours and 7 days. Intended for dashboards and status pages.
The figures are recomputed at most once every 120 seconds and served from cache in between, so the response carries:
Cache-Control: public, max-age=120
Polling faster returns the same body and buys nothing; a newly confirmed entry can take up to two minutes to show up in the totals. Honour the header in your client and in any CDN in front of it, and never reconcile against these numbers.
curl -s https://black.majevski.com/v1/stats
<?php
$stats = json_decode(
(string) @file_get_contents('https://black.majevski.com/v1/stats'),
true
);
printf(
"%d confirmed IPs, %d entries confirmed in the last 24h\n",
$stats['totals']['by_kind']['ip'] ?? 0,
$stats['confirmed_24h'] ?? 0
);
const stats = await (await fetch('https://black.majevski.com/v1/stats')).json();
console.log(`${stats.totals.entries} entries, ` +
`${stats.confirmed_24h} confirmed in the last 24h`);
{
"totals": {
"entries": 184203,
"reports": 942118,
"by_kind": { "ip": 151022, "ip_range": 812, "email_hash": 29944, "email_domain": 2425 },
"by_status": { "pending": 22140, "confirmed": 148902, "expired": 12044,
"removed": 604, "allowlisted": 513 }
},
"confirmed_24h": 1482,
"confirmed_7d": 9633,
"sites": { "total": 412, "verified": 388 },
"server_time": "2026-07-28T09:14:02Z"
}
Answers whether a single subject is currently listed. Only confirmed
entries count as listed; a pending, expired,
removed or allowlisted entry always yields
"listed": false. A subject that is not listed is still an HTTP
200, not a 404.
| Name | Required | Description |
|---|---|---|
type | Yes | One of ip, email, email_hash,
domain. domain searches the
email_domain kind. |
value | Yes | The subject. URL-encode it. Max 320 characters. |
For type=ip the lookup matches an exact ip entry
or any ip_range entry that contains the address.
When the match came from a range, the CIDR is returned in
result.matched; on an exact match matched is
null.
The address is hashed in memory and discarded immediately. The response echoes
only query.value_hash — the plaintext never appears in the
response, in the access log or in the database. Even so, prefer
/v1/range/{prefix}: it means the address
never leaves your server at all.
# IP
curl -s "https://black.majevski.com/v1/check?type=ip&value=203.0.113.5"
# E-mail domain
curl -s "https://black.majevski.com/v1/check?type=domain&value=spamdomain.test"
# Pre-hashed e-mail — nothing personal on the wire
curl -s "https://black.majevski.com/v1/check?type=email_hash\
&value=ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976"
<?php
declare(strict_types=1);
/**
* @param string $type ip|email|email_hash|domain
* @return array{listed:bool,result:?array}
*/
function mbl_check(string $type, string $value): array
{
$url = 'https://black.majevski.com/v1/check?'
. http_build_query(['type' => $type, 'value' => $value]);
$ctx = stream_context_create(['http' => [
'timeout' => 3,
'ignore_errors' => true,
'header' => "Accept: application/json\r\n",
]]);
$body = @file_get_contents($url, false, $ctx);
$data = false === $body ? null : json_decode($body, true);
if (!is_array($data) || !isset($data['listed'])) {
return ['listed' => false, 'result' => null]; // fail open
}
return ['listed' => (bool) $data['listed'], 'result' => $data['result'] ?? null];
}
$hit = mbl_check('ip', '203.0.113.5');
if ($hit['listed'] && ($hit['result']['score'] ?? 0) >= 70.0) {
// High confidence: block outright.
} elseif ($hit['listed']) {
// Lower confidence: challenge instead of block.
}
const BASE = 'https://black.majevski.com';
async function mblCheck(type, value) {
const url = `${BASE}/v1/check?type=${encodeURIComponent(type)}` +
`&value=${encodeURIComponent(value)}`;
try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return { listed: false, result: null };
return await res.json();
} catch {
return { listed: false, result: null }; // fail open
}
}
const hit = await mblCheck('domain', 'spamdomain.test');
if (hit.listed) console.log(hit.result.categories.join(', '), hit.result.score);
{
"query": { "type": "ip", "value": "203.0.113.5" },
"listed": true,
"result": {
"kind": "ip",
"status": "confirmed",
"categories": ["bruteforce"],
"score": 72.5,
"report_count": 14,
"distinct_sites": 3,
"first_seen": "2026-05-02T18:21:44Z",
"last_seen": "2026-07-27T23:06:10Z",
"matched": "203.0.113.0/24"
}
}
{
"query": { "type": "ip", "value": "198.51.100.9" },
"listed": false,
"result": null
}
type=email{
"query": {
"type": "email",
"value_hash": "ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976"
},
"listed": true,
"result": {
"kind": "email_hash",
"status": "confirmed",
"categories": ["fake_account", "spam"],
"score": 61.0,
"report_count": 7,
"distinct_sites": 4,
"first_seen": "2026-06-11T07:55:02Z",
"last_seen": "2026-07-26T12:40:31Z",
"matched": null
}
}
Note the absence of query.value: the address you sent is not echoed
back, by design.
Batch form. All values in one request share a single type. The
results array preserves input order and always has exactly the same
length as values, so you can zip them by index.
limits.batch_max); a longer batch returns
413 payload_too_large, not a validation error.413 payload_too_large.curl -s -X POST https://black.majevski.com/v1/check \
-H "Content-Type: application/json" \
-d '{
"type": "ip",
"values": ["203.0.113.5", "198.51.100.9", "2001:db8::1"]
}'
<?php
declare(strict_types=1);
/**
* @param list<string> $values Max 100 items.
* @return array<string,bool> value => listed
*/
function mbl_check_many(string $type, array $values): array
{
$payload = json_encode(
['type' => $type, 'values' => array_values($values)],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
$ch = curl_init('https://black.majevski.com/v1/check');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$out = array_fill_keys($values, false); // fail open
if (200 !== $status || !is_string($body)) {
return $out;
}
$data = json_decode($body, true);
foreach ($data['results'] ?? [] as $i => $row) {
if (isset($values[$i])) {
$out[$values[$i]] = true === ($row['listed'] ?? false);
}
}
return $out;
}
print_r(mbl_check_many('ip', ['203.0.113.5', '198.51.100.9']));
async function mblCheckMany(type, values) {
const res = await fetch('https://black.majevski.com/v1/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ type, values }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return values.map(() => false); // fail open
const { results } = await res.json();
// results is index-aligned with `values`.
return results.map((r) => r.listed === true);
}
console.log(await mblCheckMany('ip', ['203.0.113.5', '198.51.100.9']));
{
"results": [
{
"query": { "type": "ip", "value": "203.0.113.5" },
"listed": true,
"result": {
"kind": "ip", "status": "confirmed", "categories": ["bruteforce"],
"score": 72.5, "report_count": 14, "distinct_sites": 3,
"first_seen": "2026-05-02T18:21:44Z", "last_seen": "2026-07-27T23:06:10Z",
"matched": "203.0.113.0/24"
}
},
{ "query": { "type": "ip", "value": "198.51.100.9" }, "listed": false, "result": null },
{ "query": { "type": "ip", "value": "2001:db8::1" }, "listed": false, "result": null }
]
}
Privacy-preserving e-mail lookup. You send the first 5 hex characters of the SHA-256 digest of the normalized address; the service returns every listed digest that starts with those characters, and you finish the comparison locally. See the worked example for the full flow.
| Name | In | Description |
|---|---|---|
prefix | path | Exactly 5 lowercase hex characters (^[0-9a-f]{5}$).
Anything else returns 422 validation_failed. |
type | query | Optional, defaults to email. Reserved for future
subject types. |
This endpoint neither accepts nor returns a plaintext e-mail address. Send the
prefix only — not the full digest, and certainly not the address. An unknown
prefix returns 200 with an empty suffixes array, never
a 404, so the response does not confirm or deny anything about a
particular address on its own.
# sha256("alice@example.com") starts with ff8d9
curl -s "https://black.majevski.com/v1/range/ff8d9?type=email"
<?php
declare(strict_types=1);
/**
* True when the address is listed. The address never leaves this process:
* only 5 hex characters of its digest are sent. Fails open.
*/
function mbl_email_listed(string $email): bool
{
$hash = hash('sha256', strtolower(trim($email)));
$prefix = substr($hash, 0, 5);
$suffix = substr($hash, 5);
$ctx = stream_context_create(['http' => [
'timeout' => 3,
'ignore_errors' => true,
'header' => "Accept: application/json\r\n",
]]);
$body = @file_get_contents(
'https://black.majevski.com/v1/range/' . $prefix . '?type=email',
false,
$ctx
);
if (false === $body) {
return false; // fail open
}
$data = json_decode($body, true);
if (!is_array($data) || !is_array($data['suffixes'] ?? null)) {
return false;
}
foreach ($data['suffixes'] as $row) {
$candidate = (string) ($row['suffix'] ?? '');
// Constant-time compare: no timing signal about the address.
if (strlen($candidate) === strlen($suffix) && hash_equals($candidate, $suffix)) {
return true;
}
}
return false;
}
var_dump(mbl_email_listed(' Alice@Example.COM ')); // bool(true)
const BASE = 'https://black.majevski.com';
async function mblEmailListed(email) {
// 1. Normalize and hash locally.
const normalized = email.trim().toLowerCase();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(normalized)
);
const hash = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// 2. Send only the first 5 characters.
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
try {
const res = await fetch(`${BASE}/v1/range/${prefix}?type=email`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return false; // fail open
// 3. Compare locally.
const { suffixes } = await res.json();
return suffixes.some((row) => row.suffix === suffix);
} catch {
return false; // fail open
}
}
console.log(await mblEmailListed(' Alice@Example.COM ')); // true
{
"prefix": "ff8d9",
"type": "email",
"count": 3,
"suffixes": [
{
"suffix": "819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976",
"status": "confirmed",
"categories": ["fake_account", "spam"],
"score": 61.0,
"last_seen": "2026-07-26T12:40:31Z"
},
{
"suffix": "cc05ccd3815abaddae9971aeeb904fd1fe0402a9747fc00854374a706d0",
"status": "confirmed",
"categories": ["spam"],
"score": 44.25,
"last_seen": "2026-07-19T04:02:57Z"
},
{
"suffix": "d1010a46efd74417334df7f0dfc8d84bfaba8907bd33a0fdd2ef3cf4672",
"status": "confirmed",
"categories": ["abuse"],
"score": 55.75,
"last_seen": "2026-07-24T16:33:08Z"
}
]
}
Each suffix is exactly 59 hex characters. Concatenating
prefix + suffix reproduces the full 64-character digest.
Cursor-paginated stream of confirmed entries ordered by
(updated_at, id). This is how you build a local mirror and answer every
lookup with zero network latency. See mirroring the feed for the
recommended sync loop.
| Name | Default | Description |
|---|---|---|
since | — | ISO 8601 UTC timestamp. Only entries with
updated_at >= since are returned. Send the
server_time of your last successful sync. |
kind | all | ip, ip_range, email_hash or
email_domain. |
limit | 500 |
Page size, clamped to 1000
(limits.feed_max). |
cursor | — | Opaque cursor from the previous page's next_cursor.
Treat it as a black box; do not parse or construct it. |
Send since on the first request of a sync run. For every
following page send only cursor, keeping kind and
limit unchanged. Stop when next_cursor is
null, then persist the server_time from that last
page as the since for your next run.
# First page of everything confirmed or updated since yesterday
curl -s "https://black.majevski.com/v1/feed?since=2026-07-27T00:00:00Z&limit=500"
# Next page — cursor only
curl -s "https://black.majevski.com/v1/feed?limit=500\
&cursor=MjAyNi0wNy0yOCAwMjowMDowMHw5OTQxMg%3D%3D"
# IP-only feed
curl -s "https://black.majevski.com/v1/feed?kind=ip&limit=1000"
<?php
declare(strict_types=1);
/**
* Pulls every page since $since and hands each batch to $sink.
*
* @param callable(list<array>):void $sink
* @return string The server_time to persist as the next $since.
*/
function mbl_sync(?string $since, callable $sink, ?string $kind = null): string
{
$cursor = null;
$serverTime = $since ?? gmdate('Y-m-d\TH:i:s\Z');
$pages = 0;
do {
$query = ['limit' => 1000];
if (null !== $kind) { $query['kind'] = $kind; }
if (null !== $cursor) { $query['cursor'] = $cursor; }
elseif (null !== $since) { $query['since'] = $since; }
$url = 'https://black.majevski.com/v1/feed?' . http_build_query($query);
$ctx = stream_context_create(['http' => [
'timeout' => 15,
'ignore_errors' => true,
'header' => "Accept: application/json\r\n",
]]);
$body = @file_get_contents($url, false, $ctx);
if (false === $body) {
break; // keep the old cursor, retry later
}
$page = json_decode($body, true);
if (!is_array($page) || !isset($page['items'])) {
break;
}
$sink($page['items']);
$serverTime = (string) ($page['server_time'] ?? $serverTime);
$cursor = $page['next_cursor'] ?? null;
} while (null !== $cursor && ++$pages < 200); // hard stop, never loop forever
return $serverTime;
}
$next = mbl_sync('2026-07-27T00:00:00Z', static function (array $items): void {
foreach ($items as $item) {
// UPSERT into your local table, keyed by kind + value.
// For email_hash the value IS the sha256 digest — safe to store.
}
});
// Persist $next and pass it as `since` on the following run.
const BASE = 'https://black.majevski.com';
/**
* Async generator over every confirmed entry updated since `since`.
*/
async function* mblFeed({ since = null, kind = null, limit = 1000 } = {}) {
let cursor = null;
let pages = 0;
while (pages++ < 200) { // hard stop
const q = new URLSearchParams({ limit: String(limit) });
if (kind) q.set('kind', kind);
if (cursor) q.set('cursor', cursor);
else if (since) q.set('since', since);
const res = await fetch(`${BASE}/v1/feed?${q}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return;
const page = await res.json();
for (const item of page.items) yield item;
if (!page.next_cursor) {
// Persist page.server_time as the next `since`.
return page.server_time;
}
cursor = page.next_cursor;
}
}
for await (const entry of mblFeed({ since: '2026-07-27T00:00:00Z', kind: 'ip' })) {
// upsert(entry.kind, entry.value, entry.categories, entry.score);
}
{
"items": [
{
"kind": "ip",
"value": "203.0.113.5",
"categories": ["bruteforce"],
"score": 72.5,
"status": "confirmed",
"first_seen": "2026-05-02T18:21:44Z",
"last_seen": "2026-07-27T23:06:10Z",
"updated_at": "2026-07-27T23:06:10Z"
},
{
"kind": "ip_range",
"value": "203.0.113.0/24",
"categories": ["bruteforce", "abuse"],
"score": 80.0,
"status": "confirmed",
"first_seen": "2026-04-18T10:02:00Z",
"last_seen": "2026-07-28T01:11:09Z",
"updated_at": "2026-07-28T01:11:09Z"
},
{
"kind": "email_hash",
"value": "ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976",
"categories": ["fake_account", "spam"],
"score": 61.0,
"status": "confirmed",
"first_seen": "2026-06-11T07:55:02Z",
"last_seen": "2026-07-26T12:40:31Z",
"updated_at": "2026-07-28T02:00:00Z"
}
],
"next_cursor": "MjAyNi0wNy0yOCAwMjowMDowMHw5OTQxMg==",
"server_time": "2026-07-28T09:14:02Z"
}
Submit abuse observations. Requires an API key with the write scope on
a verified site. Up to 100 reports per call
(limits.batch_max); a longer batch, or a body over 256 KB, is
rejected with 413 payload_too_large — exactly as on
POST /v1/check.
| Field | Required | Description |
|---|---|---|
reports[].kind | Yes | ip, ip_range, email,
email_hash or email_domain.
email is hashed server-side and stored as
email_hash. |
reports[].value | Yes | The subject, in any form the normalization rules accept. |
reports[].category | Yes | bruteforce, spam,
fake_account or abuse. |
reports[].evidence | No | Free-form JSON object stored verbatim for moderators. Keep it under a few hundred bytes. |
reports[].observed_at | No | ISO 8601 UTC. Defaults to time of receipt; future timestamps are clamped to now. |
evidence is retained and read by human moderators. Do not include
plaintext e-mail addresses, passwords, session tokens, full request bodies,
cookies or anything else identifying an individual. Counts, timestamps, target
paths and rule names are the right level of detail.
(entry, site, category, UTC day). Reporting the same subject twice
on the same day from the same site does not inflate counters; the result is
still accepted but flagged "duplicate": true.pending and is promoted to
confirmed per the corroboration rules.200 even when individual reports are rejected —
always inspect each element of results.curl -s -X POST https://black.majevski.com/v1/report \
-H "Authorization: Bearer $MBL_KEY" \
-H "Content-Type: application/json" \
-d '{
"reports": [
{
"kind": "ip",
"value": "203.0.113.5",
"category": "bruteforce",
"evidence": { "attempts": 412, "window": 600, "target": "/wp-login.php" },
"observed_at": "2026-07-28T08:59:00Z"
},
{ "kind": "ip_range", "value": "203.0.113.0/24", "category": "abuse" },
{ "kind": "email", "value": "alice@example.com", "category": "fake_account" },
{ "kind": "email_domain", "value": "spamdomain.test", "category": "spam" }
]
}'
<?php
declare(strict_types=1);
/**
* @param list<array{kind:string,value:string,category:string,evidence?:array}> $reports
* @return array{accepted:int,rejected:int,results:list<array>}
*/
function mbl_report(array $reports): array
{
$payload = json_encode(
['reports' => array_slice(array_values($reports), 0, 100)],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
$ch = curl_init('https://black.majevski.com/v1/report');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . (getenv('MBL_KEY') ?: ''),
'Content-Type: application/json',
'Accept: application/json',
],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (200 !== $status) {
// 429 -> back off and retry later; 401/403 -> fix the key, do not retry.
return ['accepted' => 0, 'rejected' => 0, 'results' => []];
}
$data = json_decode((string) $body, true);
return is_array($data)
? $data
: ['accepted' => 0, 'rejected' => 0, 'results' => []];
}
$out = mbl_report([
[
'kind' => 'ip',
'value' => '203.0.113.5',
'category' => 'bruteforce',
'evidence' => ['attempts' => 412, 'window' => 600],
],
// Hash locally so the address never crosses the network at all.
[
'kind' => 'email_hash',
'value' => hash('sha256', strtolower(trim('alice@example.com'))),
'category' => 'fake_account',
],
]);
foreach ($out['results'] as $row) {
if ('rejected' === ($row['status'] ?? '')) {
error_log('mbl report rejected: ' . ($row['reason'] ?? 'unknown'));
}
}
// Server-side only — the key must never reach a browser.
async function mblReport(reports) {
const res = await fetch('https://black.majevski.com/v1/report', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MBL_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ reports: reports.slice(0, 100) }),
signal: AbortSignal.timeout(10000),
});
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') || 60);
throw Object.assign(new Error('rate_limited'), { retryAfter: wait });
}
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const out = await res.json();
for (const row of out.results) {
if (row.status === 'rejected') console.warn('rejected:', row.index, row.reason);
}
return out;
}
await mblReport([
{ kind: 'ip', value: '203.0.113.5', category: 'bruteforce',
evidence: { attempts: 412, window: 600 } },
{ kind: 'email_domain', value: 'spamdomain.test', category: 'spam' },
]);
{
"accepted": 3,
"rejected": 1,
"results": [
{ "index": 0, "status": "accepted", "entry_status": "confirmed", "entry_kind": "ip",
"value_hash": "6e3d6eef07ecd39fdcefc089a6dae37cb19c2608de0c0d1d4fa9ba0395592f08" },
{ "index": 1, "status": "rejected", "reason": "protected_range" },
{ "index": 2, "status": "accepted", "entry_status": "pending", "entry_kind": "email_hash",
"value_hash": "b75fc24864e70c6d31a24590a804f761748d95327232ddf264e5f50f066fc43e" },
{ "index": 3, "status": "accepted", "entry_status": "pending", "entry_kind": "ip",
"duplicate": true, "reason": "duplicate",
"value_hash": "8ecfebd6d364ae359344433ce4ab288483df700661876fe314b3b52aa4351015" }
]
}
| Reason | Meaning | What to do |
|---|---|---|
protected_range |
The target is private, reserved or published infrastructure — or
an email_domain belonging to a major mail provider
(gmail.com, outlook.com, … and their
subdomains), which is never blacklisted whole. |
Filter locally before reporting; for provider domains, report the
individual email_hash instead. Repeated hits reduce
your trust score sharply. |
invalid_value |
The value could not be normalized. | Validate before sending; check your CIDR and IP parsing. |
invalid_kind |
Unknown kind. |
Use one of the five documented kinds. |
invalid_category |
Unknown category. |
Use one of the documented categories — note it is
bruteforce, not brute_force. |
duplicate |
Already reported by your site today for this category. | Nothing — the report is still counted as accepted. Deduplicate locally to save quota. |
self_report |
The value resolves to your own domain or server IP. | A site cannot blacklist itself. Check what your firewall is feeding the reporter. |
Everything the service knows about the calling key and its site: verification status, trust score, key scopes and usage counters, and the rate limits currently in force. Useful as a connection test from a settings screen.
curl -s https://black.majevski.com/v1/account \
-H "X-API-Key: $MBL_KEY"
<?php
declare(strict_types=1);
/** Returns a human-readable connection-test result. */
function mbl_test_connection(string $key): string
{
$ch = curl_init('https://black.majevski.com/v1/account');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['X-API-Key: ' . $key, 'Accept: application/json'],
]);
$body = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($body, true);
if (200 !== $status) {
return 'Failed: ' . (string) ($data['error']['code'] ?? 'network_error');
}
// scopes is a map: {"read": true, "write": false}
$scopes = array_keys(array_filter((array) $data['key']['scopes']));
return sprintf(
'Connected as %s (%s), trust %.1f, scopes: %s',
$data['site']['domain'],
$data['site']['status'],
(float) $data['site']['trust_score'],
implode(', ', $scopes)
);
}
echo mbl_test_connection(getenv('MBL_KEY') ?: ''), PHP_EOL;
const res = await fetch('https://black.majevski.com/v1/account', {
headers: { 'X-API-Key': process.env.MBL_KEY, Accept: 'application/json' },
});
if (res.ok) {
const a = await res.json();
console.log(`${a.site.domain} — ${a.site.status}, trust ${a.site.trust_score}`);
console.log(`${a.rate.remaining}/${a.rate.limit} requests left this minute`);
} else {
const { error } = await res.json();
console.error(error.code, error.message);
}
{
"site": {
"domain": "example.com",
"status": "verified",
"trust_score": 78.5,
"reports_total": 4120,
"reports_rejected": 3,
"verified_at": "2026-03-04T11:02:19Z"
},
"key": {
"prefix": "a1b2c3d4e5f6",
"name": "production",
"scopes": { "read": true, "write": true },
"status": "active",
"request_count": 104329,
"last_used_at": "2026-07-28T09:13:58Z",
"expires_at": null
},
"limits": { "keyed_per_min": 600, "write_per_min": 120, "batch_max": 100, "feed_max": 1000 },
"usage": { "reports_today": 41, "rate": { "limit": 600, "remaining": 598, "reset": 1785315300 } },
"rate": { "limit": 600, "remaining": 598, "reset": 1785315300 },
"server_time": "2026-07-28T09:14:02Z"
}
rate and usage.rate carry the same window state —
both shapes are served so clients built against either keep working.
Opens a delisting appeal for manual review. No key required, but limited to
5 appeals per IP per day (policy.appeal_rate_per_day).
Appeals are resolved by a human; there is no polling endpoint, so supply a contact
address if you want the outcome.
| Field | Required | Description |
|---|---|---|
kind | Yes | ip, ip_range, email,
email_hash or email_domain. |
value | Yes | The listed subject. Prefer email_hash over
email so no address leaves your system. |
reason | Yes | 10–2000 characters of plain text. Explain what changed — “the compromised account was rebuilt”, “this is a shared NAT gateway”. HTML is stripped. |
email | No | Your contact address, stored only so the outcome can be communicated. |
curl -s -X POST https://black.majevski.com/v1/appeal \
-H "Content-Type: application/json" \
-d '{
"kind": "ip",
"value": "203.0.113.5",
"email": "admin@example.com",
"reason": "This address is a shared NAT gateway for a university campus. The abuse came from a single compromised workstation which has been rebuilt."
}'
<?php
declare(strict_types=1);
// Appeal an e-mail listing without ever transmitting the address.
$payload = json_encode([
'kind' => 'email_hash',
'value' => hash('sha256', strtolower(trim('alice@example.com'))),
'email' => 'admin@example.com',
'reason' => 'The account was compromised and has since been secured.',
], JSON_THROW_ON_ERROR);
$ch = curl_init('https://black.majevski.com/v1/appeal');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$body = (string) curl_exec($ch);
curl_close($ch);
$out = json_decode($body, true);
echo 'Appeal #', $out['appeal_id'] ?? 0, ' is ', $out['status'] ?? 'unknown', PHP_EOL;
const res = await fetch('https://black.majevski.com/v1/appeal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'ip_range',
value: '203.0.113.0/24',
email: 'admin@example.com',
reason: 'This range was reassigned to us in June and the previous tenant is gone.',
}),
});
if (res.status === 429) {
console.warn('Daily appeal limit reached; try again tomorrow.');
} else {
console.log(await res.json());
}
{
"accepted": true,
"appeal_id": 4711,
"status": "received",
"message": "Your appeal has been recorded and will be reviewed by a human. If the request is granted, the change propagates within 24 hours.",
"quota": { "per_day": 5, "remaining": 4, "reset": 1786752000 }
}
The endpoint answers 202 Accepted. email is required
— the decision is delivered to it. reason is optional but an appeal without
one is unlikely to be granted.
One anonymised plugin-usage snapshot per install — at most one per 15 minutes;
the response's next_interval suggests a daily cadence. With an API
key the snapshot links to the key's site and the payload's domain
is ignored (the verified site domain is authoritative); without a key the
install is anonymous and may claim a domain, shown as unverified. Unknown
settings, counters and fields are silently dropped — an older server never
fails a newer plugin.
install_id is 32 hex characters, generated once at plugin
activation and stored permanently — never derived from the domain or any
personal data. Counters are lifetime totals (the same numbers
the plugin shows its own administrator); the server derives daily activity
from consecutive snapshots, so never reset them client-side. The accepted
vocabulary is defined in the OpenAPI document
(TelemetrySettings, TelemetryStats).
{
"accepted": true,
"linked": false,
"next_interval": 86400,
"server_time": "2026-08-14T09:47:36Z"
}
Resubmitting sooner than 15 minutes after the previous snapshot answers
429 with Retry-After; an invalid key answers
401 — it is never treated as anonymous.
Backs the plugin's feedback form: bug reports, feedback and feature requests,
with or without an API key. Limited to 5 submissions per IP per
day. message (10–5000 characters) is required;
type is bug, feedback or
feature; email is optional and stored only for
follow-up. An optional diagnostics object is reduced server-side
to the whitelisted telemetry vocabulary — free-form diagnostic data is never
stored.
{
"accepted": true,
"feedback_id": 4711,
"message": "Thank you — the report has been received and will be reviewed."
}
Runs scheduled maintenance: expiring entries past their TTL, deleting long-dead
entries with their reports, recomputing entry scores and site trust, and pruning
the rate-limit and audit tables. POST is accepted too, for schedulers
that only issue POST.
This exists because plenty of shared hosting cannot run a PHP CLI script on a
schedule — some plans offer nothing but a URL fetcher. It is the HTTPS equivalent of
php app/bin/cron.php; both execute the same code. Call it
hourly.
This endpoint does not use an API key. It is guarded by its own
secret, security.cron_key in the server configuration, which must be at
least 32 characters. Supply it in one of three ways:
| Form | When to use |
|---|---|
X-MBL-Cron-Key: <key> | Preferred. Any scheduler that can send a header. |
Authorization: Bearer <key> | Equivalent alternative. |
?key=<key> | Only for services that can do nothing but fetch a plain URL. Query strings are written to server access logs — rotate the key if you share them. |
When no key is configured the endpoint is disabled and returns 401 to
everyone — identical to a wrong key, so it cannot be probed to discover whether a
key exists. Failed attempts are throttled to 10 per minute per client.
curl -fsS -H "X-MBL-Cron-Key: $CRON_KEY" \
https://black.majevski.com/v1/cron/run
URL-only scheduler (cron-job.org, UptimeRobot, …):
https://black.majevski.com/v1/cron/run?key=YOUR_CRON_KEY
{
"status": "ok",
"reason": null,
"completed": true,
"duration_ms": 412,
"stats": {
"expired": 18,
"entries_purged": 4,
"reports_purged": 9,
"scores_scanned": 1204,
"scores_updated": 87,
"sites_refreshed": 12,
"rate_limits": 340,
"audit_rows": 0
},
"last_run": "2026-07-30T09:00:04Z",
"server_time": "2026-07-30T10:00:03Z"
}
completed can be false
A run is bounded by the host's max_execution_time less a five-second
margin. If the budget runs out the response is still 200 but
completed is false. Every task is independently resumable,
so the next hourly call simply continues; nothing is lost and no action is needed.
On a 30-second shared host a large backlog is worked through over several passes.
| Code | Body | Meaning |
|---|---|---|
200 | status: ok | Work ran. Check completed. |
202 | reason: too_soon | Called again inside policy.maintenance_min_interval (default 300s). Harmless. |
202 | reason: already_running | Another trigger holds the lock. Harmless. |
401 | unauthorized | Key missing, too short, or wrong — or the endpoint is disabled. |
405 | bad_request | Use GET or POST. |
429 | rate_limited | Too many failed attempts from this client. |
Overlapping runs are prevented by a database advisory lock, so configuring both the HTTPS trigger and a CLI cron job is safe. Admin → Settings → Scheduled maintenance shows the last completed run and warns when it is overdue.
Checking an e-mail address against a remote blocklist normally means sending the address — or its full hash, which is just as identifying for a known address — to a third party. M Blacklist avoids that entirely.
You hash the address locally, send only the first 5 hex characters of the digest, and receive every listed digest sharing that bucket. The final comparison happens on your machine. A 5-character prefix covers 165 = 1 048 576 buckets, so the request tells the server nothing beyond “one of roughly a millionth of the address space”, and the answer is byte-for-byte identical for every address in that bucket.
YOUR SERVER black.majevski.com
─────────── ──────────────────
" Alice@Example.COM "
│
│ 1. trim + lowercase
▼
"alice@example.com"
│
│ 2. sha256
▼
ff8d9 819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976
└─┬─┘ └──────────────────────────┬──────────────────────────────┘
│ │
│ 3. send prefix only │ never leaves your server
▼ │
GET /v1/range/ff8d9 ─────────────┼──────────────► bucket lookup
│ │
4. receive every suffix in │ │
the bucket ◄─────────────────┼────────────────────────┘
│ │
│ 5. compare locally │
▼ ▼
suffix in response? ────────► LISTED / NOT LISTED
The raw input " Alice@Example.COM " becomes
alice@example.com after trim() and
strtolower(). Nothing else is changed — no plus-address
stripping, no dot removal.
sha256("alice@example.com") is:
ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976
The first 5 characters (ff8d9) are the prefix; the remaining 59 are the suffix.
curl -s "https://black.majevski.com/v1/range/ff8d9?type=email"
Only ff8d9 was transmitted. The server cannot tell whether you
asked about alice@example.com or any of the roughly
2236 other strings that hash into the same bucket.
{
"prefix": "ff8d9",
"type": "email",
"count": 3,
"suffixes": [
{ "suffix": "819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976",
"status": "confirmed", "categories": ["fake_account","spam"],
"score": 61.0, "last_seen": "2026-07-26T12:40:31Z" },
{ "suffix": "cc05ccd3815abaddae9971aeeb904fd1fe0402a9747fc00854374a706d0",
"status": "confirmed", "categories": ["spam"],
"score": 44.25, "last_seen": "2026-07-19T04:02:57Z" },
{ "suffix": "d1010a46efd74417334df7f0dfc8d84bfaba8907bd33a0fdd2ef3cf4672",
"status": "confirmed", "categories": ["abuse"],
"score": 55.75, "last_seen": "2026-07-24T16:33:08Z" }
]
}
Your suffix is
819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976.
It is the first entry in the response, so
alice@example.com is listed, with categories
fake_account and spam and a score of
61.0. Had it not appeared, the answer would be “not listed”,
and the other two suffixes tell you nothing about which addresses they
belong to — they are one-way digests.
Use a constant-time comparison (hash_equals() in PHP) so your
own process leaks no timing signal about which address was checked.
email_hash values
directly.422.For anything on a request-handling hot path — a firewall, a login form, a registration check — do not call the API synchronously. Mirror the feed on a schedule and answer from your own database. That gives you O(1) lookups, no third-party latency, and correct behaviour when the network is down.
Keep the server_time from your last completed sync. Pass it as
since on the next run's first request, then follow
next_cursor until it is null. Only update your
stored timestamp after a run completes — a partial run must be safe to
repeat.
(kind, value)Entries reappear in the feed whenever they are updated, so your writer must be idempotent. Index IP ranges as binary start/end pairs so a lookup is a single indexed range query, and remember that IPv4 and IPv6 values must be compared within the same address family.
The feed only contains confirmed entries; an entry that is
delisted, removed or expired simply stops appearing. Prune local rows whose
last_seen is older than the 90-day retention window so your
mirror never outlives the source.
Hourly to every six hours is right for almost everyone. Add jitter so a
fleet of clients does not stampede on the hour. Back off on
429 and 5xx, and never retry in a tight loop.
Cap the number of pages per run, cap your local table size, and cap the report queue you push back. A sync job that can grow without limit is an outage waiting for a busy week.
See the PHP and JavaScript sync loops in the
/v1/feed section for complete, bounded
implementations.
Limits are enforced in a fixed 60-second window. Anonymous reads are counted per client IP; authenticated requests are counted per API key.
For anonymous traffic, IPv4 is counted on the exact address but IPv6 is counted on the /64 prefix: a single subscriber is routinely handed an entire /64, so keying on the full /128 would let one host walk through billions of addresses and ignore every public limit. If your users share a /64 they share a bucket — use an API key, which is counted per key.
| Traffic | Counted per | Default limit |
|---|---|---|
Anonymous reads — /v1/health, /v1/stats,
/v1/check, /v1/range, /v1/feed |
Client IP | 60 / minute |
| Authenticated reads (valid key present) | API key | 600 / minute |
Writes — POST /v1/report |
API key | 120 / minute |
Appeals — POST /v1/appeal |
Client IP | 5 / day |
Every response carries the state of your current window:
HTTP/1.1 200 OK
X-MBL-Version: 1
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1785315300
X-RateLimit-Reset is a Unix timestamp. When the budget is exhausted the
service answers 429 and adds Retry-After in seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 27
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785315300
{"error":{"code":"rate_limited","message":"Rate limit exceeded. Retry after 27 seconds."}}
POST /v1/check with 100 values is one request, not 100.Retry-After. Add exponential backoff with jitter on
429 and 5xx; never retry a 4xx other
than 429.Every non-2xx response uses one envelope:
{
"error": {
"code": "validation_failed",
"message": "Parameter \"type\" must be one of: ip, email, email_hash, domain."
}
}
Branch on error.code, which is stable. error.message is
human-readable, may change wording, and never contains internal detail such as SQL,
stack traces or file paths.
| Code | HTTP | Meaning & fix |
|---|---|---|
bad_request | 400 | Body is not valid JSON, Content-Type is wrong on a
POST, or a required parameter is missing. Not
retryable without a change. |
unauthorized | 401 | No key where one is required, or the key is malformed, unknown, revoked or expired. Note that sending an invalid key to a public endpoint is also a 401 — omit the header entirely for anonymous access. |
forbidden_scope | 403 | The key is valid but lacks the required scope. Issue a key with
write. |
site_unverified | 403 | The site has not completed domain verification, or has been suspended. Complete verification before reporting. |
not_found | 404 | No route matches the path. Check the /v1 prefix and
spelling. A subject that is simply not listed is a
200, not a 404. |
validation_failed | 422 | The request was well formed but a field failed validation: unknown
kind or category, unparseable IP or CIDR,
a digest that is not 64 lowercase hex, a prefix that is not 5
lowercase hex. |
payload_too_large | 413 | Body over 256 KB, or a batch over limits.batch_max on
POST /v1/check or POST /v1/report —
both answer 413, neither 422. Split into smaller batches. |
rate_limited | 429 | Budget exhausted for the current window. Wait
Retry-After seconds, then retry with backoff. |
server_error | 500 | Unexpected server-side condition. Retry with exponential backoff; if it persists, treat the service as down and fail open. |
| Status | Retry? | Action |
|---|---|---|
| 2xx | — | Use the body. |
| 400, 413, 422 | No | Log it and fix the request. Retrying will not help. |
| 401, 403 | No | Surface it to an administrator. Do not retry in a loop — you will only burn quota. |
| 429 | Yes | Sleep for Retry-After, then retry with jitter. |
| 5xx, timeout, DNS failure | Yes | Exponential backoff, capped attempts. Meanwhile fail open. |
M Blacklist is a security service, which makes it exactly the kind of system that must be careful with personal data rather than exempt from caring. The design below is the guarantee; it is enforced in code, not by policy alone.
sha256(strtolower(trim(address)))
— plus, when the operator has configured an encryption key, an AES-256-GCM
ciphertext for the admin panel's moderation screen. That key lives in the
server configuration file, never in the database, so neither column can
yield an address from a database dump alone. No column anywhere in the
schema can hold a plaintext address.type=email or kind=email, the address
is hashed in memory during request handling and discarded. It is not written to
the database, not written to an application log, and not echoed in the
response — /v1/check returns query.value_hash and
deliberately omits query.value.A hash is pseudonymous, not anonymous. Anyone holding a candidate address can hash it and test for a match, and the space of real e-mail addresses is small enough to enumerate. Hashing therefore protects against bulk disclosure — a leaked database does not hand anyone a mailing list — but it does not make the data non-personal, and under GDPR it is still personal data. The service treats it as such. This is precisely why the k-anonymity endpoint exists: the strongest protection is the address never being transmitted in the first place.
| Data | Why | Retention |
|---|---|---|
| Reported IP addresses and CIDR ranges | They are the threat intelligence itself. | 90 days after the last corroborating report. |
| SHA-256 digests of reported e-mail addresses | To answer lookups without holding the addresses. | 90 days after the last corroborating report. |
| Reported e-mail domains | Disposable and throwaway-provider detection. | 90 days after the last corroborating report. |
evidence objects supplied by reporters |
Manual moderation and appeal review. | With the report. Reporters are instructed never to include personal data. |
| The IP of the site that submitted a report | Abuse control on the reporting endpoint. | With the report. |
| Contact address on an appeal | To communicate the outcome, and nothing else. | Until the appeal is resolved and archived. |
| API key prefix, usage counters, last-used IP | Rate limiting, abuse control, key management. | Lifetime of the key. |
email_hash over email and prefer the
range endpoint over both, so the address never leaves
your systems.evidence. Counts, timestamps, rule names
and target paths are enough.
Anyone can request removal through POST /v1/appeal
without an account. An e-mail listing can be appealed using only its digest, so
exercising the right does not require disclosing the address. Approved appeals set
the entry to removed, which takes it out of lookups and out of the feed
on the next sync.
Not for reading. Lookups, the range endpoint, the feed, statistics and appeals are all open. A key is required only to submit reports, and it raises your read limit from 60 to 600 requests per minute.
Almost always a normalization mismatch. Check your implementation against the
test vectors: alice@example.com must
hash to ff8d9819…c6d976. The usual culprits are a trailing newline
(use printf, not echo), a missing
trim(), uppercase hex output, or hashing the raw input before
lowercasing it.
Yes — that is exactly what /v1/range/{prefix}
is for. You send 5 hex characters of the digest and compare the rest locally.
See the worked example.
Entries appear as soon as they are confirmed. Scores, expiry and trust are
recomputed hourly, so updated_at on an otherwise unchanged entry
may move without its status changing. Syncing hourly is more than enough.
Open an appeal describing what changed — a compromised account secured, a reassigned range, a NAT gateway shared by many users. Appeals are reviewed by a human. Note that shared and NAT addresses do get listed when abuse genuinely originates from them; the corroboration threshold exists to keep single-site mistakes out of the published set.
protected_range?The address is private, reserved, loopback, link-local, or belongs to a major provider's published range. This nearly always means your application is reading a client IP from an untrusted proxy header. Fix the source, and filter those ranges locally before reporting — repeated hits reduce your trust score sharply.
You will also see it for an email_domain report against a major
mail provider (gmail.com, outlook.com and the like,
including their subdomains). Those can never be listed as a whole; report the
specific email_hash instead.
Public GET endpoints, yes — they send
Access-Control-Allow-Origin: * and answer preflight. Authenticated
endpoints, no: they deliberately send no CORS headers so that an API key can
never be embedded in front-end code.
Your integration should not care. Every example on this page fails open: a timeout, a 5xx or a malformed body is treated as “not listed”. If availability matters to your blocking decisions, mirror the feed and answer locally.
Yes: OpenAPI 3.0.3, covering every endpoint, schema, example and error code. Point your generator at it.
| Version | Date | Changes |
|---|---|---|
1.0.0X-MBL-Version: 1 |
2026-07-28 |
Initial public release.
|
/v1. A breaking
change ships as /v2, and /v1 keeps working through a
published deprecation window announced on this page.error.code values and entry kind, status
and category values are stable. New members may be added to those
sets; handle unknown members gracefully.score values are advisory and their formula may be tuned.
Do not hard-code exact thresholds you cannot revisit.