Developer documentation

M Blacklist API

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.

Base URL https://black.majevski.com Version v1 Format JSON Reads no key required

Overview

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.

Free to read

Lookups, the feed, statistics and delisting appeals need no API key. Only submitting reports does.

Privacy by construction

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

Corroborated, not credulous

An entry is published only after independent verified sites agree — or a site whose trust an operator has explicitly pinned reports it.

Mirror it locally

The cursor-paginated feed lets you answer every lookup from your own database, with zero latency on the hot path.

What is in the database

KindCanonical valueExample
ipA single IPv4 or IPv6 address203.0.113.5
ip_rangeCIDR reduced to network address plus prefix length203.0.113.0/24
email_hashSHA-256 of the normalized address, 64 lowercase hexff8d9819…c6d976
email_domainLowercase hostname, no @, no schemespamdomain.test

Endpoints at a glance

MethodPathAuthPurpose
GET/v1/healthNoneLiveness probe
GET/v1/statsNoneAggregate statistics
GET/v1/checkNoneCheck one subject
POST/v1/checkNoneCheck up to 100 subjects
GET/v1/range/{prefix}Nonek-anonymity e-mail lookup
GET/v1/feedNoneIncremental confirmed feed
POST/v1/reportwriteSubmit observations
GET/v1/accountkeyKey & site status
POST/v1/appealNoneRequest delisting
GET/v1/cron/runcron keyRun scheduled maintenance

Conventions

  • All requests and responses are application/json; POST bodies must be JSON with a matching Content-Type.
  • All timestamps are ISO 8601 in UTC, e.g. 2026-07-28T09:14:02Z.
  • Every JSON response carries X-MBL-Version: 1. A breaking change ships as a new path prefix (/v2), never as a silent change to /v1.
  • Additive fields may appear at any time — parse defensively and ignore keys you do not know.
  • Public 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.

Quick start

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"
  }
}
Fail open, always

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.

Where to go next

  1. Check subjects on demand

    Use /v1/check for one-off lookups and its batch form for up to 100 values per call.

  2. Check e-mail addresses privately

    Use /v1/range/{prefix} so the address never leaves your server — see the worked example.

  3. Mirror the data set

    Pull /v1/feed on a schedule and answer every lookup from your own database. See mirroring the feed.

  4. Contribute back

    Register your domain, verify ownership, get a key and report what you block. Contribution is what keeps the data set useful.

Core concepts

Entries and statuses

Every subject in the database is one entry. Reports accumulate against it and drive its status.

StatusMeaningReturned as listed?
pendingReported, not yet corroborated.No
confirmedPublished. The only status that appears in the feed.Yes
expiredNo activity within the retention window (90 days by default).No
removedDelisted by an administrator, usually after an appeal.No
allowlistedPermanently exempt — protected infrastructure, CDNs, resolvers.No

Categories

CategoryUse it for
bruteforceRepeated failed authentication — login, XML-RPC, SSH, API credential stuffing.
spamUnsolicited content submission — comments, contact forms, trackbacks.
fake_accountFraudulent, throwaway or automated registrations.
abuseScanning, exploit probing, path traversal attempts, other malicious traffic.

Corroboration

A single site's opinion is not enough to list anything. An entry is promoted from pending to confirmed when either:

  • at least 2 distinct verified sites have reported it (policy.corroboration_threshold), or
  • a single site whose trust score is 80 or higher and has been pinned by an operator reports it (policy.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.

Score

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.

Trust score

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.

Protected ranges

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.

Retention

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.

Authentication

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.

Key format

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.

Sending the key

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);

Scopes

ScopeGrants
readAuthenticated reads at the higher rate limit, plus /v1/account.
writePOST /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.

Getting a key

  1. Register your domain

    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.

  2. Prove ownership

    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.

  3. Copy the key once

    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.

Key hygiene

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.

Normalization & hashing

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.

KindRuleResult
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

Derived identifiers

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.

Reference implementation

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

Test vectors

Verify your implementation against these before going near production. If any of them differs, your lookups will silently return “not listed” forever.

InputNormalizedSHA-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

KindCanonical valuevalue_hashhash_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
Note

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.

Endpoints

GET https://black.majevski.com/v1/health No key

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" }
GET https://black.majevski.com/v1/stats No key

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"
}
GET https://black.majevski.com/v1/check No key

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.

Query parameters

NameRequiredDescription
typeYes One of ip, email, email_hash, domain. domain searches the email_domain kind.
valueYes The subject. URL-encode it. Max 320 characters.
IP range matching

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.

type=email

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);

Response — listed

{
  "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"
  }
}

Response — not listed

