Verify API

Spot alt accounts before they reach your server

An HTTP API for Discord bots. You create a verify session, send the user a link, and get back a verdict covering device fingerprint, alt suspicion and trust score. No SDK, no library, just JSON.

Base URL https://mrs-maid.cc/api/verify/v1
Authentication Authorization: Bearer
Format JSON
Delivery Webhook and polling

What the Verify API solves

A banned user comes back with a fresh account, and all your bot sees is a new Discord ID. The Verify API gives you a second angle: it recognises the device, not the account.

Device fingerprint instead of account ID

The user opens a one-time link in a browser. There a fingerprint of the device is collected and matched against the devices that have already verified on your server.

Alt suspicion and trust score

You get a list of the Discord IDs seen on the same device, plus a trust score from 0 to 100. Where you draw the line is your bot's decision.

Captcha when you need it

You can require a captcha per session. The outcome comes back as its own field so your bot can treat bots and automated joins separately.

Two ways to get the result

As soon as the check finishes, a signed webhook goes to your URL. If you miss it, you can fetch the same result at any time through the polling endpoint.

Plain HTTP and JSON No SDK required Signed webhooks Polling as fallback

Quickstart

Four steps from nothing to your first verdict.

  1. Register your bot

    Create a verify tenant for your bot in the dashboard. That gives you your signing secret and the field for your webhook URL.

  2. Create an API key

    Generate a key. You see it exactly once, after that only the prefix. Up to 5 keys can be active per tenant at the same time.

  3. Create a session

    When a user needs to verify, your bot creates a session and sends them the verifyUrl it gets back, for example by DM or behind a button.

    curl
    curl -X POST https://mrs-maid.cc/api/verify/v1/session \
      -H "Authorization: Bearer mm_vrf_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{"guildId":"123456789012345678","userId":"987654321098765432"}'
  4. Receive the result

    If you configured a webhook URL, the verdict is delivered to you automatically. Otherwise, or when a delivery fails, you fetch the session through the polling endpoint.

Authentication

Every protected endpoint expects your API key as a bearer token in the Authorization header. There are no cookies and no sessions.

HTTP
Authorization: Bearer mm_vrf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

About your keys

A key starts with mm_vrf_ followed by 43 URL-safe characters.

At most 5 keys can be active per tenant at any one time. Revoked keys do not count.

The full key is shown only right after you create it. We store nothing but a hash, so we cannot show it to you again. If you lose it, revoke it and create a new one.

The key belongs on your server. The API does answer with open CORS headers, because the key is the credential and not a cookie. That is exactly why it must never end up in frontend code, in a mobile app, or in a public repository. Whoever holds it burns your quota and reads your results.

The verify flow

How one verification runs end to end. Steps one and two are one-off, three through nine happen per user.

  1. Register the bot in the dashboard

    You create a verify tenant. It holds your signing secret, your webhook URL and your plan.

  2. Create an API key

    The key identifies your bot to the API and is also the unit that quota and burst limit are counted against.

  3. Create a session

    Your bot calls the session endpoint with guildId and userId and gets back sessionId, verifyUrl and an expiry. By default the link is valid for 900 seconds.

  4. The user opens the link

    You deliver the verifyUrl to the user, for example by direct message or through a button. The link is issued for exactly that user and that server.

  5. Consent screen

    In the browser the user first sees what is collected and why. Nothing else happens without their agreement.

  6. Discord login

    The user signs in with Discord. If a different account signs in than the one named in the link, the flow stops with an error.

  7. Device check and optional captcha

    The browser collects the fingerprint. If you requested a captcha for this session and your plan allows it, it is solved here.

  8. Verdict and webhook

    The result is computed, reduced to the fields of your plan and sent to your webhook URL. The user sees a completion page immediately, no matter how fast your endpoint answers.

  9. Act on the result

    Your bot processes the verdict from the webhook or fetches it through the polling endpoint, and then decides on its own whether to grant a role, flag a case for review, or kick.

A session is bound to tenant, server and user. A link from your bot cannot complete a session belonging to another bot, and a foreign sessionId gets 404 rather than 403 so that the existence of other tenants' sessions cannot be probed.

Endpoints

Three endpoints, and the integration needs nothing more. All responses are JSON and are never cached.

GET /api/verify/v1
Public

Service info

Returns name, version, docs link and a short usage hint. Needs no key and works well as a lightweight reachability check.

curl
curl https://mrs-maid.cc/api/verify/v1

Response with status 200:

