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.
Verify API
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.
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.
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.
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.
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.
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.
Four steps from nothing to your first verdict.
Create a verify tenant for your bot in the dashboard. That gives you your signing secret and the field for your webhook URL.
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.
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 -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"}'
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.
Every protected endpoint expects your API key as a bearer token in the Authorization header. There are no cookies and no sessions.
Authorization: Bearer mm_vrf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
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.
How one verification runs end to end. Steps one and two are one-off, three through nine happen per user.
You create a verify tenant. It holds your signing secret, your webhook URL and your plan.
The key identifies your bot to the API and is also the unit that quota and burst limit are counted against.
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.
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.
In the browser the user first sees what is collected and why. Nothing else happens without their agreement.
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.
The browser collects the fingerprint. If you requested a captcha for this session and your plan allows it, it is solved here.
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.
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.
Three endpoints, and the integration needs nothing more. All responses are JSON and are never cached.
Returns name, version, docs link and a short usage hint. Needs no key and works well as a lightweight reachability check.
curl https://mrs-maid.cc/api/verify/v1
Response with status 200:
{
"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}."
}
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 -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:
{
"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.
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 -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:
{
"sessionId": "vsess_3f1c8a9e-2d44-4b17-9c0a-7e15b0a2d9f1",
"status": "pending",
"guildId": "123456789012345678",
"userId": "987654321098765432"
}
Response after completion, here with the fields of a Pro plan:
{
"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.
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.
| 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. |
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.
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.
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.
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));
}
);
| 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. |
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.
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. |
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 differ in two things: how many sessions you may create per day, and how detailed the result is.
500 sessions per day
1,000 sessions per day
5,000 sessions per day
100,000 sessions per day
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
Three layers apply independently. When one of them fills up you get a 429, no matter how much room is left in the others.
300 requests per minute across all endpoints. This value is fixed and protects against load spikes from a single source.
120 requests per minute per key. Catches loops and retry storms before they burn through your daily quota.
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.
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 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.
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.
Register your bot, create a key and open your first session. The free plan is enough to walk through the whole flow once.