{
  "query":  { "type": "ip", "value": "198.51.100.9" },
  "listed": false,
  "result": null
}

Response — 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.

POST https://black.majevski.com/v1/check No key

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.

  • Maximum 100 values per request (limits.batch_max); a longer batch returns 413 payload_too_large, not a validation error.
  • Maximum request body 256 KB; larger bodies return 413 payload_too_large.
  • One batch request counts as one request against your rate limit.
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 }
  ]
}
GET https://black.majevski.com/v1/range/{prefix} No key

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.

Parameters

NameInDescription
prefixpath Exactly 5 lowercase hex characters (^[0-9a-f]{5}$). Anything else returns 422 validation_failed.
typequery Optional, defaults to email. Reserved for future subject types.
Never send a full address here

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.

GET https://black.majevski.com/v1/feed No key

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.

Query parameters

NameDefaultDescription
since ISO 8601 UTC timestamp. Only entries with updated_at >= since are returned. Send the server_time of your last successful sync.
kindall ip, ip_range, email_hash or email_domain.
limit500 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.
Pagination contract

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"
}
POST https://black.majevski.com/v1/report write scope

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.

Request body

FieldRequiredDescription
reports[].kindYes ip, ip_range, email, email_hash or email_domain. email is hashed server-side and stored as email_hash.
reports[].valueYes The subject, in any form the normalization rules accept.
reports[].categoryYes bruteforce, spam, fake_account or abuse.
reports[].evidenceNo Free-form JSON object stored verbatim for moderators. Keep it under a few hundred bytes.
reports[].observed_atNo ISO 8601 UTC. Defaults to time of receipt; future timestamps are clamped to now.
Never put personal data in evidence

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.

Deduplication and promotion

  • Reports are deduplicated per (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.
  • A new entry starts as pending and is promoted to confirmed per the corroboration rules.
  • The call returns 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" }
  ]
}

Rejection reasons

ReasonMeaningWhat 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.
GET https://black.majevski.com/v1/account any valid key

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.

POST https://black.majevski.com/v1/appeal No key

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.

Request body

FieldRequiredDescription
kindYes ip, ip_range, email, email_hash or email_domain.
valueYes The listed subject. Prefer email_hash over email so no address leaves your system.
reasonYes 10–2000 characters of plain text. Explain what changed — “the compromised account was rebuilt”, “this is a shared NAT gateway”. HTML is stripped.
emailNo 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.

POST https://black.majevski.com/v1/telemetry Key optional

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.

POST https://black.majevski.com/v1/feedback Key optional

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."
}
GET https://black.majevski.com/v1/cron/run Cron key

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.

Authentication

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:

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

Examples

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

Response

{
  "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"
}

Time budget — why 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.

Status codes

CodeBodyMeaning
200status: okWork ran. Check completed.
202reason: too_soonCalled again inside policy.maintenance_min_interval (default 300s). Harmless.
202reason: already_runningAnother trigger holds the lock. Harmless.
401unauthorizedKey missing, too short, or wrong — or the endpoint is disabled.
405bad_requestUse GET or POST.
429rate_limitedToo 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.

The k-anonymity e-mail flow

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

Worked example

  1. Normalize

    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.

  2. Hash

    sha256("alice@example.com") is:

    ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976

    The first 5 characters (ff8d9) are the prefix; the remaining 59 are the suffix.

  3. Request the bucket

    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.

  4. Receive every candidate

    {
      "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" }
      ]
    }
  5. Compare locally

    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.

Practical guidance
  • Cache the bucket response for up to 24 hours keyed by prefix. Buckets are small — typically a handful of suffixes — and this removes the API from your registration path almost entirely.
  • Never log the prefix together with the address you were checking; that recombination is the one thing this design is meant to prevent.
  • If you also mirror the feed you can answer e-mail lookups entirely offline: the feed publishes email_hash values directly.
  • Do not send a full 64-character digest to this endpoint. Only the 5-character prefix is accepted; anything else is a 422.

