{
  "openapi": "3.0.3",
  "info": {
    "title": "MailProbe API",
    "version": "1.0.0",
    "description": "Real-time email verification REST API, hosted in France (OVH), GDPR-compliant. Every address is checked live — syntax, DNS MX lookup, SMTP handshake — and **no verification result is ever cached or stored**: each request re-probes and costs 1 credit per address.\n\nAuthentication is by API key (`Authorization: Bearer mp_live_...`), created from the dashboard. Two endpoints: `POST /api/v1/verify` (1 to 500 addresses per call) and `GET /api/v1/credits`.\n\nEvery result carries both the raw SMTP verdict (`result`) and an actionable rollup (`status`) plus a deterministic 0-100 `score`, so the scoring rubric is auditable rather than a black box.",
    "termsOfService": "https://mailprobe.dev/terms/",
    "contact": {
      "name": "MailProbe",
      "url": "https://mailprobe.dev/about/"
    }
  },
  "servers": [
    { "url": "https://mailprobe.dev", "description": "Production" }
  ],
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Verification", "description": "Email address verification" },
    { "name": "Account", "description": "Credit balance" }
  ],
  "paths": {
    "/api/v1/verify": {
      "post": {
        "tags": ["Verification"],
        "summary": "Verify one or more email addresses",
        "description": "Verifies 1 to 500 addresses in a single synchronous call and always returns an array, in the same order as the input. **One credit is charged per address in the request**, deducted before the probes run; if the client disconnects before the results are delivered, the whole batch is refunded.\n\nAddresses are grouped by domain (sequential within a domain to avoid RCPT floods, up to 20 domains in parallel) and de-duplicated within the request. Nothing is cached across requests.",
        "operationId": "verifyEmails",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/VerifyRequest" },
              "examples": {
                "single": {
                  "summary": "One address",
                  "value": { "emails": ["contact@mailprobe.dev"] }
                },
                "bulk": {
                  "summary": "Several addresses",
                  "value": { "emails": ["contact@mailprobe.dev", "hello@stripe.com", "noreply@gmial.com"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One entry per submitted address, in input order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": { "$ref": "#/components/schemas/VerifyEntry" }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/NoCredits" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/credits": {
      "get": {
        "tags": ["Account"],
        "summary": "Check credit balance",
        "description": "Returns the remaining credit balance. Credits are valid for 12 months from the most recent purchase; an expired balance returns `0`.",
        "operationId": "getCredits",
        "responses": {
          "200": {
            "description": "Current balance",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Credits" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key in the `mp_live_...` format, created from the dashboard. Header: `Authorization: Bearer mp_live_...`."
      }
    },
    "schemas": {
      "VerifyRequest": {
        "type": "object",
        "required": ["emails"],
        "properties": {
          "emails": {
            "type": "array",
            "description": "Addresses to verify. Entries are lower-cased and trimmed before probing. The JSON body is capped at 50 kB, which is well above 500 addresses of ordinary length.",
            "items": { "type": "string" },
            "minItems": 1,
            "maxItems": 500,
            "example": ["contact@mailprobe.dev", "hello@stripe.com"]
          }
        }
      },
      "VerifyEntry": {
        "description": "A verification result, or — when the submitted entry was not a JSON string — an `InvalidEntry` placeholder that keeps the array aligned with the input.",
        "oneOf": [
          { "$ref": "#/components/schemas/VerifyResult" },
          { "$ref": "#/components/schemas/InvalidEntry" }
        ]
      },
      "VerifyResult": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "description": "The submitted address, lower-cased and trimmed.",
            "example": "contact@mailprobe.dev"
          },
          "result": {
            "type": "string",
            "description": "Raw verdict from the verification pipeline.",
            "enum": ["deliverable", "undeliverable", "catch-all", "unknown"],
            "example": "deliverable"
          },
          "status": {
            "type": "string",
            "description": "Actionable rollup above `result` — the field most integrations filter on. `valid` = deliverable and clean; `invalid` = bad syntax, no MX, or rejected; `risky` = accepted but uncertain (catch-all, role-based, disposable, or a provider that blocks probing such as Microsoft consumer domains); `unknown` = temporarily undeterminable (network-level failure, unresolved MX, greylisting). Filtering out `risky` discards deliverable Microsoft mailboxes — score them instead.",
            "enum": ["valid", "risky", "invalid", "unknown"],
            "example": "valid"
          },
          "score": {
            "type": "integer",
            "description": "Deterministic 0-100 confidence value derived only from the signals in this response — no model, no stored data. Rough bands: 90+ deliverable and clean, ~65 probe-blocking provider (well-formed address on a live major-provider MX), ~55 catch-all, ~50 greylisted, <=15 disposable, 0 invalid. Role-based and suspected-typo addresses are penalised.",
            "minimum": 0,
            "maximum": 100,
            "example": 92
          },
          "catch_all": {
            "type": "boolean",
            "description": "The domain accepts every address, so the mailbox cannot be confirmed individually.",
            "example": false
          },
          "reason": {
            "type": "string",
            "description": "Why the verdict is not a plain `deliverable`, `null` otherwise. `accept_all` = catch-all domain; `greylisting` and `provider_blocks_probe` mean the mailbox likely exists but cannot be probed now.",
            "nullable": true,
            "enum": [
              "accept_all",
              "connection_error",
              "ehlo_rejected",
              "greylisting",
              "invalid_syntax",
              "mail_from_rejected",
              "mx_private_ip",
              "mx_unresolved",
              "no_banner",
              "no_mx",
              "provider_blocks_probe",
              "timeout",
              "unexpected_code",
              null
            ],
            "example": null
          },
          "retry_after": {
            "type": "integer",
            "description": "Suggested wait, in seconds, before re-verifying a temporarily deferred mailbox (greylisting). `null` when no retry is advised.",
            "nullable": true,
            "example": null
          },
          "smtp_code": {
            "type": "integer",
            "description": "SMTP reply code observed on the RCPT probe, `null` when no handshake took place.",
            "nullable": true,
            "example": 250
          },
          "syntax": {
            "type": "boolean",
            "description": "The address is RFC-valid.",
            "example": true
          },
          "mx_found": {
            "type": "boolean",
            "description": "The domain publishes usable MX records.",
            "example": true
          },
          "smtp_check": {
            "type": "boolean",
            "description": "An SMTP conversation actually took place (false when the server was unreachable).",
            "example": true
          },
          "disposable": {
            "type": "boolean",
            "description": "Domain listed as disposable / throwaway.",
            "example": false
          },
          "role_based": {
            "type": "boolean",
            "description": "Local part is a role alias (info, support, contact…) rather than a person.",
            "example": false
          },
          "free_provider": {
            "type": "boolean",
            "description": "Domain belongs to a free consumer mailbox provider.",
            "example": false
          },
          "did_you_mean": {
            "type": "string",
            "description": "Suggested correction for a likely typo (e.g. `gmial.com` -> `gmail.com`), `null` when the domain looks intentional.",
            "nullable": true,
            "example": null
          },
          "mx_records": {
            "type": "array",
            "description": "MX hostnames found for the domain, in priority order. Empty when none.",
            "items": { "type": "string" },
            "example": ["gmail-smtp-in.l.google.com"]
          }
        }
      },
      "InvalidEntry": {
        "type": "object",
        "description": "Returned in place of a result when the submitted entry was not a JSON string. It has no `result` field, and the credit for that slot is still consumed.",
        "required": ["error"],
        "properties": {
          "email": {
            "description": "The offending entry, echoed back as submitted — any JSON value, since only strings are verifiable."
          },
          "error": {
            "type": "string",
            "enum": ["invalid"]
          }
        }
      },
      "Credits": {
        "type": "object",
        "properties": {
          "credits": {
            "type": "integer",
            "description": "Credits remaining. `0` when the balance has expired.",
            "example": 847
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable message."
          },
          "code": {
            "type": "string",
            "description": "Stable machine-readable code. Present on every error raised by the API handlers; absent on the two responses produced by the global error handler (413 and an unexpected 500).",
            "enum": [
              "INVALID_INPUT",
              "TOO_MANY",
              "UNAUTHORIZED",
              "NO_CREDITS",
              "RATE_LIMITED",
              "INTERNAL_ERROR"
            ]
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Missing or empty `emails` array (`INVALID_INPUT`), or more than 500 entries (`TOO_MANY`). No credit is charged.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "Missing `Authorization` header, or a key that is not a valid `mp_live_...` (`UNAUTHORIZED`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NoCredits": {
        "description": "Balance too low for the whole batch, or expired (`NO_CREDITS`). The message states how many credits are needed and how many remain. Credits are never partially consumed: either the full batch is charged or nothing is.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PayloadTooLarge": {
        "description": "JSON body above 50 kB. Raised by the body parser, so this response carries no `code` field. Split the batch.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "More than 60 requests per minute for this API key (`RATE_LIMITED`). The limit is on requests, not addresses: a 500-address batch is one request. Standard `RateLimit-*` headers are returned.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "ServerError": {
        "description": "Verification failed server-side (`INTERNAL_ERROR`). The credits for the batch are refunded.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