JSON
{
  "name": "Mrs. Maid Verify API",
  "version": "v1",
  "docs": "https://mrs-maid.cc/verify-api",
  "usage": "POST /api/verify/v1/session with header \"Authorization: Bearer <key>\" and body {guildId,userId}."
}
POST /api/verify/v1/session
Key required Counts against quota

Create a session

Creates a verify session and returns the link you send to the user. This call consumes one unit of your daily quota.

The request body is JSON and may be at most 16 kB.

Field Type Required Description
guildId string yes Discord ID of the server the user verifies for. 15 to 25 digits.
userId string yes Discord ID of the user who has to verify. 15 to 25 digits. Only that account can complete the link.
captcha boolean no Requests a captcha for this session. It only takes effect if your plan includes captcha or captcha has been enabled separately for your tenant, otherwise the request is silently set to false. The response tells you in the captcha field what actually applies.
meta object no Free-form extra data that we return unchanged in the result. Handy for ticket or correlation IDs. At most 2000 characters once serialised.
curl
curl -X POST https://mrs-maid.cc/api/verify/v1/session \
  -H "Authorization: Bearer mm_vrf_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "guildId": "123456789012345678",
        "userId": "987654321098765432",
        "captcha": true,
        "meta": { "ticket": "a1b2c3" }
      }'

Response with status 201:

JSON
{
  "sessionId": "vsess_3f1c8a9e-2d44-4b17-9c0a-7e15b0a2d9f1",
  "verifyUrl": "https://mrs-maid.cc/verify/s/eyJ0Ijoi...<token>",
  "captcha": true,
  "expiresAt": 1785312000
}

sessionId starts with vsess_ followed by a UUID. expiresAt is a Unix timestamp in seconds, not milliseconds. The default lifetime is 900 seconds. If a link expires, simply create a new session.

GET /api/verify/v1/session/:id
Key required

Poll for the result

Fetches the state of a session. This call consumes no quota and is the reliable way to catch up on a result. Externally there are only two states: pending and completed.

Field Type Required Description
id string yes The sessionId from the response of the session call.
curl
curl -H "Authorization: Bearer mm_vrf_YOUR_KEY" \
  https://mrs-maid.cc/api/verify/v1/session/vsess_3f1c8a9e-2d44-4b17-9c0a-7e15b0a2d9f1

Response while the check is still running:

JSON
{
  "sessionId": "vsess_3f1c8a9e-2d44-4b17-9c0a-7e15b0a2d9f1",
  "status": "pending",
  "guildId": "123456789012345678",
  "userId": "987654321098765432"
}

Response after completion, here with the fields of a Pro plan:

JSON
{
  "status": "completed",
  "event": "verify.completed",
  "sessionId": "vsess_3f1c8a9e-2d44-4b17-9c0a-7e15b0a2d9f1",
  "guildId": "123456789012345678",
  "userId": "987654321098765432",
  "meta": { "ticket": "a1b2c3" },
  "completedAt": 1785311640,
  "isVerified": true,
  "isInDiscord": false,
  "isAlt": false,
  "altUserIds": [],
  "trustScore": 82,
  "signals": ["alt_shared_ip", "bot:no_gpu"],
  "penalties": { "alt": 0, "privacy": 5, "bot": 0 },
  "telemetry": {
    "ipHash": "9f2c...",
    "country": "DE",
    "asn": "3320",
    "asnOrg": "Deutsche Telekom AG",
    "rttMs": 24,
    "gpuSpeedMs": 11
  },
  "captchaPassed": true
}

You only see sessions belonging to your own tenant. A foreign or unknown sessionId gets a 404. In the completed state the response carries the same fields as the webhook, plus status.

Webhooks

If you configured a webhook URL in the dashboard, we send the verdict there as soon as a session completes. The request is a POST with a JSON body and a signature you can use to verify the origin.

Webhooks are not guaranteed. Retries run in process and do not survive a restart. If the service restarts between two attempts, that delivery is lost. Treat the webhook as the fast path, not the safe one: the polling endpoint is the official way to reconcile. Reconcile open sessions on a schedule instead of relying on delivery alone.

Headers on every delivery

Header Description
x-mrsmaid-event The event type. Currently always verify.completed.
x-mrsmaid-timestamp Time of delivery as a Unix timestamp in seconds. It is part of the signature.
x-mrsmaid-signature The signature, formatted as sha256= followed by the hex encoding of the HMAC.
user-agent Fixed value mrs-maid-verify/1.0. Not suitable on its own for identification, always check the signature.
content-type Always application/json.

Verifying the signature

First derive a dedicated webhook key from your signing secret: HMAC-SHA256 over the fixed string mm-verify-webhook-v1, encoded as base64url. That separation means the key used to check webhooks cannot forge verify links.