Mirroring the feed

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.

  1. Store a cursor, not a guess

    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.

  2. Upsert by (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.

  3. Expire locally too

    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.

  4. Sync at a sane interval

    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.

  5. Bound everything

    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.

Rate limits

Limits are enforced in a fixed 60-second window. Anonymous reads are counted per client IP; authenticated requests are counted per API key.

IPv6 clients are bucketed by /64

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.

TrafficCounted perDefault limit
Anonymous reads — /v1/health, /v1/stats, /v1/check, /v1/range, /v1/feed Client IP60 / minute
Authenticated reads (valid key present) API key600 / minute
Writes — POST /v1/report API key120 / minute
Appeals — POST /v1/appeal Client IP5 / day

Headers

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."}}

Staying inside the budget

  • Batch: POST /v1/check with 100 values is one request, not 100.
  • Cache: hold lookup answers for at least a few minutes, and range buckets for up to 24 hours.
  • Mirror: the feed removes per-lookup traffic entirely.
  • Honour Retry-After. Add exponential backoff with jitter on 429 and 5xx; never retry a 4xx other than 429.
  • Behind a proxy or CDN, anonymous limits are counted per client IP only when the proxy is on the service's trusted list — otherwise your whole fleet shares one bucket. Use a key.

Error codes

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.

CodeHTTPMeaning & fix
bad_request400 Body is not valid JSON, Content-Type is wrong on a POST, or a required parameter is missing. Not retryable without a change.
unauthorized401 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_scope403 The key is valid but lacks the required scope. Issue a key with write.
site_unverified403 The site has not completed domain verification, or has been suspended. Complete verification before reporting.
not_found404 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_failed422 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_large413 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_limited429 Budget exhausted for the current window. Wait Retry-After seconds, then retry with backoff.
server_error500 Unexpected server-side condition. Retry with exponential backoff; if it persists, treat the service as down and fail open.

Recommended client behaviour

StatusRetry?Action
2xxUse the body.
400, 413, 422NoLog it and fix the request. Retrying will not help.
401, 403NoSurface it to an administrator. Do not retry in a loop — you will only burn quota.
429YesSleep for Retry-After, then retry with jitter.
5xx, timeout, DNS failureYesExponential backoff, capped attempts. Meanwhile fail open.

Privacy statement

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.

E-mail addresses are never stored in plaintext

  • An address is only ever persisted as 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.
  • When you submit 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.
  • The same rule applies to appeals: an appeal record stores the digest, never the address that produced it.
  • Because SHA-256 is one-way, the service cannot recover an address from its stored form, and neither can anyone who obtains a copy of the database.
What hashing does and does not buy you

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.

What is stored

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

What is never stored

  • Plaintext e-mail addresses, in any table, log or backup.
  • Passwords, password hashes or session tokens belonging to reported users.
  • Message bodies, form submissions, request bodies or cookies.
  • Names, postal addresses, phone numbers or payment data.
  • Browsing history, or any behavioural profile of an end user. Reports are single-event observations, not a timeline.
  • API key secrets. Only a keyed HMAC of the secret is stored.

Your obligations as an integrator

  • Report only what you actually observed on your own infrastructure. Never forward data obtained from a third party.
  • Prefer email_hash over email and prefer the range endpoint over both, so the address never leaves your systems.
  • Keep personal data out of evidence. Counts, timestamps, rule names and target paths are enough.
  • Tell your users, in your own privacy notice, that abusive submissions may be reported to a shared threat-intelligence service in hashed form.
  • Do not use the service to make consequential decisions about a person without a human review path. Automated blocking should always be appealable.

Delisting

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.

Transport and access

  • HTTPS only, with HSTS. There is no plaintext endpoint.
  • Authenticated endpoints send no permissive CORS headers, so a key cannot be used from a browser page and cannot leak through one.
  • All database access uses prepared statements; all secret comparisons are constant-time; administrative access is session-authenticated with CSRF protection on every state change.

FAQ

Do I need an API key to use this?

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.

Why does my e-mail lookup always say “not listed”?

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.

Can I look up an e-mail address without sending 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.

How fresh is the feed?

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.

My server's IP was listed. What do I do?

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.

Why was my report rejected as 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.

Can I call the API from browser JavaScript?

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.

What happens if the API is down?

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.

Is there a machine-readable specification?

Yes: OpenAPI 3.0.3, covering every endpoint, schema, example and error code. Point your generator at it.

Changelog

VersionDateChanges
1.0.0
X-MBL-Version: 1
2026-07-28 Initial public release.
  • Public read endpoints: /v1/health, /v1/stats, /v1/check (GET and POST), /v1/range/{prefix}, /v1/feed, /v1/appeal.
  • Authenticated endpoints: /v1/report, /v1/account.
  • Entry kinds ip, ip_range, email_hash, email_domain; categories bruteforce, spam, fake_account, abuse.
  • k-anonymity e-mail lookups with a 5-character hash prefix.
  • Cursor-paginated feed for local mirroring.

Compatibility policy

  • Fields may be added to any response without notice. Ignore keys you do not recognise rather than failing.
  • Fields are never removed or repurposed within /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.
  • Numeric score values are advisory and their formula may be tuned. Do not hard-code exact thresholds you cannot revisit.