{
  "openapi": "3.1.0",
  "info": {
    "title": "MailOtp API",
    "version": "1.0.0",
    "description": "Email OTP infrastructure. Rent a mailbox for a service, receive the verification code via a fast REST API. Charged up front per service; refunded automatically if no code arrives within the service window. Only mail that arrives after the task is created counts: create the task (or call /again) first, then request the code — earlier mail is ignored. Authenticate with Authorization: Bearer <key> (or X-API-Key). Every error body is the same shape: {\"error\":{\"code\",\"message\",\"request_id\"}} — branch on code, not on the message text."
  },
  "servers": [
    {
      "url": "https://api.mailotp.org"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyHeader": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your API key: Authorization: Bearer mo_live_..."
      },
      "apiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Alternative to Authorization: Bearer — send the same key as X-API-Key."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string"
              },
              "message": {
                "type": "string"
              },
              "request_id": {
                "type": "string"
              }
            }
          }
        }
      },
      "Task": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "waiting",
              "received",
              "cancelled",
              "timeout",
              "failed"
            ]
          },
          "service": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "price": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "cancellable_at": {
            "type": "string",
            "format": "date-time"
          },
          "result": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Present ONLY when the mail carried one. Empty values are omitted, not sent as null — read it with a presence check."
              },
              "link": {
                "type": "string",
                "description": "Present ONLY when the mail carried one. Empty values are omitted, not sent as null — read it with a presence check."
              },
              "from": {
                "type": "string"
              },
              "subject": {
                "type": "string"
              },
              "received_at": {
                "type": "string",
                "format": "date-time"
              },
              "confidence": {
                "type": "string",
                "enum": [
                  "high",
                  "medium",
                  "low"
                ]
              }
            },
            "description": "null until a mail lands; then the extracted result.",
            "required": [
              "from",
              "subject",
              "received_at",
              "confidence"
            ]
          }
        },
        "required": [
          "id",
          "status",
          "service",
          "email",
          "price",
          "created_at",
          "expires_at",
          "cancellable_at",
          "result"
        ]
      },
      "Me": {
        "type": "object",
        "properties": {
          "customer_id": {
            "type": "string",
            "format": "uuid"
          },
          "balance": {
            "type": "string",
            "description": "USD, decimal string (e.g. \"12.000000\")"
          },
          "currency": {
            "type": "string",
            "examples": [
              "USD"
            ]
          },
          "discount_pct": {
            "type": "string",
            "description": "Percent off the list price, decimal string"
          },
          "key": {
            "type": "object",
            "properties": {
              "prefix": {
                "type": "string",
                "description": "First 12 characters of the key, e.g. mo_live_kiAt"
              },
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "read",
                    "tasks:write"
                  ]
                }
              },
              "rate_limit_rps": {
                "type": "integer",
                "description": "Per-second read limit on this key. 0 = no limit (the default). See the abuse pause below."
              },
              "create_limit_rps": {
                "type": "integer",
                "description": "Per-second task-creation limit on this key. 0 = no limit (the default)."
              }
            }
          },
          "pause": {
            "type": "object",
            "description": "Failure pause status. There is no requests-per-second limit: more than fail_limit_per_minute failed responses (HTTP 400 and above, except 429) within one UTC calendar minute pause every key of the account (10 min, then 30 min, 1 hour, 24 hours on repeats within 24 hours). Successful calls never count. GET /v1/me keeps answering during a pause, so paused_until can be read.",
            "properties": {
              "fail_limit_per_minute": {
                "type": "integer",
                "description": "Failed responses allowed per UTC minute before the account is paused. 0 = pausing is off for this account."
              },
              "failures_this_minute": {
                "type": "integer",
                "description": "Failed responses counted so far in the current UTC minute, across all keys of the account."
              },
              "window_resets_at": {
                "type": "string",
                "format": "date-time",
                "description": "When the failure counter resets (start of the next UTC minute)."
              },
              "paused_until": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "End of the active pause; null when the account is not paused. While paused every other call returns 429 api_paused with Retry-After."
              }
            },
            "required": [
              "fail_limit_per_minute",
              "failures_this_minute",
              "window_resets_at",
              "paused_until"
            ]
          }
        },
        "required": [
          "customer_id",
          "balance",
          "currency",
          "discount_pct",
          "key",
          "pause"
        ]
      },
      "Service": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "price": {
            "type": "string",
            "description": "USD per delivered code, decimal string"
          },
          "ttl_sec": {
            "type": "integer",
            "description": "Delivery window for this service, in seconds. A task stays open this long unless you override it with `ttl` on POST /v1/tasks."
          },
          "code_format": {
            "type": "string",
            "description": "Expected code shape, e.g. \"6 digits\""
          }
        }
      },
      "Domain": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string"
          },
          "provider": {
            "type": "string"
          },
          "ready_count": {
            "type": "integer",
            "description": "Addresses ready right now. Counts only what the `service` query parameter allows when it is given; otherwise the whole ready pool."
          }
        },
        "required": [
          "domain",
          "provider",
          "ready_count"
        ]
      },
      "Message": {
        "type": "object",
        "properties": {
          "task_id": {
            "type": "string",
            "format": "uuid"
          },
          "from": {
            "type": "string"
          },
          "subject": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "description": "Present only when the mail carried one; omitted otherwise."
          },
          "link": {
            "type": "string",
            "description": "Present only when the mail carried one; omitted otherwise."
          },
          "received_at": {
            "type": "string",
            "format": "date-time"
          },
          "folder": {
            "type": "string",
            "enum": [
              "inbox",
              "junk"
            ]
          },
          "body_text": {
            "type": "string",
            "description": "Plain-text body; emptied after the retention window"
          }
        },
        "required": [
          "task_id",
          "from",
          "subject",
          "received_at",
          "folder"
        ]
      },
      "TaskList": {
        "type": "object",
        "properties": {
          "tasks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Task"
            }
          }
        }
      },
      "ServiceList": {
        "type": "object",
        "properties": {
          "services": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Service"
            }
          }
        }
      },
      "DomainList": {
        "type": "object",
        "properties": {
          "domains": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Domain"
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "validation_error — missing or malformed field, unknown or inactive service, a JSON body with unknown keys, or a task id that is not a UUID.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "unauthorized — the key is missing, malformed, revoked, or the account is suspended. Revoking a key takes effect within 30 seconds.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "forbidden — the key lacks the required scope, or the caller's IP is not on the key's allowlist.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "not_found — no such task for this key's account. Tasks are scoped to the account that created them; another account's task id also returns 404.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "api_paused — more than 500 failed requests in one minute (the threshold is configurable) pauses the account's API access (10 min, then 30 min, 1 hour, 24 hours on repeats); only an admin can lift it early. Retry-After gives the seconds left. GET /v1/me keeps answering during a pause and shows pause.paused_until, the threshold and the failures counted in the current minute. rate_limited is returned instead when a per-second limit has been set on the key (off by default).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "internal — unexpected failure on our side. Retry; quote request_id when reporting it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/v1/me": {
      "get": {
        "summary": "Account balance, limits and key info",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/services": {
      "get": {
        "summary": "Service catalog with price and TTL",
        "responses": {
          "200": {
            "description": "Active services and their prices",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServiceList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/domains": {
      "get": {
        "summary": "Available domains and ready mailbox stock",
        "description": "Ready address counts per mailbox domain. Pass `service` to get the count that can actually be handed to that service: an address is closed for a service only once it has received a code there, and stays usable for every other service. Without `service` the count is the whole ready pool, which is higher than what any one service can still use.",
        "parameters": [
          {
            "name": "service",
            "in": "query",
            "required": false,
            "description": "Service slug, e.g. `instagram`. Unknown or inactive slug returns 400.",
            "schema": {
              "type": "string"
            },
            "example": "instagram"
          }
        ],
        "responses": {
          "200": {
            "description": "Mailbox domains and current stock",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DomainList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/tasks": {
      "post": {
        "summary": "Create a task (rent a mailbox, charge up front)",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "service"
                ],
                "properties": {
                  "service": {
                    "type": "string",
                    "description": "Service slug, e.g. instagram"
                  },
                  "domain": {
                    "type": "string",
                    "description": "Optional: restrict to a domain"
                  },
                  "ttl": {
                    "type": "integer",
                    "description": "Optional delivery window in seconds. Omit to use the service's own `ttl_sec` (see GET /v1/services). Values are clamped to 60-900: below 60 is raised to 60, above the configured maximum (900) is lowered. When the window closes the task becomes `timeout` and the held price is refunded automatically.",
                    "minimum": 60,
                    "maximum": 900
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Task created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "insufficient_balance",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "no_stock",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "503": {
            "description": "maintenance (task creation paused)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "summary": "List your tasks",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Most recent tasks, newest first",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/tasks/{id}": {
      "get": {
        "summary": "Get task status (long-poll with ?wait=1..30)",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Task id (UUID). Any other shape returns 400 validation_error."
          },
          {
            "name": "wait",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 30
            },
            "description": "Block up to N seconds until the status changes"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "not_found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/tasks/{id}/stream": {
      "get": {
        "summary": "Server-Sent Events stream of task status",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Task id (UUID). Any other shape returns 400 validation_error."
          }
        ],
        "responses": {
          "200": {
            "description": "Server-sent events. Emits `status` on every change and `result` when the code lands, then closes. Falls back to a 15-second `ping` comment to keep the connection open.",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/tasks/{id}/message": {
      "get": {
        "summary": "Full delivered message (code, link, subject, from, body text)",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Task id (UUID). Any other shape returns 400 validation_error."
          }
        ],
        "responses": {
          "200": {
            "description": "The delivered message, including the plain-text body",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "not_found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/tasks/{id}/cancel": {
      "post": {
        "summary": "Cancel a waiting task (after cancellable_at) and refund",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Task id (UUID). Any other shape returns 400 validation_error."
          }
        ],
        "responses": {
          "200": {
            "description": "Cancelled and refunded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "cancel_too_early — the task is still inside its no-cancel window; or conflict — the task is no longer in a cancellable state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/tasks/{id}/again": {
      "post": {
        "summary": "Request another code on the same address: creates a new task pinned to the source task's mailbox and address (charged again). Source must be received, timeout or cancelled. Only mail that arrives after this call counts — request the code afterwards. If one of your own waiting tasks for the same service already holds that address, that task is returned with 200 instead of a new one (no new charge).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Task id (UUID). Any other shape returns 400 validation_error."
          }
        ],
        "responses": {
          "200": {
            "description": "Your own waiting task for the same service already holds this address; it is returned instead of creating a new one (no new charge). The next mail to the address goes to it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "201": {
            "description": "New task created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "insufficient_balance",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "not_found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "mailbox_busy (the address is serving a task other than your own waiting task for this service, has reached its daily limit or is unavailable) or not_repeatable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "503": {
            "description": "maintenance",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  }
}