Then compute HMAC-SHA256 over the header timestamp, a dot, and the raw body, using the derived key. Encode the result as hex and prefix it with sha256=.

Compare the result against the header, and do it in constant time. A plain string comparison leaks through its runtime how many characters matched.

Pseudocode
key       = base64url(HMAC_SHA256(signingSecret, "mm-verify-webhook-v1"))
signature = "sha256=" + hex(HMAC_SHA256(key, timestamp + "." + rawBody))

Sign the raw body. The signature covers exactly the bytes that went over the wire. If your framework parses the body into JSON and you re-serialise it afterwards, key order and whitespace change and the check will always fail. Use a raw body parser on that one route.

Example with Node.js and Express

A complete receiver: raw body, a 300 second window, constant-time comparison, and a 2xx before the actual processing starts. If you answer only after processing, you run into our timeout and get the same delivery again.

Node.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SIGNING_SECRET = process.env.MRSMAID_SIGNING_SECRET;
const TOLERANCE_SECONDS = 300;

function handleVerdict(verdict) {
  console.log(verdict.event, verdict.sessionId, verdict.isVerified);
}

app.post(
  '/webhooks/mrsmaid',
  express.raw({ type: 'application/json', limit: '256kb' }),
  (req, res) => {
    const ts  = req.get('x-mrsmaid-timestamp') || '';
    const sig = req.get('x-mrsmaid-signature') || '';
    const raw = req.body.toString('utf8');

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
    if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
      return res.status(400).end();
    }

    const key = crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update('mm-verify-webhook-v1')
      .digest('base64url');

    const expected =
      'sha256=' +
      crypto.createHmac('sha256', key).update(ts + '.' + raw).digest('hex');

    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    res.status(200).end();
    handleVerdict(JSON.parse(raw));
  }
);

Retries

Aspect Behaviour
Delivery attempts Up to 7 per session, meaning the first attempt plus the configured retries.
Wait between attempts 2s, 4s, 8s, 16s, 32s, 64s
Timeout per attempt 8 seconds. After that the attempt counts as failed.
What counts as success Any status code in the 2xx range. Anything else triggers a retry.
Redirects A 3xx counts as a failure. We follow no redirect, because it could otherwise point at an internal address.

Requirements for your URL

Because you choose the target URL, we check it before every attempt. Only publicly reachable https addresses are allowed. Plain http, localhost, hostnames ending in .local, .internal or .localhost, private IPv4 ranges, link-local addresses and raw IPv6 literals are rejected. A rejected URL is not retried, it can never work.

https Public hosts only No redirects

Result fields

Webhook and polling return the same structure. Which fields are included depends on your plan: a higher plan adds fields, it does not change the existing ones.

Field Type From plan Description
event string always The event type, currently always verify.completed.
sessionId string always The session this result belongs to.
guildId string always The server from the session call.
userId string always The user from the session call.
meta object | null always Your extra data, returned unchanged. Null if you sent none.
completedAt number | null always Time of completion as a Unix timestamp in seconds.
isVerified boolean Free Whether the user completed the flow successfully. This answers whether verification happened at all, not whether the user is trustworthy.
isInDiscord boolean Free Carries the same value as isAlt. The field only exists on Free because isAlt itself is delivered from Indie upwards. Do not read it as a statement about server membership.
isAlt boolean Indie Whether the device has already verified with a different Discord account on this server.
altUserIds string[] Indie The Discord IDs of the other accounts seen on this device. Empty array if there are none.
trustScore number Indie Integer from 0 to 100. Higher means more trustworthy. You set the threshold at which you act.
signals string[] Indie A flat list of short identifiers, not an object. Alt hits appear without a prefix (alt_hardware_match, alt_shared_ip, alt_match), privacy browser traits carry the privacy: prefix, and automation hints carry the bot: prefix, for example bot:no_gpu.
penalties object Pro The deductions that make up the score, broken down into alt, privacy and bot.
telemetry object Pro Technical context of the check: ipHash, country, asn, asnOrg, rttMs and gpuSpeedMs. asn arrives as a string, not as a number.
captchaPassed boolean | null Pro Outcome of the captcha. True means passed, false means failed, null means not checked: either no captcha was requested or served, or the check could not reach Cloudflare.

How to read the fields

trustScore runs from 0 to 100, and higher is better. That is the direction people get wrong most often: 95 is an unremarkable user, 10 is a highly suspicious one.

