{
  "openapi": "3.1.0",
  "info": {
    "title": "Echoo API",
    "version": "1.0.0-draft.1",
    "summary": "One API for every Echoo service. The first service is OTP: one-time verification codes delivered over WhatsApp.",
    "description": "DRAFT for review. Nothing here is live yet.\n\nOne address, one kind of key, one error shape for every service. A key belongs to ONE profile (one website or app of the customer): the key alone tells which profile, so requests never name the profile. Live keys start with `ek_live_`, test keys with `ek_test_` (a test key never sends a message; see `x-test-mode`).",
    "termsOfService": "https://otp.echoo.services/terms",
    "contact": {
      "name": "Echoo",
      "url": "https://echoo.services",
      "email": "xstorem6@gmail.com"
    },
    "x-legal": {
      "terms": "https://otp.echoo.services/terms",
      "privacy": "https://otp.echoo.services/privacy",
      "acceptable_use": "https://otp.echoo.services/acceptable-use",
      "report_abuse": "https://otp.echoo.services/report-abuse"
    }
  },
  "servers": [
    {
      "url": "https://api.echoo.services",
      "description": "Production"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "OTP",
      "description": "Send a one-time code to a phone number and check what the person typed."
    }
  ],
  "paths": {
    "/v1/otp/send": {
      "post": {
        "tags": [
          "OTP"
        ],
        "operationId": "otpSend",
        "summary": "Send a one-time code",
        "description": "Makes a new code and queues it for delivery from one of the profile's WhatsApp numbers. The code is never returned and never stored: only a keyed hash is kept. Delivery is asynchronous: the answer says the request was accepted; its later status comes from GET /v1/otp/requests/{id} or a webhook.",
        "x-scopes": [
          "otp:send"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendRequest"
              },
              "examples": {
                "minimal": {
                  "value": {
                    "to": "+9647701234567"
                  }
                },
                "full": {
                  "value": {
                    "to": "9647701234567",
                    "locale": "ar",
                    "code_length": 6,
                    "ttl_seconds": 300,
                    "reference": "signup-58213"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted and queued.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OtpRequest"
                },
                "examples": {
                  "queued": {
                    "value": {
                      "id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
                      "status": "queued",
                      "channel": "whatsapp",
                      "to_masked": "964*******67",
                      "created_at": "2026-11-14T10:00:00Z",
                      "expires_at": "2026-11-14T10:05:00Z",
                      "verified": false,
                      "reference": "signup-58213"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/v1/otp/verify": {
      "post": {
        "tags": [
          "OTP"
        ],
        "operationId": "otpVerify",
        "summary": "Check a code",
        "description": "Checks the code the person typed. A code works once, at most 5 checks are allowed, and only before it expires. A wrong code is a normal answer (200 with valid=false and a reason), not an error. Only a key of the SAME profile that sent the code can check it.",
        "x-scopes": [
          "otp:verify"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VerifyRequest"
              },
              "examples": {
                "check": {
                  "value": {
                    "id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
                    "code": "482913"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The answer for this code.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerifyResult"
                },
                "examples": {
                  "valid": {
                    "value": {
                      "id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
                      "valid": true
                    }
                  },
                  "wrong": {
                    "value": {
                      "id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
                      "valid": false,
                      "reason": "invalid",
                      "attempts_left": 3
                    }
                  }
                }
              }
            }
          },
          "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"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/v1/otp/requests/{id}": {
      "get": {
        "tags": [
          "OTP"
        ],
        "operationId": "otpGetRequest",
        "summary": "Read one request",
        "description": "Where a request stands. Only keys of the profile that made it can read it; for any other key it does not exist (404).",
        "x-scopes": [
          "otp:read"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/RequestId"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The request.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OtpRequest"
                }
              }
            }
          },
          "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"
          }
        }
      }
    }
  },
  "webhooks": {
    "otp.sent": {
      "post": {
        "summary": "A code was handed to WhatsApp",
        "operationId": "webhookOtpSent",
        "parameters": [
          {
            "$ref": "#/components/parameters/EchooSignature"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEvent"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Received. Answer within 10 seconds."
          }
        }
      }
    },
    "otp.failed": {
      "post": {
        "summary": "A code could not be delivered",
        "operationId": "webhookOtpFailed",
        "parameters": [
          {
            "$ref": "#/components/parameters/EchooSignature"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEvent"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Received. Answer within 10 seconds."
          }
        }
      }
    },
    "otp.verified": {
      "post": {
        "summary": "A code was checked successfully",
        "operationId": "webhookOtpVerified",
        "parameters": [
          {
            "$ref": "#/components/parameters/EchooSignature"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEvent"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Received. Answer within 10 seconds."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Authorization: Bearer ek_live_... (or ek_test_...). A key belongs to one profile and carries scopes (otp:send, otp:verify, otp:read). It is shown once when made; Echoo keeps only a hash of it."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Any unique string (8 to 64 characters, for example a UUID). The same key with the same body within 24 hours returns the first answer and sends nothing new; the same key with a different body is refused (409 idempotency_conflict).",
        "schema": {
          "type": "string",
          "minLength": 8,
          "maxLength": 64,
          "pattern": "^[A-Za-z0-9_.:-]+$"
        }
      },
      "EchooSignature": {
        "name": "Echoo-Signature",
        "in": "header",
        "required": true,
        "description": "t=<unix seconds>,v1=<hex HMAC-SHA256 of '<t>.<raw body>' with the endpoint's signing secret>. Refuse it if the signature does not match or t is older than 5 minutes.",
        "schema": {
          "type": "string"
        }
      }
    },
    "headers": {
      "XRequestId": {
        "description": "An id for this HTTP call; give it to support if something looks wrong.",
        "schema": {
          "type": "string"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before trying again.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      }
    },
    "schemas": {
      "RequestId": {
        "type": "string",
        "pattern": "^otp_req_[0-9A-Za-z]{20,40}$",
        "examples": [
          "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G"
        ]
      },
      "SendRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "to"
        ],
        "properties": {
          "to": {
            "type": "string",
            "pattern": "^\\+?[1-9][0-9]{7,14}$",
            "description": "The phone number with its country code: digits, optionally starting with +. Spaces and dashes are not accepted."
          },
          "locale": {
            "type": "string",
            "enum": [
              "ar",
              "en"
            ],
            "default": "ar",
            "description": "The language of the message."
          },
          "code_length": {
            "type": "integer",
            "minimum": 4,
            "maximum": 8,
            "default": 6
          },
          "ttl_seconds": {
            "type": "integer",
            "minimum": 60,
            "maximum": 900,
            "default": 300,
            "description": "How long the code works."
          },
          "reference": {
            "type": "string",
            "maxLength": 64,
            "pattern": "^[A-Za-z0-9_.:-]*$",
            "description": "Your own id for this attempt (for example a sign-up id). Returned in the request and in webhooks. Do not put personal data in it."
          }
        }
      },
      "VerifyRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "code"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/RequestId"
          },
          "code": {
            "type": "string",
            "pattern": "^[0-9]{4,8}$"
          }
        }
      },
      "VerifyResult": {
        "type": "object",
        "required": [
          "id",
          "valid"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/RequestId"
          },
          "valid": {
            "type": "boolean"
          },
          "reason": {
            "type": "string",
            "enum": [
              "invalid",
              "expired",
              "too_many_attempts",
              "already_used",
              "not_sent",
              "not_found"
            ],
            "description": "Only when valid is false."
          },
          "attempts_left": {
            "type": "integer",
            "minimum": 0,
            "description": "Only when reason is invalid."
          }
        }
      },
      "OtpRequest": {
        "type": "object",
        "required": [
          "id",
          "status",
          "channel",
          "to_masked",
          "created_at",
          "expires_at",
          "verified"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/RequestId"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "sending",
              "sent",
              "failed",
              "expired"
            ],
            "description": "queued: waiting for a number; sending: being sent; sent: handed to WhatsApp; failed: could not be delivered (see failure_reason); expired: its time ran out before it was sent."
          },
          "failure_reason": {
            "type": "string",
            "enum": [
              "not_on_whatsapp",
              "delivery_failed",
              "timeout"
            ],
            "description": "Only when status is failed."
          },
          "channel": {
            "type": "string",
            "enum": [
              "whatsapp"
            ]
          },
          "to_masked": {
            "type": "string",
            "description": "The number with its middle hidden."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "sent_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "verified": {
            "type": "boolean"
          },
          "verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "reference": {
            "type": [
              "string",
              "null"
            ]
          },
          "test": {
            "type": "boolean",
            "description": "true when made with a test key (nothing was sent)."
          }
        }
      },
      "WebhookEvent": {
        "type": "object",
        "required": [
          "id",
          "type",
          "created_at",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9A-Za-z]{20,40}$",
            "description": "Unique per event: use it to ignore a repeated delivery."
          },
          "type": {
            "type": "string",
            "enum": [
              "otp.sent",
              "otp.failed",
              "otp.verified"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "data": {
            "$ref": "#/components/schemas/OtpRequest"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "request_id"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "A fixed code from x-error-codes: write your program against this, not against the message."
              },
              "message": {
                "type": "string",
                "description": "A sentence for a person (English)."
              },
              "retry_after": {
                "type": "integer",
                "minimum": 1,
                "description": "Seconds to wait, when waiting helps (also in the Retry-After header)."
              },
              "request_id": {
                "type": "string"
              },
              "doc_url": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request is not valid (invalid_request, invalid_json, invalid_number, invalid_ttl).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "invalid_api_key: the key is missing, wrong or revoked.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "insufficient_scope or profile_suspended.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "not_found.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "idempotency_conflict.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooLarge": {
        "description": "body_too_large.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unprocessable": {
        "description": "country_not_allowed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "rate_limited, too_soon, too_many_for_number, profile_daily_limit, plan_limit_reached or capacity_exhausted.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "server_error: something went wrong on our side.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unavailable": {
        "description": "no_number_available or unavailable.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "x-error-codes": [
    {
      "code": "invalid_request",
      "status": 400,
      "retry": false,
      "message": "The request is not valid."
    },
    {
      "code": "invalid_json",
      "status": 400,
      "retry": false,
      "message": "The body must be a JSON object."
    },
    {
      "code": "invalid_number",
      "status": 400,
      "retry": false,
      "message": "The phone number must be digits with the country code, for example +9647701234567."
    },
    {
      "code": "invalid_ttl",
      "status": 400,
      "retry": false,
      "message": "ttl_seconds must be between 60 and 900."
    },
    {
      "code": "invalid_api_key",
      "status": 401,
      "retry": false,
      "message": "The API key is missing, wrong or revoked."
    },
    {
      "code": "insufficient_scope",
      "status": 403,
      "retry": false,
      "message": "This key may not do this. Make a key with the needed scope."
    },
    {
      "code": "profile_suspended",
      "status": 403,
      "retry": false,
      "message": "This profile is paused. See your Echoo dashboard."
    },
    {
      "code": "not_found",
      "status": 404,
      "retry": false,
      "message": "No such request."
    },
    {
      "code": "method_not_allowed",
      "status": 405,
      "retry": false,
      "message": "This method is not allowed here."
    },
    {
      "code": "idempotency_conflict",
      "status": 409,
      "retry": false,
      "message": "This Idempotency-Key was used with a different request."
    },
    {
      "code": "body_too_large",
      "status": 413,
      "retry": false,
      "message": "The request body is too large."
    },
    {
      "code": "country_not_allowed",
      "status": 422,
      "retry": false,
      "message": "This profile may not send codes to numbers of that country."
    },
    {
      "code": "rate_limited",
      "status": 429,
      "retry": true,
      "message": "Too many requests with this key. Slow down."
    },
    {
      "code": "too_soon",
      "status": 429,
      "retry": true,
      "message": "A code was just sent to this number. Wait before asking again."
    },
    {
      "code": "too_many_for_number",
      "status": 429,
      "retry": true,
      "message": "Too many codes were sent to this number recently."
    },
    {
      "code": "profile_daily_limit",
      "status": 429,
      "retry": true,
      "message": "This profile reached its daily limit of codes."
    },
    {
      "code": "plan_limit_reached",
      "status": 429,
      "retry": false,
      "message": "Your plan's limit is reached. Upgrade your plan in the Echoo dashboard."
    },
    {
      "code": "capacity_exhausted",
      "status": 429,
      "retry": true,
      "message": "All numbers of this profile are at their limit right now."
    },
    {
      "code": "server_error",
      "status": 500,
      "retry": true,
      "message": "Something went wrong on our side. Try again."
    },
    {
      "code": "no_number_available",
      "status": 503,
      "retry": true,
      "message": "No WhatsApp number of this profile is connected right now."
    },
    {
      "code": "unavailable",
      "status": 503,
      "retry": true,
      "message": "The service is not reachable right now. Try again in a moment."
    }
  ],
  "x-test-mode": {
    "keys": "ek_test_...",
    "behaviour": "Nothing is sent and nothing counts against limits or plans. Every send answers 202 and the request becomes 'sent' within a second. The code for any test request is 000000; any other code is 'invalid'. Webhooks are sent as for live requests, with data.test = true."
  },
  "x-webhooks": {
    "addresses": "Added in the Echoo dashboard, inside the profile: a public https address (no user:password, no local or private network address), at most 5 per profile, each choosing which events it receives.",
    "secret": "Each address gets its own signing secret, whsec_ followed by 40 letters and digits. It is shown ONCE, when the address is added; Echoo keeps it only encrypted. To change it, add a new address and delete the old one.",
    "delivery": "POST with Content-Type application/json and the Echoo-Signature header. Any 2XX within 10 seconds counts as received; redirects are not followed. Every address of the profile that wants an event receives it with the same event id.",
    "retries": "A delivery that is not received is tried again after 30 seconds, then 1, 2, 4, 8, 16, 32, 64, 128 and 256 minutes, then 6 hours: 12 tries in all, over about 14.5 hours. After 5 failures in a row the profile's admins see an alert in the dashboard.",
    "order": "Events may arrive out of order or more than once: use the event id to ignore repeats and data.status for the latest state.",
    "test": "The dashboard can send a signed test event (otp.sent with data.test = true) to an address at any time."
  },
  "x-limits": {
    "note": "Starting values; the service may lower them for safety, and they may change after measurement.",
    "per_key_per_minute": 60,
    "per_recipient": "one code every 30 seconds and 3 an hour",
    "body_bytes": 4096
  },
  "x-versioning": "v1 never changes in a breaking way: fields and error codes may be ADDED, never removed or renamed. A breaking change becomes /v2, announced in advance, and both run side by side for a period."
}