signals is a string array, not an object with fixed fields. At most one alt entry is present: alt_hardware_match for a hardware match, alt_shared_ip for a shared IP address alone, otherwise alt_match. On top of that there is one entry per detected privacy trait with the privacy: prefix and one per automation hint with the bot: prefix, for example bot:software_renderer. New heuristics add new values, so match on the prefix rather than on a fixed list.

isInDiscord always carries the same value as isAlt, it is not a second assessment. On Free it is the only way to get that information, because isAlt is in the payload from Indie upwards. It says nothing about whether the user is a member of a server. Do not build membership logic on it, the Discord API is there for that.

null is not a pass. captchaPassed is null when no captcha was requested or served at all, and equally when one was served but we could not check it because Cloudflare was unreachable. Treat only an explicit false as a failure, otherwise you block every user you never asked for a captcha.

telemetry never contains the raw IP address. You get ipHash, country, network operator and measurements, but never the address itself.

Plans and limits

Plans differ in two things: how many sessions you may create per day, and how detailed the result is.

Free

500 sessions per day

  • Result: isVerified and isInDiscord
  • No captcha

Indie

1,000 sessions per day

  • Adds alt detection, trust score and signals
  • No captcha

Scale

100,000 sessions per day

  • Adds penalties, telemetry and the captcha outcome
  • Captcha available

Pro and Scale return exactly the same fields. The only difference is the daily limit.

Current prices are in your dashboard, where you also see your active plan and your usage. Go to verify keys

Rate limits and quota

Three layers apply independently. When one of them fills up you get a 429, no matter how much room is left in the others.

Per IP address

300 requests per minute across all endpoints. This value is fixed and protects against load spikes from a single source.

Per key, short term

120 requests per minute per key. Catches loops and retry storms before they burn through your daily quota.

Per key, daily

Your plan's daily quota is counted per key, not per bot. Several keys therefore do not share an allowance, each has its own. The counter resets daily at 00:00 UTC.

Headers

There are two families of headers. X-RateLimit-* describes your daily quota and appears only on responses from POST /session, including the successful ones. The draft-7 headers belong to the IP limit and appear on every endpoint, so on the polling call and the service info as well.

Header Applies to Description
X-RateLimit-Limit POST /session Your daily limit.
X-RateLimit-Remaining POST /session How many sessions are left today.
X-RateLimit-Reset POST /session Seconds until the next reset. A duration, not a timestamp.
RateLimit All endpoints State of the IP limit in the current window, in the draft-7 format. It refers to the IP address, not to your key.
RateLimit-Policy All endpoints The window behind the IP limit, also per draft-7.
Retry-After Only on 429 Seconds you should wait before trying again.

On a 429 an immediate retry does not help. For rate_limited wait a moment, for quota_exceeded wait for the reset. The polling endpoint does not count against the daily quota, so you can still reconcile open sessions once your allowance is used up.

Errors

Errors always come back as JSON with an error field. The machine-readable code is stable, the message next to it may change. Branch on the code, not on the text.

Status Code Meaning
400 bad_request guildId or userId is missing or is not a Discord ID.
400 meta_too_large Your meta object is longer than 2000 characters once serialised.
400 bad_json The body could not be read as JSON.
401 missing_api_key The Authorization header is missing or carries no bearer token.
401 invalid_api_key The key is unknown or has been revoked.
403 tenant_suspended Your access is suspended. The key itself is valid, but the tenant is disabled.
404 not_found The session does not exist or belongs to another tenant. We answer both the same way so that other tenants' sessions cannot be guessed.
413 payload_too_large The request body exceeds 16 kB.
429 rate_limited A per-minute limit was hit, either per IP or per key.
429 quota_exceeded The daily quota of this key is used up. The response includes limit and resetInSeconds.
500 internal_error Unexpected error on our side. Retrying later is reasonable.
503 unavailable A backend service is currently unreachable, for example the database. Try again later.

If the Verify API is not enabled in this environment, the routes do not exist at all. You then get a 404 instead of a 503.

Privacy and retention

Verification processes personal data. We keep it as briefly as possible and only hand it out at the depth your plan provides for.

Data Retention Description
Session verdict 90 days The stored verdict is cleared after 90 days. The session record remains, but the result can no longer be fetched afterwards. Store whatever you need long term in your own system.
Device data 365 to 1095 days Recognition needs a memory. The exact period depends on the kind of record, after which it is removed.
IP address not stored A hash is stored instead of the address. Neither you nor we get the raw IP back out of the API.

We return your meta data unchanged and treat it as opaque. Do not put personal data in there that you do not need yourself, use an ID that only means something inside your own system.

Ready for your first test run

Register your bot, create a key and open your first session. The free plan is enough to walk through the whole flow once.