{
  "openapi": "3.1.0",
  "info": {
    "title": "JuicySMS API",
    "version": "2026-08-09",
    "summary": "Buy disposable phone numbers, read the verification codes sent to them, and rent numbers long term.",
    "description": "API v2 is a JSON API. It runs alongside the frozen plain-text API under `/api/*`, which is unchanged and unaffected by anything described here.\n\n**Authentication.** `Authorization: Bearer <key>`, never a query parameter. Two credentials are accepted: a v2 API key (scoped, expirable, revocable, stored hashed) and your legacy `users.api_token`. The legacy token is accepted so existing integrations can point at v2 without minting anything, and it is treated as holding every scope — but it is the weaker credential (32 hex characters, one per account, stored in plaintext, rotatable but not individually revocable) and it should be replaced with a v2 key.\n\n**Scopes.** Every operation declares the scope it needs in `x-required-scope`. A key holding `*` passes everything. A key that is missing the scope gets `403 insufficient_scope`, and the problem document names both the required and the granted scopes. `GET /account` echoes the scopes of the presented key, which is the cheapest way to check a key without provoking a 403 somewhere else.\n\n**Errors.** Every failure is an RFC 9457 `application/problem+json` document with a stable machine-readable `code`. Branch on `code`, not on the HTTP status and not on `detail` — the status is derived from the code, one code always means one status, and `detail` is prose that may be reworded. `retryable` tells you whether repeating the identical request can plausibly succeed later, so you do not have to keep your own table of which of our failures are transient. Codes may be added over time; treat an unknown code as its HTTP status class.\n\n`internal_error` (500) and `maintenance` (503) can come back from any operation and are listed only where an operation has real work to fail at, so treat them as universal rather than reading anything into their absence. Everything else listed on an operation is a failure that operation can actually reach, and no operation can produce a code that is not listed on it.\n\n**Money.** Every amount is an object: `{\"amount\": \"0.50\", \"amount_minor\": 50, \"currency\": \"EUR\"}`. Never a float. `amount_minor` is the exact integer in cents; `amount` is the same value as an exact decimal string. Everything is denominated in EUR and is never converted — the website's display-currency conversion runs off a stale rate table and exposing it here would hand you wrong numbers.\n\n**Timestamps** are ISO 8601 in UTC with a `Z` suffix. **Booleans** are real JSON booleans.\n\n**Shapes.** A single resource is returned bare. A collection is always `{\"data\": [...]}` plus either a `pagination` block or endpoint-specific metadata — never a top-level array, so members can be added without breaking clients. Every resource carries an `object` discriminator.\n\n**Pagination.** Large collections are cursor-paginated: pass `limit` and the `next_cursor` from the previous page. Cursors are opaque; do not construct or parse them. Offset pagination is not offered — `requests` is past eight million rows and an offset page both costs more the deeper you go and shifts under you as new rows arrive.\n\n**Rate limits.** Per credential (per IP when unauthenticated), per minute: 240 reads, 60 writes, 30 order-placing calls, 30 unauthenticated calls. Ordering has its own tighter bucket because each call can allocate scarce stock. Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds, not an epoch) and `RateLimit-Policy`, plus Laravel's `X-RateLimit-*` equivalents.",
    "contact": {
      "name": "JuicySMS support",
      "url": "https://juicysms.com/contact"
    },
    "x-api-version-header": "X-Api-Version"
  },
  "externalDocs": {
    "description": "Developer documentation",
    "url": "https://juicysms.com/api"
  },
  "servers": [
    {
      "url": "https://juicysms.com/api/v2",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Discovery",
      "description": "Unauthenticated endpoints for finding out what this API is and whether it is up."
    },
    {
      "name": "Account",
      "description": "The caller's own account and balance."
    },
    {
      "name": "Catalog",
      "description": "What is on sale, where, and at what price for this account."
    },
    {
      "name": "Orders",
      "description": "One-time verification numbers. You are charged only if a message arrives."
    },
    {
      "name": "Rentals",
      "description": "Long-term number rentals, charged up front per package."
    },
    {
      "name": "Webhooks",
      "description": "Push delivery of events, so you do not have to poll for messages."
    }
  ],
  "paths": {
    "/": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getApiIndex",
        "summary": "Describe the API",
        "description": "A signpost, not a spec: the version this deployment is running, where the machine-readable contract and the prose documentation live, the settlement currency, and how to authenticate. Anything more detailed is in the OpenAPI document it points at.\n\nThis response and the OpenAPI document are the only two in the API that are cacheable — `Cache-Control: max-age=300, public`. Both are identical for every caller and carry no account data. Everything else is `no-store, private`, because it is per-caller and often contains a phone number or a verification code.\n\nPublic: no API key, no scope. Throttled by IP at 30 requests per minute, a different and much smaller bucket than the authenticated ones.",
        "security": [],
        "x-required-scope": null,
        "responses": {
          "200": {
            "description": "API metadata.",
            "headers": {
              "Cache-Control": {
                "description": "`max-age=300, public`. This endpoint deliberately opts out of the `no-store, private` applied to every other response, and is the only place besides the OpenAPI document that does.",
                "schema": {
                  "type": "string"
                },
                "example": "max-age=300, public"
              },
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Discovery"
                },
                "example": {
                  "object": "api",
                  "name": "JuicySMS API",
                  "version": "2026-08-09",
                  "documentation_url": "https://juicysms.com/api",
                  "openapi_url": "https://juicysms.com/api/v2/openapi.json",
                  "currency": "EUR",
                  "authentication": {
                    "scheme": "Bearer",
                    "header": "Authorization: Bearer <api_key>",
                    "note": "The 32-character key on your account page works as a bearer token. Keys are never accepted in the query string."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getHealth",
        "summary": "Liveness check",
        "description": "Answers from memory: no database, no cache, no queue. That is deliberate — a probe that queries a dependency reports that dependency's health rather than the API's, so one slow query becomes a false outage and every uptime checker in the world responds by retrying into the thing already struggling. A 200 here means only that a web worker accepted your request.\n\nIt is therefore not a stock check either: a healthy API still answers `out_of_stock` when there is no SIM to sell you. And it says nothing about your account, because it does not know who you are.\n\nPublic: no API key, no scope. Throttled by IP at 30 requests per minute; polling it faster than that earns a 429 that tells you nothing about our health.",
        "security": [],
        "x-required-scope": null,
        "responses": {
          "200": {
            "description": "The API is up.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "status": "ok",
                  "time": "2026-08-09T09:14:22Z"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "The API is deliberately out of service for maintenance. Retry after the interval in `Retry-After`.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "maintenance"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#maintenance",
                  "title": "Temporarily unavailable for maintenance",
                  "status": 503,
                  "code": "maintenance",
                  "detail": "The API is temporarily unavailable for maintenance.",
                  "retryable": true
                }
              }
            }
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getOpenApiDocument",
        "summary": "Fetch this OpenAPI document",
        "description": "Serves this document byte for byte off disk — it is not re-serialized per request, so what you generate a client from is the exact artifact that was tested and published, with no re-escaping and no `1.0` quietly becoming `1`.\n\nGenerate from this URL rather than from a copy: it describes the deployment you are actually calling, and `info.version` here is the same string the live API returns in `X-Api-Version`, so a mismatch between the two is how you find out your generated client is stale.\n\nCached for five minutes (`public, max-age=300`) so a deploy that ships a new spec is picked up without anyone purging anything.\n\nPublic: no API key, no scope. Throttled by IP at 30 requests per minute.",
        "security": [],
        "x-required-scope": null,
        "responses": {
          "200": {
            "description": "The OpenAPI 3.1 description of this API.",
            "headers": {
              "Cache-Control": {
                "description": "`max-age=300, public`.",
                "schema": {
                  "type": "string"
                },
                "example": "max-age=300, public"
              },
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "An OpenAPI 3.1 document.",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "`not_found`: this deployment was built without the spec artifact. The prose documentation is still available at the URL in `detail`.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "not_found"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#not_found",
                  "title": "Not found",
                  "status": 404,
                  "code": "not_found",
                  "detail": "The OpenAPI document is not available on this deployment. The prose documentation is at https://juicysms.com/api.",
                  "retryable": false
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/account": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getAccount",
        "summary": "Retrieve the authenticated account",
        "description": "The account behind the presented key, its spendable balance, and the scopes that key holds.\n\n`balance` is the sum of the three balance pots the order path spends from, in the order it spends them: referral balance, then purchased balance, then top-up balance. There is no field per pot, because which pot a charge lands in is a billing detail and not something an integration can act on.\n\n`parallel_orders_allowed` is worth reading before you build a queue. When it is `false` — the default — a second `POST /orders` while an order of yours is still open is rejected with `concurrent_order_limit`, and the only way to discover that limit otherwise is to trip over it.\n\n`scopes` is what the presented key actually holds. A legacy `users.api_token` reports `[\"*\"]` verbatim rather than being expanded into the full list, because that key genuinely is unscoped.\n\nRequires scope `account:read`.",
        "x-required-scope": "account:read",
        "responses": {
          "200": {
            "description": "The account.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                },
                "example": {
                  "object": "account",
                  "id": 48213,
                  "email": "dev@example.com",
                  "balance": {
                    "amount": "12.40",
                    "amount_minor": 1240,
                    "currency": "EUR"
                  },
                  "parallel_orders_allowed": false,
                  "scopes": [
                    "orders:read",
                    "orders:write",
                    "account:read"
                  ],
                  "created_at": "2025-11-02T18:41:07Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/services": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "operationId": "listServices",
        "summary": "List services and their prices",
        "description": "The whole active catalogue — around 520 rows — unpaginated, because a client caches it once and then orders against it, and paging a set that fits in one payload would only add round trips.\n\nThe same service costs a different amount in every country, so `price` is only populated when you pass `country`. Without it every `price` is `null` and `prices_available` is `false`; that flag exists so a client cannot read those nulls as \"free\". The price quoted is the price that will be taken from your balance, including any per-account override negotiated with support — it is not the list price.\n\nA `country` we do not recognise is `422 country_not_supported`, not a silently substituted default. A typo like `?country=USA1` is rejected rather than quietly priced as some other country.\n\nRequires scope `services:read`.",
        "x-required-scope": "services:read",
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "Price the catalogue for this country. Accepts the storage code (`USA`, `UK`, `NL`, `PH`), the ISO code (`GB`, `US`), or the English name, in any casing. Omit to list the catalogue unpriced.",
            "schema": {
              "type": "string",
              "maxLength": 32
            },
            "example": "NL"
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match against the service name. Matches anywhere in the name, not just the start.",
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "example": "whats"
          }
        ],
        "responses": {
          "200": {
            "description": "The active catalogue.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "count",
                    "prices_available"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Service"
                      }
                    },
                    "count": {
                      "type": "integer",
                      "description": "Number of services in `data`. The set is not paginated, so this is the whole match."
                    },
                    "country": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The country the prices are quoted in, or null when none was supplied."
                    },
                    "prices_available": {
                      "type": "boolean",
                      "description": "False when `country` was omitted, in which case every `price` is null because there is nothing to quote."
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "service",
                      "id": 12,
                      "name": "WhatsApp",
                      "slug": "whatsapp",
                      "price": {
                        "amount": "0.50",
                        "amount_minor": 50,
                        "currency": "EUR"
                      },
                      "country": "NL"
                    },
                    {
                      "object": "service",
                      "id": 41,
                      "name": "Telegram",
                      "slug": "telegram",
                      "price": {
                        "amount": "0.65",
                        "amount_minor": 65,
                        "currency": "EUR"
                      },
                      "country": "NL"
                    }
                  ],
                  "count": 2,
                  "country": "NL",
                  "prices_available": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "`country_not_supported` when `country` names a country we do not sell in; `validation_failed` when a parameter is the wrong shape (`country` longer than 32 characters, `search` longer than 100).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "country_not_supported",
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "country_not_supported": {
                    "summary": "Unknown country",
                    "value": {
                      "type": "https://juicysms.com/api/errors#country_not_supported",
                      "title": "Country not supported",
                      "status": 422,
                      "code": "country_not_supported",
                      "detail": "Unknown country `USA1`. Use one of USA, UK, NL, PH.",
                      "retryable": false,
                      "country": "USA1"
                    }
                  },
                  "validation_failed": {
                    "summary": "Parameter out of range",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "The request payload failed validation. See `errors` for the offending fields.",
                      "retryable": false,
                      "errors": {
                        "search": [
                          "The search field must not be greater than 100 characters."
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/orders": {
      "get": {
        "tags": [
          "Orders"
        ],
        "operationId": "listOrders",
        "summary": "List your orders",
        "description": "Your orders, newest first, cursor-paginated.\n\nList items deliberately omit `messages` and `code`: including them would mean one extra query per row. Fetch a single order, or its `/messages` sub-resource, when you want the content.\n\nOrdering is by id rather than by `created_at`, and the two can only disagree by microseconds, but it is why there is no `sort` parameter: `requests` is past eight million rows with an index on `user_id` and none on `created_at`, so id ordering is a backward index scan and anything else is a filesort.\n\n`status` is derived, not stored, and `completed` outranks `canceled` — an order that received its code and was then skipped reports `completed`, because the customer got the code and was charged for it.\n\nRequires scope `orders:read`.",
        "x-required-scope": "orders:read",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only orders in this state.",
            "schema": {
              "$ref": "#/components/schemas/OrderStatus"
            },
            "example": "completed"
          },
          {
            "name": "service_id",
            "in": "query",
            "required": false,
            "description": "Only orders for this service. See `GET /services`.",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "example": 12
          },
          {
            "name": "created_after",
            "in": "query",
            "required": false,
            "description": "Only orders created at or after this instant. Any parseable date-time; send ISO 8601 with an offset to avoid ambiguity.",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "example": "2026-08-01T00:00:00Z"
          },
          {
            "name": "created_before",
            "in": "query",
            "required": false,
            "description": "Only orders created at or before this instant.",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "example": "2026-08-09T00:00:00Z"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of orders.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Order"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/CursorPagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "order",
                      "id": 8123991,
                      "status": "completed",
                      "service": {
                        "id": 12,
                        "name": "WhatsApp"
                      },
                      "country": "NL",
                      "country_iso": "NL",
                      "phone_number": "+31686216609",
                      "phone_number_local": "686216609",
                      "reused_from_order_id": null,
                      "created_at": "2026-08-09T09:02:11Z",
                      "expires_at": "2026-08-09T09:12:11Z"
                    },
                    {
                      "object": "order",
                      "id": 8123974,
                      "status": "expired",
                      "service": {
                        "id": 41,
                        "name": "Telegram"
                      },
                      "country": "UK",
                      "country_iso": "GB",
                      "phone_number": "+447407792510",
                      "phone_number_local": "7407792510",
                      "reused_from_order_id": null,
                      "created_at": "2026-08-09T08:41:03Z",
                      "expires_at": "2026-08-09T08:51:03Z"
                    }
                  ],
                  "pagination": {
                    "limit": 25,
                    "has_more": true,
                    "next_cursor": "eyJpZCI6ODEyMzk3NCwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ",
                    "previous_cursor": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "A filter is the wrong shape: an unknown `status`, a `limit` above 100, an unparseable date.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#validation_failed",
                  "title": "Validation failed",
                  "status": 422,
                  "code": "validation_failed",
                  "detail": "The request payload failed validation. See `errors` for the offending fields.",
                  "retryable": false,
                  "errors": {
                    "limit": [
                      "The limit field must not be greater than 100."
                    ]
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "tags": [
          "Orders"
        ],
        "operationId": "createOrder",
        "summary": "Order a one-time number",
        "description": "Allocates a number for one service in one country and opens a ten-minute window for a message to arrive.\n\n**You are not charged here.** Money moves only when a message is delivered, so an order that expires unused is free. The `price` on the response is what will be taken if the order completes, at your account's effective price including any negotiated override — it is quoted up front precisely so that a client can refuse it. `max_price` is the machine-readable form of refusing it: if the current price exceeds the ceiling you send, nothing is allocated and you get `422 price_above_maximum` with both figures — 422 and not 402, because the ceiling is a constraint you imposed, not a statement about your balance. Without it, an integration that has been running for a year silently accepts whatever the price has become.\n\nUnless `parallel_orders_allowed` is true on your account, you may hold only one open order at a time; a second attempt is `409 concurrent_order_limit` and names the order that is in the way.\n\nAn unknown country, or one we do not sell one-time (`USA`, `UK`, `NL` and `PH`), is rejected as `validation_failed` with the offending field, not as `country_not_supported` — the check happens during request validation, before the ordering logic runs. An unrecognised country is rejected; it is never substituted for another one.\n\nStock is genuinely finite and per (service, country); `out_of_stock` is normal and retryable. A per-account or per-service purchase block set by support is also reported as `out_of_stock`, as is Poland while its soft launch is closed for you — we do not disclose that inventory exists but is being withheld from you.\n\nRequires scope `orders:write`. Uses the order bucket: 30 requests per minute.",
        "x-required-scope": "orders:write",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateOrderRequest"
              },
              "examples": {
                "minimal": {
                  "summary": "Order a Dutch WhatsApp number",
                  "value": {
                    "service_id": 12,
                    "country": "NL"
                  }
                },
                "with_price_ceiling": {
                  "summary": "Refuse the order if the price has moved above 0.75",
                  "value": {
                    "service_id": 12,
                    "country": "NL",
                    "max_price": "0.75"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The number is yours for ten minutes. Poll `GET /orders/{order}/messages`, or register a webhook endpoint and stop polling.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Order"
                },
                "example": {
                  "object": "order",
                  "id": 8124007,
                  "status": "pending",
                  "service": {
                    "id": 12,
                    "name": "WhatsApp"
                  },
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "reused_from_order_id": null,
                  "created_at": "2026-08-09T09:20:44Z",
                  "expires_at": "2026-08-09T09:30:44Z",
                  "code": null,
                  "messages": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance`: the account's money does not cover the price. The problem document carries both figures as extension members so you do not have to re-fetch them. A `max_price` rejection is NOT here — that is a constraint you put on your own request, not a statement about your balance, and it answers 422.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_balance"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#insufficient_balance",
                  "title": "Insufficient balance",
                  "status": 402,
                  "code": "insufficient_balance",
                  "detail": "Your balance does not cover this order.",
                  "retryable": false,
                  "price": {
                    "amount": "0.50",
                    "amount_minor": 50,
                    "currency": "EUR"
                  },
                  "balance": {
                    "amount": "0.12",
                    "amount_minor": 12,
                    "currency": "EUR"
                  }
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` when the key lacks `orders:write`; `account_restricted` when the account may not place orders at all.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_scope",
                            "account_restricted"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#account_restricted",
                  "title": "Account restricted",
                  "status": 403,
                  "code": "account_restricted",
                  "detail": "This account cannot place orders. Contact support.",
                  "retryable": false
                }
              }
            }
          },
          "404": {
            "description": "No service has the `service_id` you sent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "service_not_found"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#service_not_found",
                  "title": "Service not found",
                  "status": 404,
                  "code": "service_not_found",
                  "detail": "No service with id 999999.",
                  "retryable": false
                }
              }
            }
          },
          "409": {
            "description": "",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "out_of_stock",
                            "concurrent_order_limit"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "out_of_stock": {
                    "summary": "No number available",
                    "value": {
                      "type": "https://juicysms.com/api/errors#out_of_stock",
                      "title": "No numbers available",
                      "status": 409,
                      "code": "out_of_stock",
                      "detail": "No Netherlands number is available for WhatsApp right now.",
                      "retryable": true,
                      "service_id": 12,
                      "country": "NL"
                    }
                  },
                  "concurrent_order_limit": {
                    "summary": "An order is already open",
                    "value": {
                      "type": "https://juicysms.com/api/errors#concurrent_order_limit",
                      "title": "An order is already open",
                      "status": 409,
                      "code": "concurrent_order_limit",
                      "detail": "Finish, cancel or wait out your open order before placing another.",
                      "retryable": false,
                      "order_id": 8124007
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "The request was refused on its own terms. `price_above_maximum` when the payload was fine but the current price exceeds the `max_price` you set: nothing was allocated and nothing will be charged, and both figures are on the document.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "validation_failed",
                            "price_above_maximum"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "validation_failed": {
                    "summary": "Payload rejected",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "The request payload failed validation. See `errors` for the offending fields.",
                      "retryable": false,
                      "errors": {
                        "country": [
                          "Unknown country. One-time numbers are available for USA, UK, NL and PH."
                        ]
                      }
                    }
                  },
                  "price_above_maximum": {
                    "summary": "Price moved above the ceiling",
                    "value": {
                      "type": "https://juicysms.com/api/errors#price_above_maximum",
                      "title": "Price above maximum",
                      "status": 422,
                      "code": "price_above_maximum",
                      "detail": "The current price for this service is above the max_price you supplied.",
                      "retryable": false,
                      "price": {
                        "amount": "0.80",
                        "amount_minor": 80,
                        "currency": "EUR"
                      },
                      "max_price": {
                        "amount": "0.75",
                        "amount_minor": 75,
                        "currency": "EUR"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "`service_busy`: another order for the same service and country is being allocated and we would not serialise correctly behind it. Retry immediately; the lock is held for at most ten seconds.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "service_busy"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#service_busy",
                  "title": "Service temporarily busy",
                  "status": 503,
                  "code": "service_busy",
                  "detail": "Another order for this service and country is being placed. Retry in a moment.",
                  "retryable": true
                }
              }
            }
          }
        }
      }
    },
    "/orders/{order}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrderId"
        }
      ],
      "get": {
        "tags": [
          "Orders"
        ],
        "operationId": "getOrder",
        "summary": "Retrieve an order",
        "description": "One order, with its messages and the extracted `code` inlined.\n\nAn order belonging to another account answers `404 order_not_found`, identically to an id that does not exist. That is deliberate: distinguishing the two would turn this endpoint into an enumeration oracle for other customers' order ids.\n\n`price` is null when the country behind the number can no longer be determined — pool rows are purged — because a price we cannot compute is worse than a wrong one.\n\nRequires scope `orders:read`.",
        "x-required-scope": "orders:read",
        "responses": {
          "200": {
            "description": "The order.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Order"
                },
                "example": {
                  "object": "order",
                  "id": 8124007,
                  "status": "completed",
                  "service": {
                    "id": 12,
                    "name": "WhatsApp"
                  },
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "reused_from_order_id": null,
                  "created_at": "2026-08-09T09:20:44Z",
                  "expires_at": "2026-08-09T09:30:44Z",
                  "code": "382914",
                  "messages": [
                    {
                      "object": "message",
                      "id": 55210394,
                      "sender": "WhatsApp",
                      "text": "Your WhatsApp code: 382-914. Don't share this code with others.",
                      "code": "382914",
                      "received_at": "2026-08-09T09:21:58Z",
                      "source": "sms"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/OrderNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/orders/{order}/messages": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrderId"
        }
      ],
      "get": {
        "tags": [
          "Orders"
        ],
        "operationId": "listOrderMessages",
        "summary": "Read the messages on an order",
        "description": "The polling endpoint. Messages are oldest first; an empty `data` with `order_status` still `pending` means keep waiting, and once `order_status` is terminal (`completed`, `canceled`, `expired`) no further message will ever appear and you should stop.\n\n`order_status` is returned alongside `data` precisely so that one poll answers both \"did anything arrive?\" and \"is it still worth asking?\" without a second request.\n\nA sensible poll is every two to three seconds within the ten-minute window; the read bucket allows 240 requests a minute across your whole integration, which one aggressive loop can exhaust on its own. Registering a webhook endpoint removes the need entirely.\n\n`code` on each message is a best-effort extraction — a single run of 4-8 digits, optionally hyphenated as Google formats theirs. It is null when the body has no clear candidate or has several, because a wrong code is worse than no code. `text` is always the full body.\n\n`source` distinguishes a message we hold as its own row (`sms`, with a `sender`) from one a third-party provider handed us as a bare body (`provider`, no sender, no id).\n\nRequires scope `orders:read`.",
        "x-required-scope": "orders:read",
        "responses": {
          "200": {
            "description": "Every message received on this order so far, plus the order's current status.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "order_id",
                    "order_status"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Message"
                      }
                    },
                    "order_id": {
                      "type": "integer"
                    },
                    "order_status": {
                      "$ref": "#/components/schemas/OrderStatus"
                    }
                  }
                },
                "examples": {
                  "still_waiting": {
                    "summary": "Nothing yet — keep polling",
                    "value": {
                      "data": [],
                      "order_id": 8124007,
                      "order_status": "pending"
                    }
                  },
                  "delivered": {
                    "summary": "Code arrived",
                    "value": {
                      "data": [
                        {
                          "object": "message",
                          "id": 55210394,
                          "sender": "WhatsApp",
                          "text": "Your WhatsApp code: 382-914. Don't share this code with others.",
                          "code": "382914",
                          "received_at": "2026-08-09T09:21:58Z",
                          "source": "sms"
                        }
                      ],
                      "order_id": 8124007,
                      "order_status": "completed"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/OrderNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/orders/{order}/cancel": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrderId"
        }
      ],
      "post": {
        "tags": [
          "Orders"
        ],
        "operationId": "cancelOrder",
        "summary": "Cancel an order",
        "description": "Releases the number without a charge and closes the order immediately, instead of leaving it to expire on its own. For provider-served orders we also cancel upstream, so we stop paying for a number you are not using.\n\nCancel as soon as you know you do not need the number: while an order is open it counts against your one-open-order limit, and the number is out of stock for everyone else.\n\nOnly a `pending` order can be cancelled. Anything else — already completed, already cancelled, already expired — is `409 order_not_open` and names the state it is actually in. Cancelling after the code has arrived does not refund it: `completed` is a charge that has already happened.\n\nRequires scope `orders:write`.",
        "x-required-scope": "orders:write",
        "responses": {
          "200": {
            "description": "The order, now `canceled`. `messages` is empty on this response by construction — it is not a claim that none arrived.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Order"
                },
                "example": {
                  "object": "order",
                  "id": 8124007,
                  "status": "canceled",
                  "service": {
                    "id": 12,
                    "name": "WhatsApp"
                  },
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "reused_from_order_id": null,
                  "created_at": "2026-08-09T09:20:44Z",
                  "expires_at": "2026-08-09T09:22:03Z",
                  "code": null,
                  "messages": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/OrderNotFound"
          },
          "409": {
            "$ref": "#/components/responses/OrderNotOpen"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/orders/{order}/skip": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrderId"
        }
      ],
      "post": {
        "tags": [
          "Orders"
        ],
        "operationId": "skipOrder",
        "summary": "Cancel an order and blacklist the number",
        "description": "Cancel, plus never hand this same number back to this account for this same service again. Use it when the number itself is the problem — the target service already has an account on it, or has blocked it — rather than when you simply changed your mind; for that, use cancel.\n\nThe blacklist is permanent, per (account, service, number), and there is no API to undo it. It also increments a global skip counter on the number, which is how operations find SIMs that need replacing.\n\nProvider-served orders have no number of ours to blacklist, so for those this behaves exactly like cancel and still succeeds. The blacklist entry is written before the cancellation, so a failure cannot leave the order closed but the number un-skipped.\n\nOnly a `pending` order can be skipped; anything else is `409 order_not_open`.\n\nRequires scope `orders:write`.",
        "x-required-scope": "orders:write",
        "responses": {
          "200": {
            "description": "The order, now `canceled`, with the number blacklisted for this service.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Order"
                },
                "example": {
                  "object": "order",
                  "id": 8124011,
                  "status": "canceled",
                  "service": {
                    "id": 12,
                    "name": "WhatsApp"
                  },
                  "country": "PH",
                  "country_iso": "PH",
                  "phone_number": "+639553022079",
                  "phone_number_local": "9553022079",
                  "reused_from_order_id": null,
                  "created_at": "2026-08-09T09:25:10Z",
                  "expires_at": "2026-08-09T09:25:41Z",
                  "code": null,
                  "messages": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/OrderNotFound"
          },
          "409": {
            "$ref": "#/components/responses/OrderNotOpen"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/orders/{order}/reuse": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrderId"
        }
      ],
      "post": {
        "tags": [
          "Orders"
        ],
        "operationId": "reuseOrder",
        "summary": "Order the same number again, at half price",
        "description": "Buys a new ten-minute window on the number you already verified with, for the same service, at half the normal price. This is how you receive a second code on an account you have already created — a login code, a password reset — without paying full price for a new number that the service would not recognise.\n\nA **new** order is created and returned, with a new id; the original is untouched and `reused_from_order_id` on the new one points back at it. Charge again on delivery, as with any order.\n\nOnly an order that actually received a message can be reused (`422 order_not_completed`), and only while the number is still allocated to us — usually a few hours, never guaranteed. Once it is gone you get `409 number_unavailable` for a provider number whose upstream will not re-issue it, or for one of our SIMs that has gone offline. If the number is a pool number whose row has since been purged, the country is no longer knowable and it is `422 reuse_not_supported`.\n\nReuse counts against the one-open-order limit like any other order.\n\nRequires scope `orders:write`. Uses the order bucket: 30 requests per minute.",
        "x-required-scope": "orders:write",
        "parameters": [],
        "responses": {
          "201": {
            "description": "A new order on the same number.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Order"
                },
                "example": {
                  "object": "order",
                  "id": 8124042,
                  "status": "pending",
                  "service": {
                    "id": 12,
                    "name": "WhatsApp"
                  },
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "reused_from_order_id": 8124007,
                  "created_at": "2026-08-09T10:02:19Z",
                  "expires_at": "2026-08-09T10:12:19Z",
                  "code": null,
                  "messages": []
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance`: the balance does not cover the half-price reuse. The problem document carries `price` and `balance`.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_balance"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#insufficient_balance",
                  "title": "Insufficient balance",
                  "status": 402,
                  "code": "insufficient_balance",
                  "detail": "Your balance does not cover this reuse.",
                  "retryable": false,
                  "price": {
                    "amount": "0.25",
                    "amount_minor": 25,
                    "currency": "EUR"
                  },
                  "balance": {
                    "amount": "0.10",
                    "amount_minor": 10,
                    "currency": "EUR"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`order_not_found` when the id is not one of yours; `service_not_found` when the service the original order was placed for has since been removed from the catalogue.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "order_not_found",
                            "service_not_found"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#order_not_found",
                  "title": "Order not found",
                  "status": 404,
                  "code": "order_not_found",
                  "detail": "No order with id 8124007.",
                  "retryable": false
                }
              }
            }
          },
          "409": {
            "description": "`number_unavailable` when the number can no longer be re-issued — the upstream provider refused, or our SIM is offline; `concurrent_order_limit` when you already hold an open order",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "number_unavailable",
                            "concurrent_order_limit"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#number_unavailable",
                  "title": "Number no longer available",
                  "status": 409,
                  "code": "number_unavailable",
                  "detail": "The provider can no longer supply this number.",
                  "retryable": false
                }
              }
            }
          },
          "422": {
            "description": "`order_not_completed` when the original order never received a message — there is nothing to reuse; `reuse_not_supported` when the number behind it can no longer be identified, which happens once a pool row has been purged.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "order_not_completed",
                            "reuse_not_supported",
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "order_not_completed": {
                    "summary": "Original order never received a code",
                    "value": {
                      "type": "https://juicysms.com/api/errors#order_not_completed",
                      "title": "Order was never completed",
                      "status": 422,
                      "code": "order_not_completed",
                      "detail": "Only an order that received a message can be reused.",
                      "retryable": false
                    }
                  },
                  "reuse_not_supported": {
                    "summary": "Number no longer identifiable",
                    "value": {
                      "type": "https://juicysms.com/api/errors#reuse_not_supported",
                      "title": "Reuse not supported for this order",
                      "status": 422,
                      "code": "reuse_not_supported",
                      "detail": "The number behind this order is no longer available for reuse.",
                      "retryable": false
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/rental-packages": {
      "get": {
        "tags": [
          "Rentals"
        ],
        "operationId": "listRentalPackages",
        "summary": "List rental packages",
        "description": "The rental durations on offer, with both the list price and this account's effective price after any negotiated discount. Quote `your_price` to your users, not `list_price`; `your_price` is what will be taken from the balance.\n\n`days` is the exact number of days added, not a calendar month: the `1m` package is 31 days, `3m` is 93, `6m` is 186, `1y` is 365. Extending adds `days` to the current expiry rather than to today, so extending early never loses time.\n\nThe set is small, fixed and unpaginated. `key` is the value to send as `package` when creating or extending a rental.\n\nRequires scope `rentals:read`.",
        "x-required-scope": "rentals:read",
        "responses": {
          "200": {
            "description": "The available packages.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RentalPackage"
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "rental_package",
                      "key": "1m",
                      "name": "1 month",
                      "days": 31,
                      "list_price": {
                        "amount": "15.00",
                        "amount_minor": 1500,
                        "currency": "EUR"
                      },
                      "your_price": {
                        "amount": "15.00",
                        "amount_minor": 1500,
                        "currency": "EUR"
                      }
                    },
                    {
                      "object": "rental_package",
                      "key": "3m",
                      "name": "3 months",
                      "days": 93,
                      "list_price": {
                        "amount": "30.00",
                        "amount_minor": 3000,
                        "currency": "EUR"
                      },
                      "your_price": {
                        "amount": "28.00",
                        "amount_minor": 2800,
                        "currency": "EUR"
                      }
                    },
                    {
                      "object": "rental_package",
                      "key": "6m",
                      "name": "6 months",
                      "days": 186,
                      "list_price": {
                        "amount": "50.00",
                        "amount_minor": 5000,
                        "currency": "EUR"
                      },
                      "your_price": {
                        "amount": "48.00",
                        "amount_minor": 4800,
                        "currency": "EUR"
                      }
                    },
                    {
                      "object": "rental_package",
                      "key": "1y",
                      "name": "1 year",
                      "days": 365,
                      "list_price": {
                        "amount": "70.00",
                        "amount_minor": 7000,
                        "currency": "EUR"
                      },
                      "your_price": {
                        "amount": "68.00",
                        "amount_minor": 6800,
                        "currency": "EUR"
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/rentals": {
      "get": {
        "tags": [
          "Rentals"
        ],
        "operationId": "listRentals",
        "summary": "List your rentals",
        "description": "Your rental contracts, newest first, cursor-paginated.\n\nExpired rentals are listed by default, not hidden: a lapsed rental is still the customer's record of what they paid for and which number carried their account, so filtering them out by default would make this list disagree with their invoices. Pass `status=active` if you only want live ones.\n\nList items omit `messages`; fetch a single rental or its `/messages` sub-resource for those.\n\nOrdering is by id rather than by expiry, which is why there is no `sort` parameter — `hire_number_contracts` carries no index but the primary key, so sorting on `expire_on` is a filesort, and a cursor needs a unique monotonic column anyway.\n\nRequires scope `rentals:read`.",
        "x-required-scope": "rentals:read",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Which rentals to include. `active` means the expiry is in the future. Defaults to `all`.",
            "schema": {
              "$ref": "#/components/schemas/RentalStatusFilter"
            },
            "example": "active"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of rentals.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Rental"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/CursorPagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "rental",
                      "id": 9142,
                      "status": "active",
                      "country": "NL",
                      "country_iso": "NL",
                      "phone_number": "+31686216609",
                      "phone_number_local": "686216609",
                      "package": "3m",
                      "auto_renew": true,
                      "renewal_package": "1m",
                      "started_at": "2026-06-11T12:04:00Z",
                      "expires_at": "2026-09-12T12:04:00Z",
                      "days_remaining": 34
                    }
                  ],
                  "pagination": {
                    "limit": 25,
                    "has_more": false,
                    "next_cursor": null,
                    "previous_cursor": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "An unknown `status` — the only accepted values are `active`, `expired` and `all` — or a `limit` outside 1-100.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#validation_failed",
                  "title": "Validation failed",
                  "status": 422,
                  "code": "validation_failed",
                  "detail": "The request payload failed validation. See `errors` for the offending fields.",
                  "retryable": false,
                  "errors": {
                    "status": [
                      "The selected status is invalid."
                    ]
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "tags": [
          "Rentals"
        ],
        "operationId": "createRental",
        "summary": "Rent a number",
        "description": "Takes a number out of the for-hire pool and charges the package price to your balance **immediately** — unlike a one-time order, where money moves only on delivery.\n\n`auto_renew` is required and must be a real boolean — a recurring charge rides on it, so there is no default on either side of the wire.\n\nOnly numbers whose SIM has checked in within the last two minutes are considered rentable, so `409 out_of_stock` is a normal answer and worth handling. Rentals are sold for NL and UK.\n\nThe charge and the allocation happen inside one transaction under a per-country lock, so a failure cannot leave you charged without a number.\n\nAn unknown country, or one that cannot be rented, is caught during request validation and comes back as `validation_failed` with the field named — not as `country_not_supported`.\n\nRequires scope `rentals:write`. Uses the order bucket: 30 requests per minute.",
        "x-required-scope": "rentals:write",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateRentalRequest"
              },
              "example": {
                "country": "NL",
                "package": "3m",
                "auto_renew": false
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The rental, charged and active.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Rental"
                },
                "example": {
                  "object": "rental",
                  "id": 9142,
                  "status": "active",
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "package": "3m",
                  "auto_renew": false,
                  "renewal_package": "3m",
                  "started_at": "2026-08-09T10:15:00Z",
                  "expires_at": "2026-11-10T10:15:00Z",
                  "days_remaining": 93
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance`: the package price exceeds the balance. The problem document carries `price` and `balance`.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_balance"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#insufficient_balance",
                  "title": "Insufficient balance",
                  "status": 402,
                  "code": "insufficient_balance",
                  "detail": "Your balance does not cover this rental.",
                  "retryable": false,
                  "price": {
                    "amount": "30.00",
                    "amount_minor": 3000,
                    "currency": "EUR"
                  },
                  "balance": {
                    "amount": "12.40",
                    "amount_minor": 1240,
                    "currency": "EUR"
                  }
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` when the key lacks `rentals:write`; `account_restricted` when the account may not rent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_scope",
                            "account_restricted"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#account_restricted",
                  "title": "Account restricted",
                  "status": 403,
                  "code": "account_restricted",
                  "detail": "This account cannot rent numbers. Contact support.",
                  "retryable": false
                }
              }
            }
          },
          "409": {
            "description": "`out_of_stock` when no live for-hire number exists in that country right now (retryable)",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "out_of_stock"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#out_of_stock",
                  "title": "No numbers available",
                  "status": 409,
                  "code": "out_of_stock",
                  "detail": "No United Kingdom number is available to rent right now.",
                  "retryable": true,
                  "country": "UK"
                }
              }
            }
          },
          "422": {
            "description": "`invalid_package` when the **only** thing wrong is that `package` is not a key from `GET /rental-packages` — the problem document lists the valid keys. Anything else, including an unknown or unrentable country and an omitted `auto_renew`, is `validation_failed` with a field-keyed `errors` bag. A bad package alongside another bad field also collapses into `validation_failed`, so branch on `errors` rather than assuming one code per field.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "invalid_package",
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "invalid_package": {
                    "summary": "Unknown package key, and nothing else wrong",
                    "value": {
                      "type": "https://juicysms.com/api/errors#invalid_package",
                      "title": "Invalid rental package",
                      "status": 422,
                      "code": "invalid_package",
                      "detail": "Unknown rental package. See GET /api/v2/rental-packages.",
                      "retryable": false,
                      "valid_packages": [
                        "1m",
                        "3m",
                        "6m",
                        "1y"
                      ]
                    }
                  },
                  "validation_failed": {
                    "summary": "Country cannot be rented, auto_renew omitted",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "The request payload failed validation. See `errors` for the offending fields.",
                      "retryable": false,
                      "errors": {
                        "country": [
                          "Poland numbers cannot be rented."
                        ],
                        "auto_renew": [
                          "State auto_renew explicitly: true to keep renewing this rental, false to let it lapse."
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "`service_busy`: another rental for the same country is being allocated. Retry immediately.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "service_busy"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#service_busy",
                  "title": "Service temporarily busy",
                  "status": 503,
                  "code": "service_busy",
                  "detail": "Another rental for this country is being created. Retry in a moment.",
                  "retryable": true
                }
              }
            }
          }
        }
      }
    },
    "/rentals/{rental}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/RentalId"
        }
      ],
      "get": {
        "tags": [
          "Rentals"
        ],
        "operationId": "getRental",
        "summary": "Retrieve a rental",
        "description": "One rental, with its recent messages inlined.\n\n`package` is what is currently in force; `renewal_package` is what an automatic renewal will buy next, and the two can differ because extending a rental changes the term without rewriting the renewal setting.\n\nA rental belonging to another account answers `404 rental_not_found`, the same as an id that does not exist.\n\nRequires scope `rentals:read`.",
        "x-required-scope": "rentals:read",
        "responses": {
          "200": {
            "description": "The rental.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Rental"
                },
                "example": {
                  "object": "rental",
                  "id": 9142,
                  "status": "active",
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "package": "3m",
                  "auto_renew": true,
                  "renewal_package": "1m",
                  "started_at": "2026-06-11T12:04:00Z",
                  "expires_at": "2026-09-12T12:04:00Z",
                  "days_remaining": 34,
                  "messages": [
                    {
                      "object": "message",
                      "id": 55208871,
                      "sender": "+31612345678",
                      "text": "Uw Bol.com code is 4471",
                      "code": "4471",
                      "received_at": "2026-08-08T20:11:42Z",
                      "source": "sms"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/RentalNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "tags": [
          "Rentals"
        ],
        "operationId": "updateRental",
        "summary": "Change renewal settings",
        "description": "Changes how a rental renews. Only the fields you send are touched — omitting `auto_renew` leaves it as it is.\n\n`renewal_package` may differ from the package currently in force: set it to a shorter term to wind a rental down cheaply, or a longer one to lock a rate in. Neither field changes the current expiry or moves any money; to add time now, use `POST /rentals/{rental}/extend`.\n\nThere is no way to cancel a rental here. Turning `auto_renew` off is the cancellation: the number is yours until `expires_at` and is then released.\n\nA patch that names neither field is rejected as `validation_failed` rather than answering 200. The usual cause is a serializer that drops `auto_renew: false` as a falsy value, and reporting success for a change that never happened is how a customer discovers a month later that they are still being charged.\n\nAn expired rental can still be edited — the write succeeds and changes nothing useful, since a lapsed contract will not renew.\n\nRequires scope `rentals:write`.",
        "x-required-scope": "rentals:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateRentalRequest"
              },
              "examples": {
                "stop_renewing": {
                  "summary": "Let it lapse at the current expiry",
                  "value": {
                    "auto_renew": false
                  }
                },
                "renew_on_a_shorter_term": {
                  "summary": "Keep renewing, but monthly from now on",
                  "value": {
                    "auto_renew": true,
                    "renewal_package": "1m"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rental with its updated renewal settings.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Rental"
                },
                "example": {
                  "object": "rental",
                  "id": 9142,
                  "status": "active",
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "package": "3m",
                  "auto_renew": false,
                  "renewal_package": "1m",
                  "started_at": "2026-06-11T12:04:00Z",
                  "expires_at": "2026-09-12T12:04:00Z",
                  "days_remaining": 34
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/RentalNotFound"
          },
          "422": {
            "description": "`invalid_package` when the only problem is that `renewal_package` is not a key from `GET /rental-packages`; `validation_failed` for a field of the wrong type, or for a patch that names neither field.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "invalid_package",
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "invalid_package": {
                    "summary": "Unknown renewal package",
                    "value": {
                      "type": "https://juicysms.com/api/errors#invalid_package",
                      "title": "Invalid rental package",
                      "status": 422,
                      "code": "invalid_package",
                      "detail": "Unknown rental package. See GET /api/v2/rental-packages.",
                      "retryable": false,
                      "valid_packages": [
                        "1m",
                        "3m",
                        "6m",
                        "1y"
                      ]
                    }
                  },
                  "empty_patch": {
                    "summary": "Nothing to change",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "The request payload failed validation. See `errors` for the offending fields.",
                      "retryable": false,
                      "errors": {
                        "auto_renew": [
                          "Send auto_renew, renewal_package, or both. An empty patch changes nothing."
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/rentals/{rental}/messages": {
      "parameters": [
        {
          "$ref": "#/components/parameters/RentalId"
        }
      ],
      "get": {
        "tags": [
          "Rentals"
        ],
        "operationId": "listRentalMessages",
        "summary": "Read the messages on a rental",
        "description": "The most recent messages received on the rented number, newest first, capped at 100.\n\nThe window is bounded by the contract: only messages between `started_at` and `expires_at` are returned. A physical SIM outlives the contract it was rented under and gets re-rented, so without that bound you would be reading messages addressed to whoever held the number before you — and they would later be reading yours.\n\nThat also means a lapsed rental keeps answering with its historical messages, and that messages sent to the number after your contract ended are not yours and are not shown.\n\nA rental whose SIM has since been removed from the server returns an empty `data` rather than an error.\n\nRequires scope `rentals:read`.",
        "x-required-scope": "rentals:read",
        "responses": {
          "200": {
            "description": "Messages within the contract window, newest first.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Message"
                      }
                    },
                    "rental_id": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "message",
                      "id": 55208871,
                      "sender": "+31612345678",
                      "text": "Uw Bol.com code is 4471",
                      "code": "4471",
                      "received_at": "2026-08-08T20:11:42Z",
                      "source": "sms"
                    }
                  ],
                  "rental_id": 9142
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/RentalNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/rentals/{rental}/extend": {
      "parameters": [
        {
          "$ref": "#/components/parameters/RentalId"
        }
      ],
      "post": {
        "tags": [
          "Rentals"
        ],
        "operationId": "extendRental",
        "summary": "Add time to a rental",
        "description": "Charges a package price and adds that package's days to the **current expiry**, not to today, so extending early never costs you the unused remainder.\n\n200, not 201: this buys more time on the rental you already have and returns that same resource. Nothing new is created at this URL.\n\nExtending does not change `renewal_package`: the term you extend by and the term you renew on stay independent. The renewal package is left as it is; change it explicitly with `PATCH /rentals/{rental}`.\n\nA rental that has already lapsed cannot be extended — `409 rental_expired`, with the expiry instant attached. The number has been released by then and recovering it is a manual support operation, not something this endpoint can do.\n\nThe number's SIM is resolved before the money moves, so a rental whose SIM has gone away fails without being billed.\n\nRequires scope `rentals:write`. Uses the order bucket: 30 requests per minute.",
        "x-required-scope": "rentals:write",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExtendRentalRequest"
              },
              "example": {
                "package": "1m"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rental, with the new expiry.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Rental"
                },
                "example": {
                  "object": "rental",
                  "id": 9142,
                  "status": "active",
                  "country": "NL",
                  "country_iso": "NL",
                  "phone_number": "+31686216609",
                  "phone_number_local": "686216609",
                  "package": "3m",
                  "auto_renew": true,
                  "renewal_package": "1m",
                  "started_at": "2026-06-11T12:04:00Z",
                  "expires_at": "2026-10-13T12:04:00Z",
                  "days_remaining": 65
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance`: the extension price exceeds the balance. Nothing is charged and the expiry is unchanged.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_balance"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#insufficient_balance",
                  "title": "Insufficient balance",
                  "status": 402,
                  "code": "insufficient_balance",
                  "detail": "Your balance does not cover this extension.",
                  "retryable": false,
                  "price": {
                    "amount": "15.00",
                    "amount_minor": 1500,
                    "currency": "EUR"
                  },
                  "balance": {
                    "amount": "1.20",
                    "amount_minor": 120,
                    "currency": "EUR"
                  }
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope` when the key lacks `rentals:write`; `account_restricted` when the account may not rent.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "insufficient_scope",
                            "account_restricted"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#insufficient_scope",
                  "title": "Insufficient scope",
                  "status": 403,
                  "code": "insufficient_scope",
                  "detail": "This API key does not have the scope required for this endpoint.",
                  "retryable": false,
                  "required_scopes": [
                    "rentals:write"
                  ],
                  "granted_scopes": [
                    "rentals:read"
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/RentalNotFound"
          },
          "409": {
            "description": "`rental_expired` when the rental has already lapsed — the problem document carries `expired_at`, and recovery is a support operation",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "rental_expired"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#rental_expired",
                  "title": "Rental has expired",
                  "status": 409,
                  "code": "rental_expired",
                  "detail": "This rental has already lapsed and cannot be extended. Contact support to recover it.",
                  "retryable": false,
                  "expired_at": "2026-08-01T12:04:00Z"
                }
              }
            }
          },
          "422": {
            "description": "`invalid_package` when `package` names a key we do not sell — the valid keys are attached as `valid_packages`, so a client can recover without a second call. The two are kept apart deliberately — recoding an omitted field as `invalid_package` would tell a caller who sent nothing that their package is wrong, and would drop the field-keyed `errors` bag.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "invalid_package",
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "invalid_package": {
                    "summary": "Unknown package key",
                    "value": {
                      "type": "https://juicysms.com/api/errors#invalid_package",
                      "title": "Invalid rental package",
                      "status": 422,
                      "code": "invalid_package",
                      "detail": "Unknown rental package. See GET /api/v2/rental-packages.",
                      "retryable": false,
                      "valid_packages": [
                        "1m",
                        "3m",
                        "6m",
                        "1y"
                      ]
                    }
                  },
                  "validation_failed": {
                    "summary": "package omitted entirely",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "The request payload failed validation. See `errors` for the offending fields.",
                      "retryable": false,
                      "errors": {
                        "package": [
                          "The package field is required."
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "`service_busy`: another rental for the same country is being allocated. Retry immediately.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "service_busy"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#service_busy",
                  "title": "Service temporarily busy",
                  "status": 503,
                  "code": "service_busy",
                  "detail": "Another rental for this country is being created. Retry in a moment.",
                  "retryable": true
                }
              }
            }
          }
        }
      }
    },
    "/webhook-endpoints": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookEndpoints",
        "summary": "List your webhook endpoints",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nEvery endpoint registered on this account, newest first. The set is capped at five and is therefore unpaginated; `limit` in the response is that cap, not a page size.\n\n`available_events` ships the whole subscribable event catalogue with descriptions, so a settings UI can be built without hardcoding event names that may be added to later.\n\n`secret` is never returned here — it is disclosed exactly once, in the 201 from `POST /webhook-endpoints`. There is no \"show me my secret again\" endpoint by design; if you lost it, delete the endpoint and register a new one.\n\nWatch `disabled_at`: non-null means **we** switched the endpoint off after a run of consecutive failures, not you. `consecutive_failures` is the counter behind that.\n\nRequires scope `webhooks:read`.",
        "x-required-scope": "webhooks:read",
        "responses": {
          "200": {
            "description": "Your endpoints, plus the cap and the event catalogue.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "limit",
                    "available_events"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookEndpoint"
                      }
                    },
                    "limit": {
                      "type": "integer",
                      "description": "The maximum number of endpoints this account may hold. Not a page size — the list is complete.",
                      "examples": [
                        5
                      ]
                    },
                    "available_events": {
                      "type": "array",
                      "description": "Everything that can be subscribed to, with a one-line description of each.",
                      "items": {
                        "type": "object",
                        "required": [
                          "name",
                          "description"
                        ],
                        "properties": {
                          "name": {
                            "$ref": "#/components/schemas/WebhookEventName"
                          },
                          "description": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "webhook_endpoint",
                      "id": 74,
                      "url": "https://hooks.example.com/juicysms",
                      "description": "production",
                      "events": [
                        "sms.received",
                        "rental.sms.received"
                      ],
                      "enabled": true,
                      "disabled_at": null,
                      "consecutive_failures": 0,
                      "last_delivery_at": "2026-08-09T09:21:59Z",
                      "created_at": "2026-07-30T14:22:09Z",
                      "updated_at": "2026-08-02T09:00:11Z"
                    }
                  ],
                  "limit": 5,
                  "available_events": [
                    {
                      "name": "sms.received",
                      "description": "A message arrived on a one-time order. This is the event that replaces polling."
                    },
                    {
                      "name": "rental.sms.received",
                      "description": "A message arrived on a long-term rental."
                    },
                    {
                      "name": "rental.expiring",
                      "description": "A long-term rental is approaching its expiry date, and will not be renewed automatically."
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "createWebhookEndpoint",
        "summary": "Register a webhook endpoint",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nRegisters a URL we push events to, so you can stop polling `GET /orders/{order}/messages`.\n\n**The `secret` in this response is disclosed once and never again.** Store it before you do anything else — no other response in this API contains it, and the model hides it so an accidental serialization cannot leak it either.\n\n**Verifying a delivery.** Each request carries `JuicySMS-Signature: t=<unix>,v1=<hex>`, `JuicySMS-Event` and `JuicySMS-Delivery-Id`. The signature is `HMAC-SHA256(secret, \"<t>.<raw body>\")` — the timestamp is *inside* the signed string, not merely alongside it, so a captured delivery cannot be replayed forever: it has to carry its original `t`, which you then reject as outside your tolerance window. Enforce 300 seconds. Compare in constant time and sign the raw bytes, not a re-serialized copy.\n\n**The URL is fetched by our servers from inside our network**, so it is validated harder than a `url` rule would: HTTPS only, ports 80 and 443 only, no credentials in the URL, and the host must not resolve to a private, loopback, link-local or otherwise reserved address. The check runs again immediately before every delivery, so pointing a name at a public address now and a private one later does not get you anywhere.\n\n**Delivery behaviour.** 10-second timeout, up to 6 attempts, backoff 10s / 1m / 5m / 30m / 2h. Acknowledge fast and do your work afterwards, and make the handler idempotent — a slow 200 we time out on is redelivered, and each attempt signs a slightly different body because `attempt` is inside the envelope. After a sustained run of failures we switch the endpoint off ourselves; re-arm it with `PATCH {\"enabled\": true}`.\n\nAt most five endpoints per account; a sixth is `422 validation_failed` carrying `limit` and `current`.\n\nRequires scope `webhooks:write`.",
        "x-required-scope": "webhooks:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateWebhookEndpointRequest"
              },
              "example": {
                "url": "https://hooks.example.com/juicysms",
                "events": [
                  "sms.received",
                  "rental.sms.received"
                ],
                "description": "production"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The endpoint, including the signing secret. This is the only response in the API that ever contains it.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpoint"
                },
                "example": {
                  "object": "webhook_endpoint",
                  "id": 74,
                  "url": "https://hooks.example.com/juicysms",
                  "description": "production",
                  "events": [
                    "sms.received",
                    "rental.sms.received"
                  ],
                  "enabled": true,
                  "disabled_at": null,
                  "consecutive_failures": 0,
                  "last_delivery_at": null,
                  "created_at": "2026-08-09T10:40:02Z",
                  "updated_at": "2026-08-09T10:40:02Z",
                  "secret": "whsec_9f2c1b7a4d0e6538ab21c4f7e90d3b6205a1c8d47e39f10b6c2a8d5e4f7093ab",
                  "secret_notice": "Store this now. It is shown only in this response and cannot be retrieved again."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "422": {
            "description": "`validation_failed`. Either the payload is wrong — a non-HTTPS URL, a host that resolves somewhere private, credentials embedded in the URL, an unrecognised event name, an empty `events` array — or the account already holds the maximum of five endpoints, in which case the document carries `limit` and `current` instead of an `errors` bag.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "rejected_url": {
                    "summary": "URL points somewhere it must not",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "The request payload failed validation. See `errors` for the offending fields.",
                      "retryable": false,
                      "errors": {
                        "url": [
                          "The host resolves to a private, loopback, link-local or otherwise reserved address."
                        ]
                      }
                    }
                  },
                  "at_the_cap": {
                    "summary": "Already holding five endpoints",
                    "value": {
                      "type": "https://juicysms.com/api/errors#validation_failed",
                      "title": "Validation failed",
                      "status": 422,
                      "code": "validation_failed",
                      "detail": "You already have 5 webhook endpoints, which is the maximum. Delete one first.",
                      "retryable": false,
                      "limit": 5,
                      "current": 5
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "callbacks": {
          "event": {
            "{$request.body#/url}": {
              "post": {
                "operationId": "receiveWebhookEvent",
                "summary": "The request we send to your endpoint",
                "description": "Not an operation you call — this is the request JuicySMS makes to the URL you registered, described here so you can type and test your receiver.\n\nVerify `JuicySMS-Signature` before parsing the body: recompute `HMAC-SHA256(secret, \"<t>.<raw body>\")` over the **raw bytes**, compare in constant time, and reject a `t` more than 300 seconds old. Re-serializing the body first will not reproduce the signature.\n\nAnswer 2xx as soon as you have durably accepted the event and do the work afterwards. Anything else, or no answer within 10 seconds, counts as a failure and the event is retried (up to 6 attempts, backoff 10s / 1m / 5m / 30m / 2h). A sustained run of failures disables the endpoint until you re-enable it. Because retries are real, your handler must be idempotent — deduplicate on the envelope `id`, which is stable across attempts, not on `attempt`, which is not.",
                "security": [],
                "parameters": [
                  {
                    "name": "JuicySMS-Signature",
                    "in": "header",
                    "required": true,
                    "description": "`t=<unix seconds>,v1=<hex HMAC-SHA256>`. The timestamp is inside the signed string, so a captured delivery cannot be replayed indefinitely: it has to present its original `t`, which your tolerance check then rejects.",
                    "schema": {
                      "type": "string"
                    },
                    "example": "t=1786000919,v1=3f0a7c1d94e2b5860af31c7d2e9b40518c6ad7f39e2140b5c8a6d2e4f7093ab1"
                  },
                  {
                    "name": "JuicySMS-Event",
                    "in": "header",
                    "required": true,
                    "description": "The event type, so a router can dispatch without parsing the body.",
                    "schema": {
                      "type": "string"
                    },
                    "example": "sms.received"
                  },
                  {
                    "name": "JuicySMS-Delivery-Id",
                    "in": "header",
                    "required": true,
                    "description": "The delivery id. Look it up in `GET /webhook-endpoints/{endpoint}/deliveries` to see what we recorded of your response.",
                    "schema": {
                      "type": "string"
                    },
                    "example": "990211"
                  }
                ],
                "requestBody": {
                  "required": true,
                  "content": {
                    "application/json": {
                      "schema": {
                        "$ref": "#/components/schemas/WebhookEventEnvelope"
                      },
                      "example": {
                        "id": "whd_990211",
                        "object": "event",
                        "type": "sms.received",
                        "api_version": "2026-08-09",
                        "created_at": "2026-08-09T09:21:59Z",
                        "attempt": 1,
                        "data": {
                          "order": {
                            "object": "order",
                            "id": 8124007,
                            "status": "completed",
                            "service": {
                              "id": 12,
                              "name": "WhatsApp"
                            },
                            "country": "NL",
                            "country_iso": "NL",
                            "phone_number": "+31686216609",
                            "phone_number_local": "686216609"
                          },
                          "message": {
                            "object": "message",
                            "id": null,
                            "sender": "WhatsApp",
                            "text": "Your WhatsApp code: 382-914. Don't share this code with others.",
                            "code": "382914",
                            "received_at": "2026-08-09T09:21:58Z",
                            "source": "provider"
                          }
                        }
                      }
                    }
                  }
                },
                "responses": {
                  "2XX": {
                    "description": "Accepted. The delivery is marked succeeded and never retried. The body is recorded (first 2 KB) but not interpreted."
                  },
                  "default": {
                    "description": "Anything non-2xx, or no response within 10 seconds, is a failure and the event is retried until the attempt budget runs out."
                  }
                }
              }
            }
          }
        }
      }
    },
    "/webhook-endpoints/{endpoint}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WebhookEndpointId"
        }
      ],
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "getWebhookEndpoint",
        "summary": "Retrieve a webhook endpoint",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nOne endpoint's configuration, including its failure state. `secret` is omitted — see `POST /webhook-endpoints`. An endpoint belonging to another account answers `404 webhook_endpoint_not_found`, the same as an id that does not exist.\n\nRequires scope `webhooks:read`.",
        "x-required-scope": "webhooks:read",
        "responses": {
          "200": {
            "description": "The endpoint.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpoint"
                },
                "example": {
                  "object": "webhook_endpoint",
                  "id": 74,
                  "url": "https://hooks.example.com/juicysms",
                  "description": "production",
                  "events": [
                    "sms.received"
                  ],
                  "enabled": false,
                  "disabled_at": "2026-08-08T04:11:50Z",
                  "consecutive_failures": 12,
                  "last_delivery_at": "2026-08-08T04:11:50Z",
                  "created_at": "2026-07-30T14:22:09Z",
                  "updated_at": "2026-08-08T04:11:50Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/WebhookEndpointNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "updateWebhookEndpoint",
        "summary": "Update a webhook endpoint",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nChanges an endpoint. Only the fields you send are touched, and `events` is replaced wholesale rather than merged — send the complete list you want.\n\n`enabled: false` stops deliveries without losing the configuration or rotating the secret; that is the right way to pause during a deploy, because deleting and re-registering issues a new secret.\n\n`enabled: true` is also how you **re-arm an endpoint we switched off**. Sending it resets `consecutive_failures` and clears `disabled_at`, on the reasoning that re-enabling is your statement that the receiving end is fixed — without the reset the breaker would trip again on the very next failure.\n\nA new `url` is validated exactly as strictly as on create: moving an endpoint to an internal address is the same SSRF vector as registering one there.\n\nThe secret cannot be rotated here. Delete and re-create.\n\nRequires scope `webhooks:write`.",
        "x-required-scope": "webhooks:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateWebhookEndpointRequest"
              },
              "examples": {
                "pause": {
                  "summary": "Stop deliveries, keep the configuration",
                  "value": {
                    "enabled": false
                  }
                },
                "rearm": {
                  "summary": "Re-enable after fixing your handler; resets the failure counter",
                  "value": {
                    "enabled": true
                  }
                },
                "resubscribe": {
                  "summary": "Replace the event list",
                  "value": {
                    "events": [
                      "sms.received",
                      "rental.expiring"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated endpoint.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpoint"
                },
                "example": {
                  "object": "webhook_endpoint",
                  "id": 74,
                  "url": "https://hooks.example.com/juicysms",
                  "description": "production",
                  "events": [
                    "sms.received",
                    "rental.expiring"
                  ],
                  "enabled": false,
                  "disabled_at": null,
                  "consecutive_failures": 0,
                  "last_delivery_at": "2026-08-09T09:21:59Z",
                  "created_at": "2026-07-30T14:22:09Z",
                  "updated_at": "2026-08-09T10:52:44Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/WebhookEndpointNotFound"
          },
          "422": {
            "description": "A `url` that fails the same guard as on create, an unrecognised event name, or an empty `events` array.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#validation_failed",
                  "title": "Validation failed",
                  "status": 422,
                  "code": "validation_failed",
                  "detail": "The request payload failed validation. See `errors` for the offending fields.",
                  "retryable": false,
                  "errors": {
                    "events.0": [
                      "Unknown event. Valid events: sms.received, rental.sms.received, rental.expiring."
                    ]
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhookEndpoint",
        "summary": "Delete a webhook endpoint",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nRemoves the endpoint, its signing secret, and **its entire delivery history** — the history is a sub-resource of the endpoint and would be unreadable afterwards, and leaving it would keep your payloads on our disks for another thirty days until the pruner reached them. Export anything you need from `/deliveries` first.\n\nDeliveries stop; anything queued for retry is abandoned. This is not reversible and re-registering the same URL issues a **new** secret, so if you only want to stop deliveries temporarily, `PATCH {\"enabled\": false}` instead.\n\nDeleting an id that does not exist is `404`, not a silent success, so an unconditional delete is not a safe cleanup step.\n\nRequires scope `webhooks:write`.",
        "x-required-scope": "webhooks:write",
        "responses": {
          "204": {
            "description": "Deleted. No body.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/WebhookEndpointNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/webhook-endpoints/{endpoint}/deliveries": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WebhookEndpointId"
        }
      ],
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookDeliveries",
        "summary": "List recent deliveries to an endpoint",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nDelivery records for one endpoint, newest first, cursor-paginated. This is the \"why didn't I get it\" view: it carries the status **and the first 2 KB of the body your server answered with**, plus the exact `data` member we signed, so you can reproduce the signature locally without correlating against your own logs.\n\nOne row is one event across all of its attempts, not one row per attempt: `attempt` is how many tries have been made out of the maximum of six, `next_attempt_at` is when the next is due and is null once the delivery has succeeded or run out of attempts, and `succeeded` is the outcome so far. `response_status` is null when there was no response at all — a timeout, a TLS failure, or DNS.\n\nRows are pruned after thirty days, and deleted outright when the endpoint is.\n\n`limit` is clamped to 1-100 rather than rejected, so an out-of-range value is silently corrected instead of failing the call.\n\nRequires scope `webhooks:read`.",
        "x-required-scope": "webhooks:read",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per page, clamped to 1-100 rather than rejected. Defaults to 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "example": 25
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of delivery records.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookDelivery"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/CursorPagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "object": "webhook_delivery",
                      "id": 990211,
                      "endpoint_id": 74,
                      "event": "sms.received",
                      "succeeded": false,
                      "attempt": 6,
                      "response_status": 500,
                      "response_body": "{\"error\":\"handler threw\"}",
                      "error": null,
                      "duration_ms": 812,
                      "delivered_at": null,
                      "next_attempt_at": null,
                      "created_at": "2026-08-09T09:21:59Z",
                      "payload": {
                        "order": {
                          "object": "order",
                          "id": 8124007,
                          "status": "completed",
                          "service": {
                            "id": 12,
                            "name": "WhatsApp"
                          },
                          "country": "NL",
                          "country_iso": "NL",
                          "phone_number": "+31686216609",
                          "phone_number_local": "686216609"
                        },
                        "message": {
                          "object": "message",
                          "id": null,
                          "sender": "WhatsApp",
                          "text": "Your WhatsApp code: 382-914. Don't share this code with others.",
                          "code": "382914",
                          "received_at": "2026-08-09T09:21:58Z",
                          "source": "provider"
                        }
                      }
                    },
                    {
                      "object": "webhook_delivery",
                      "id": 990188,
                      "endpoint_id": 74,
                      "event": "sms.received",
                      "succeeded": true,
                      "attempt": 1,
                      "response_status": 200,
                      "response_body": "ok",
                      "error": null,
                      "duration_ms": 143,
                      "delivered_at": "2026-08-09T08:44:03Z",
                      "next_attempt_at": null,
                      "created_at": "2026-08-09T08:44:02Z",
                      "payload": {
                        "order": {
                          "object": "order",
                          "id": 8123991,
                          "status": "completed",
                          "service": {
                            "id": 41,
                            "name": "Telegram"
                          },
                          "country": "NL",
                          "country_iso": "NL",
                          "phone_number": "+31686216609",
                          "phone_number_local": "686216609"
                        },
                        "message": {
                          "object": "message",
                          "id": null,
                          "sender": "Telegram",
                          "text": "Telegram code 550183",
                          "code": "550183",
                          "received_at": "2026-08-09T08:44:01Z",
                          "source": "provider"
                        }
                      }
                    }
                  ],
                  "pagination": {
                    "limit": 25,
                    "has_more": false,
                    "next_cursor": null,
                    "previous_cursor": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/WebhookEndpointNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/webhook-endpoints/{endpoint}/test": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WebhookEndpointId"
        }
      ],
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "testWebhookEndpoint",
        "summary": "Send a test delivery",
        "description": "**Currently disabled.** Returns `403 feature_unavailable` until webhooks are switched on.\n\nQueues a `ping` to the endpoint so you can verify reachability and, more to the point, your signature verification — through exactly the same signing and delivery path a real event takes.\n\n**202, not 200: the delivery has been accepted, not performed.** The body is the delivery record as it stands the instant it was queued, so `succeeded` is false and `response_status` is null; that is not a failure. Take the `id` and poll `GET /webhook-endpoints/{endpoint}/deliveries` for the outcome.\n\n`ping` is not a subscribable event and has no case in the event enum, so an endpoint subscribed to nothing still receives it — an endpoint you cannot test would be useless. The payload is recognisably synthetic and refers to no real order or rental.\n\nA disabled endpoint is rejected rather than silently swallowed: nothing would be delivered, and answering 202 for a delivery that will never leave would be a lie. Re-enable it first.\n\nRequires scope `webhooks:write`.",
        "x-required-scope": "webhooks:write",
        "responses": {
          "202": {
            "description": "The test delivery has been queued. Poll `/deliveries` with this `id` for the outcome — the record here is the queued state, not the result.",
            "headers": {
              "X-Api-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDelivery"
                },
                "example": {
                  "object": "webhook_delivery",
                  "id": 990233,
                  "endpoint_id": 74,
                  "event": "ping",
                  "succeeded": false,
                  "attempt": 0,
                  "response_status": null,
                  "response_body": null,
                  "error": null,
                  "duration_ms": 0,
                  "delivered_at": null,
                  "next_attempt_at": null,
                  "created_at": "2026-08-09T10:58:31Z",
                  "payload": {
                    "message": "This is a test delivery from JuicySMS. Verify the JuicySMS-Signature header against your endpoint secret.",
                    "endpoint_id": 74,
                    "sent_at": "2026-08-09T10:58:31Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`feature_unavailable` — webhooks are not switched on. Every route in this section returns this until the feature is opened up; nothing about the contract changes when it is. Poll `GET /orders/{order}/messages` in the meantime.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#feature_unavailable",
                  "title": "Feature not enabled for this account",
                  "status": 403,
                  "code": "feature_unavailable",
                  "detail": "Webhooks are not enabled yet. Poll GET /api/v2/orders/{id}/messages in the meantime, and contact support to be included when webhooks open up.",
                  "retryable": false,
                  "feature": "webhooks"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/WebhookEndpointNotFound"
          },
          "422": {
            "description": "`validation_failed`: the endpoint is disabled, so nothing would be delivered. `disabled_at` says when it was switched off — non-null means our circuit breaker did it, not you.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Problem"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "validation_failed"
                          ]
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "type": "https://juicysms.com/api/errors#validation_failed",
                  "title": "Validation failed",
                  "status": 422,
                  "code": "validation_failed",
                  "detail": "This endpoint is disabled, so nothing would be delivered. Re-enable it with PATCH {\"enabled\": true} first.",
                  "retryable": false,
                  "endpoint_id": 74,
                  "disabled_at": "2026-08-08T04:11:50Z"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer <key>`. Accepts either a v2 API key (scoped, expirable, revocable) or the legacy `users.api_token`, which is treated as holding every scope. Never send the key as a query parameter — a key in a URL ends up in access logs, proxy logs and `Referer` headers."
      }
    },
    "parameters": {
      "OrderId": {
        "name": "order",
        "in": "path",
        "required": true,
        "description": "The order id, as returned by `POST /orders`.",
        "schema": {
          "type": "integer",
          "minimum": 1
        },
        "example": 8124007
      },
      "RentalId": {
        "name": "rental",
        "in": "path",
        "required": true,
        "description": "The rental contract id, as returned by `POST /rentals`.",
        "schema": {
          "type": "integer",
          "minimum": 1
        },
        "example": 9142
      },
      "WebhookEndpointId": {
        "name": "endpoint",
        "in": "path",
        "required": true,
        "description": "The webhook endpoint id.",
        "schema": {
          "type": "integer",
          "minimum": 1
        },
        "example": 74
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Rows per page. Defaults to 25, capped at 100.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        },
        "example": 25
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "The `next_cursor` from a previous page. Opaque: do not construct, parse or persist it beyond the walk you are doing.",
        "schema": {
          "type": "string",
          "maxLength": 255
        }
      }
    },
    "headers": {
      "XApiVersion": {
        "description": "The API version this deployment is running. Compare against `info.version` in the OpenAPI document to detect a stale generated client.",
        "schema": {
          "type": "string"
        },
        "example": "2026-08-09"
      },
      "RateLimitLimit": {
        "description": "Requests permitted in the current window for the bucket this endpoint uses.",
        "schema": {
          "type": "integer"
        },
        "example": 240
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        },
        "example": 238
      },
      "RateLimitReset": {
        "description": "Seconds until the window resets. Seconds remaining, not an absolute epoch — the `X-RateLimit-Reset` header emitted alongside it is the epoch form.",
        "schema": {
          "type": "integer"
        },
        "example": 42
      },
      "RateLimitPolicy": {
        "description": "The policy in force, as `<limit>;w=<window seconds>`.",
        "schema": {
          "type": "string"
        },
        "example": "240;w=60"
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying.",
        "schema": {
          "type": "integer"
        },
        "example": 1
      },
      "Location": {
        "description": "Absolute URL of the newly created resource.",
        "schema": {
          "type": "string",
          "format": "uri"
        },
        "example": "https://juicysms.com/api/v2/orders/8124007"
      },
      "WwwAuthenticate": {
        "description": "Authentication challenge.",
        "schema": {
          "type": "string"
        },
        "example": "Bearer realm=\"JuicySMS API v2\""
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "The credential is missing, unrecognised, or expired. `unauthenticated` when no bearer token was sent; `invalid_token` when it does not resolve to an account; `token_expired` when a v2 key is past its expiry. Do not retry without changing the credential.",
        "headers": {
          "WWW-Authenticate": {
            "$ref": "#/components/headers/WwwAuthenticate"
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "unauthenticated",
                        "invalid_token",
                        "token_expired"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#unauthenticated",
              "title": "Authentication required",
              "status": 401,
              "code": "unauthenticated",
              "detail": "Send your API key as `Authorization: Bearer <key>`.",
              "retryable": false
            }
          }
        }
      },
      "Forbidden": {
        "description": "The credential is valid but not permitted. `insufficient_scope` when the key lacks the scope this endpoint needs — the problem document names `required_scopes` and `granted_scopes`; `account_restricted` when the account itself is blocked, which applies to reads as well as writes.",
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "insufficient_scope",
                        "account_restricted"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#insufficient_scope",
              "title": "Insufficient scope",
              "status": 403,
              "code": "insufficient_scope",
              "detail": "This API key does not have the scope required for this endpoint.",
              "retryable": false,
              "required_scopes": [
                "orders:write"
              ],
              "granted_scopes": [
                "orders:read"
              ]
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "The bucket for this endpoint is empty. Wait `Retry-After` seconds.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "rate_limited"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#rate_limited",
              "title": "Too many requests",
              "status": 429,
              "code": "rate_limited",
              "detail": "Rate limit exceeded. Slow down and retry after the interval in the Retry-After header.",
              "retryable": true
            }
          }
        }
      },
      "InternalError": {
        "description": "Something failed on our side. The exception text is withheld in production. Retryable in principle, but back off before trying again.",
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "internal_error"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#internal_error",
              "title": "Internal server error",
              "status": 500,
              "code": "internal_error",
              "detail": "Something went wrong on our side. Try again shortly, and contact support if it persists.",
              "retryable": true
            }
          }
        }
      },
      "OrderNotFound": {
        "description": "No order with that id belongs to you. An order belonging to another account is reported identically, so this endpoint cannot be used to enumerate other customers' order ids.",
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "order_not_found"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#order_not_found",
              "title": "Order not found",
              "status": 404,
              "code": "order_not_found",
              "detail": "No order with id 8124007.",
              "retryable": false
            }
          }
        }
      },
      "OrderNotOpen": {
        "description": "The order is no longer `pending` and cannot be modified. This is the normal answer to a race between your cancel and the code arriving — check `order_status`, because `completed` means the message landed and you were charged for a code you can still read from `GET /orders/{order}/messages`. The field is `order_status` and not `status`: RFC 9457 reserves `status` for the HTTP status, so the order's own state has to travel under a different name.",
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "required": [
                    "order_status"
                  ],
                  "properties": {
                    "code": {
                      "enum": [
                        "order_not_open"
                      ]
                    },
                    "order_status": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/OrderStatus"
                        }
                      ],
                      "description": "The state the order is actually in. Never `pending` — that is the state in which this error does not occur."
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#order_not_open",
              "title": "Order is no longer open",
              "status": 409,
              "code": "order_not_open",
              "detail": "This order is completed and can no longer be modified.",
              "retryable": false,
              "order_status": "completed"
            }
          }
        }
      },
      "RentalNotFound": {
        "description": "No rental with that id belongs to you. A rental belonging to another account is reported identically.",
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "rental_not_found"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#rental_not_found",
              "title": "Rental not found",
              "status": 404,
              "code": "rental_not_found",
              "detail": "No rental with id 9142.",
              "retryable": false
            }
          }
        }
      },
      "WebhookEndpointNotFound": {
        "description": "No webhook endpoint with that id belongs to you.",
        "content": {
          "application/problem+json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Problem"
                },
                {
                  "properties": {
                    "code": {
                      "enum": [
                        "webhook_endpoint_not_found"
                      ]
                    }
                  }
                }
              ]
            },
            "example": {
              "type": "https://juicysms.com/api/errors#webhook_endpoint_not_found",
              "title": "Webhook endpoint not found",
              "status": 404,
              "code": "webhook_endpoint_not_found",
              "detail": "No webhook endpoint with id 74.",
              "retryable": false
            }
          }
        }
      }
    },
    "schemas": {
      "Money": {
        "type": "object",
        "title": "Money",
        "description": "A monetary amount. Never a float: prices are stored as doubles and float arithmetic on them silently shaves cents, so the wire format carries the exact integer in minor units and an exact decimal string, and you pick whichever your language handles safely.",
        "required": [
          "amount",
          "amount_minor",
          "currency"
        ],
        "additionalProperties": false,
        "properties": {
          "amount": {
            "type": "string",
            "description": "Exact decimal, always two places.",
            "pattern": "^-?\\d+\\.\\d{2}$",
            "examples": [
              "0.50",
              "15.00"
            ]
          },
          "amount_minor": {
            "type": "integer",
            "description": "The same amount in minor units (cents). This is the authoritative value.",
            "examples": [
              50,
              1500
            ]
          },
          "currency": {
            "type": "string",
            "description": "ISO 4217. Always `EUR` — amounts are never converted, because the site's display-currency rate table is stale and quoting from it would hand you wrong numbers.",
            "enum": [
              "EUR"
            ]
          }
        }
      },
      "Problem": {
        "type": "object",
        "title": "Problem",
        "description": "An RFC 9457 problem document. Every failure in this API has this shape, served as `application/problem+json`. Branch on `code`: it is the stable contract, the HTTP status is derived from it, and `detail` is prose that may be reworded at any time.",
        "required": [
          "type",
          "title",
          "status",
          "code",
          "detail",
          "retryable"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "Absolute URL of the documentation for this specific failure.",
            "examples": [
              "https://juicysms.com/api/errors#out_of_stock"
            ]
          },
          "title": {
            "type": "string",
            "description": "Short human-readable summary. Stable per `code`, never localized.",
            "examples": [
              "No numbers available"
            ]
          },
          "status": {
            "type": "integer",
            "description": "The HTTP status, repeated in the body. Derived from `code`, so one code always means one status.",
            "examples": [
              409
            ]
          },
          "code": {
            "type": "string",
            "description": "The machine-readable error code. This is the complete vocabulary; codes may be added over time but are never renamed or repurposed, so an unrecognised value should be treated as its HTTP status class.",
            "enum": [
              "account_restricted",
              "concurrent_order_limit",
              "country_not_supported",
              "feature_unavailable",
              "insufficient_balance",
              "insufficient_scope",
              "internal_error",
              "invalid_package",
              "invalid_token",
              "maintenance",
              "method_not_allowed",
              "not_found",
              "number_unavailable",
              "order_not_completed",
              "order_not_found",
              "order_not_open",
              "out_of_stock",
              "price_above_maximum",
              "rate_limited",
              "rental_expired",
              "rental_not_found",
              "reuse_not_supported",
              "service_busy",
              "service_not_found",
              "token_expired",
              "unauthenticated",
              "unsupported_media_type",
              "validation_failed",
              "webhook_endpoint_not_found"
            ],
            "x-code-status": {
              "unauthenticated": 401,
              "invalid_token": 401,
              "token_expired": 401,
              "insufficient_balance": 402,
              "insufficient_scope": 403,
              "account_restricted": 403,
              "not_found": 404,
              "order_not_found": 404,
              "rental_not_found": 404,
              "service_not_found": 404,
              "webhook_endpoint_not_found": 404,
              "method_not_allowed": 405,
              "out_of_stock": 409,
              "concurrent_order_limit": 409,
              "order_not_open": 409,
              "number_unavailable": 409,
              "rental_expired": 409,
              "unsupported_media_type": 415,
              "validation_failed": 422,
              "country_not_supported": 422,
              "order_not_completed": 422,
              "reuse_not_supported": 422,
              "price_above_maximum": 422,
              "invalid_package": 422,
              "rate_limited": 429,
              "internal_error": 500,
              "service_busy": 503,
              "maintenance": 503,
              "feature_unavailable": 403
            },
            "x-retryable-codes": [
              "out_of_stock",
              "service_busy",
              "rate_limited",
              "maintenance",
              "internal_error"
            ]
          },
          "detail": {
            "type": "string",
            "description": "Human-readable explanation of this particular occurrence. Prose: log it, show it to a developer, but never parse it.",
            "examples": [
              "No Netherlands number is available for WhatsApp right now."
            ]
          },
          "retryable": {
            "type": "boolean",
            "description": "Whether repeating the identical request can plausibly succeed later. Derived from `code`, so you do not have to keep your own table of which failures are transient. False does not mean \"never retry\" — it means retrying without changing something will fail the same way."
          },
          "errors": {
            "type": "object",
            "description": "Only on `validation_failed`. Field name to the list of messages for that field, matching Laravel's error bag.",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "examples": [
              {
                "country": [
                  "Unknown country. One-time numbers are available for USA, UK, NL and PH."
                ]
              }
            ]
          }
        },
        "additionalProperties": true
      },
      "CursorPagination": {
        "type": "object",
        "title": "CursorPagination",
        "description": "Cursor pagination state. Keyed on the primary key, so a page is stable while rows are being appended and costs the same at any depth — unlike an offset, which walks and discards rows and shifts under you.",
        "required": [
          "limit",
          "has_more",
          "next_cursor",
          "previous_cursor"
        ],
        "additionalProperties": false,
        "properties": {
          "limit": {
            "type": "integer",
            "description": "Rows requested per page.",
            "examples": [
              25
            ]
          },
          "has_more": {
            "type": "boolean",
            "description": "Whether another page exists. Loop on this rather than on `data` being non-empty."
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pass as `?cursor=` for the next page. Null on the last page. Opaque.",
            "examples": [
              "eyJpZCI6ODEyMzk3NCwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ"
            ]
          },
          "previous_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pass as `?cursor=` for the previous page. Null on the first page.",
            "examples": [
              null
            ]
          }
        }
      },
      "OrderStatus": {
        "type": "string",
        "title": "OrderStatus",
        "description": "The lifecycle of a one-time order. Derived, not stored: `completed` outranks `canceled`, so an order that received its code and was then skipped reports `completed` — because the customer got the code and was charged for it, and reporting `canceled` would be a lie about their money.",
        "enum": [
          "pending",
          "completed",
          "canceled",
          "expired"
        ],
        "x-enum-descriptions": {
          "pending": "Number issued, no message yet, still inside the ten-minute window.",
          "completed": "A message arrived. The only state that is charged for.",
          "canceled": "Cancelled or skipped before a message arrived. Not charged.",
          "expired": "The window elapsed with no message. Not charged."
        }
      },
      "RentalStatus": {
        "type": "string",
        "title": "RentalStatus",
        "description": "Derived from the expiry against the current time. There is no separate cancelled state: turning `auto_renew` off leaves a rental `active` until it lapses.",
        "enum": [
          "active",
          "expired"
        ]
      },
      "RentalStatusFilter": {
        "type": "string",
        "title": "RentalStatusFilter",
        "description": "The `?status=` filter on the rental list. Defaults to `all` — expired rentals are the customer's record of what they paid for, so they are not hidden by default.",
        "enum": [
          "active",
          "expired",
          "all"
        ],
        "default": "all"
      },
      "WebhookEventName": {
        "type": "string",
        "title": "WebhookEventName",
        "description": "An event an endpoint can subscribe to. The values arrive in the envelope's `type` member and in the `JuicySMS-Event` header, so they may be added to but never renamed. `ping`, produced only by `POST /webhook-endpoints/{endpoint}/test`, is deliberately not here — it is not subscribable, and listing it would let a caller subscribe to something we never emit. The same rule removed two names that appeared in earlier drafts: `order.completed` was `sms.received` at the same instant with the same payload (an order reaches `completed` only because a message arrived), and nothing in the platform sweeps order expiry, so `order.expired` had no transition to fire on.",
        "enum": [
          "sms.received",
          "rental.sms.received",
          "rental.expiring"
        ],
        "x-enum-descriptions": {
          "sms.received": "A message arrived on a one-time order, which is also the moment the order completed and the balance was charged. This is the event that replaces polling.",
          "rental.sms.received": "A message arrived on a long-term rental.",
          "rental.expiring": "A long-term rental is within three days of expiry and will not be renewed automatically — either auto-renew is off, or the renewal charge failed. Sent once per expiry window."
        }
      },
      "ServiceRef": {
        "type": "object",
        "title": "ServiceRef",
        "description": "The service an order was placed for. Null when the service row has since been removed from the catalogue.",
        "required": [
          "id",
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "integer",
            "examples": [
              12
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "WhatsApp"
            ]
          }
        }
      },
      "Order": {
        "type": "object",
        "title": "Order",
        "description": "A one-time verification order. `messages` and `code` are present only on single-order responses — list endpoints omit them, because including them would cost a query per row.",
        "required": [
          "object",
          "id",
          "status",
          "service",
          "country",
          "country_iso",
          "phone_number",
          "phone_number_local",
          "reused_from_order_id",
          "created_at",
          "expires_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "order"
          },
          "id": {
            "type": "integer",
            "examples": [
              8124007
            ]
          },
          "status": {
            "$ref": "#/components/schemas/OrderStatus"
          },
          "service": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ServiceRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "country": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/CountryCode"
              },
              {
                "type": "null"
              }
            ],
            "description": "Our storage code for the country. Null when the number behind the order can no longer be identified — pool rows are purged."
          },
          "country_iso": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 3166-1 alpha-2 for the same country: `GB` where `country` is `UK`, `US` where it is `USA`.",
            "examples": [
              "NL"
            ]
          },
          "phone_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "E.164, with the `+`. Null when the number is no longer knowable.",
            "examples": [
              "+31686216609"
            ]
          },
          "phone_number_local": {
            "type": [
              "string",
              "null"
            ],
            "description": "The subscriber number without the country code and without a trunk zero. Some signup forms reject an E.164 string, which is why both forms are given.",
            "examples": [
              "686216609"
            ]
          },
          "reused_from_order_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The order this one reused the number of, if any. Its presence is also why `price` is halved."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-09T09:20:44Z"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the window closes. Cancelling or skipping moves this to the moment of the cancellation rather than leaving the original ten-minute value.",
            "examples": [
              "2026-08-09T09:30:44Z"
            ]
          },
          "code": {
            "type": [
              "string",
              "null"
            ],
            "description": "The verification code extracted from the first message. Best-effort and null when ambiguous; `text` on the message is always authoritative. Present only on single-order responses.",
            "examples": [
              "382914"
            ]
          },
          "messages": {
            "type": "array",
            "description": "Messages received, oldest first. Present only on single-order responses.",
            "items": {
              "$ref": "#/components/schemas/Message"
            }
          }
        },
        "additionalProperties": false
      },
      "Message": {
        "type": "object",
        "title": "Message",
        "description": "One inbound message.",
        "required": [
          "object",
          "id",
          "sender",
          "text",
          "code",
          "received_at",
          "source"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "message"
          },
          "id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Null for provider-sourced messages, which have no row of their own.",
            "examples": [
              55210394
            ]
          },
          "sender": {
            "type": [
              "string",
              "null"
            ],
            "description": "The sender as the network reported it — an alphanumeric sender id or a number. Null for provider-sourced messages, which arrive as a bare body.",
            "examples": [
              "WhatsApp"
            ]
          },
          "text": {
            "type": "string",
            "description": "The full message body, unmodified and unprefixed.",
            "examples": [
              "Your WhatsApp code: 382-914. Don't share this code with others."
            ]
          },
          "code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Best-effort extraction: a single run of 4-8 digits, optionally hyphenated in the middle. Null when the body has no clear candidate or has several, because a wrong code is worse than none.",
            "examples": [
              "382914"
            ]
          },
          "received_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "examples": [
              "2026-08-09T09:21:58Z"
            ]
          },
          "source": {
            "type": "string",
            "description": "`sms` for a message we hold as a row on one of our own SIMs; `provider` for one a third party handed us as a bare body with no sender.",
            "enum": [
              "sms",
              "provider"
            ]
          }
        }
      },
      "Rental": {
        "type": "object",
        "title": "Rental",
        "description": "A long-term rental contract. `messages` is present only on single-rental responses.",
        "required": [
          "object",
          "id",
          "status",
          "country",
          "country_iso",
          "phone_number",
          "phone_number_local",
          "package",
          "auto_renew",
          "renewal_package",
          "started_at",
          "expires_at",
          "days_remaining"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "rental"
          },
          "id": {
            "type": "integer",
            "examples": [
              9142
            ]
          },
          "status": {
            "$ref": "#/components/schemas/RentalStatus"
          },
          "country": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/CountryCode"
              },
              {
                "type": "null"
              }
            ]
          },
          "country_iso": {
            "type": [
              "string",
              "null"
            ],
            "examples": [
              "NL"
            ]
          },
          "phone_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "E.164. Null when the SIM has been removed from the server the contract points at.",
            "examples": [
              "+31686216609"
            ]
          },
          "phone_number_local": {
            "type": [
              "string",
              "null"
            ],
            "examples": [
              "686216609"
            ]
          },
          "package": {
            "type": [
              "string",
              "null"
            ],
            "description": "The package currently in force.",
            "examples": [
              "3m"
            ]
          },
          "auto_renew": {
            "type": "boolean",
            "description": "A real boolean."
          },
          "renewal_package": {
            "type": [
              "string",
              "null"
            ],
            "description": "Which package an automatic renewal will buy. Not necessarily the one in force: extending changes the term without touching this.",
            "examples": [
              "1m"
            ]
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "examples": [
              "2026-06-11T12:04:00Z"
            ]
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-09-12T12:04:00Z"
            ]
          },
          "days_remaining": {
            "type": "integer",
            "minimum": 0,
            "description": "Whole days until expiry, rounded up, floored at zero for a lapsed rental.",
            "examples": [
              34
            ]
          },
          "messages": {
            "type": "array",
            "description": "Recent messages within the contract window, newest first. Present only on single-rental responses.",
            "items": {
              "$ref": "#/components/schemas/Message"
            }
          }
        }
      },
      "RentalPackage": {
        "type": "object",
        "title": "RentalPackage",
        "description": "A rental duration on offer.",
        "required": [
          "object",
          "key",
          "name",
          "days",
          "list_price",
          "your_price"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "rental_package"
          },
          "key": {
            "type": "string",
            "description": "Send this as `package` when creating or extending a rental.",
            "examples": [
              "1m",
              "3m",
              "6m",
              "1y"
            ]
          },
          "name": {
            "type": "string",
            "description": "Display label, localized to the account's language.",
            "examples": [
              "1 month"
            ]
          },
          "days": {
            "type": "integer",
            "description": "Exact days added. Not a calendar month: `1m` is 31 days, `3m` is 93, `6m` is 186, `1y` is 365.",
            "examples": [
              31
            ]
          },
          "list_price": {
            "$ref": "#/components/schemas/Money"
          },
          "your_price": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Money"
              }
            ],
            "description": "What this account will actually be charged, after any negotiated discount. Quote this one."
          }
        }
      },
      "Service": {
        "type": "object",
        "title": "Service",
        "description": "One service in the catalogue.",
        "required": [
          "object",
          "id",
          "name",
          "slug",
          "price",
          "country"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "service"
          },
          "id": {
            "type": "integer",
            "description": "Send this as `service_id` when ordering.",
            "examples": [
              12
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "WhatsApp"
            ]
          },
          "slug": {
            "type": "string",
            "description": "URL-safe form of the name. Convenience for building links; `id` is the identifier to key on, since a service can be renamed.",
            "examples": [
              "whatsapp"
            ]
          },
          "price": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Money"
              },
              {
                "type": "null"
              }
            ],
            "description": "This account's effective price in the requested country, including any per-account override. Null when the request did not name a country — that is not the same as free."
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "description": "The country `price` is quoted for, echoed back.",
            "examples": [
              "NL"
            ]
          }
        }
      },
      "CountryCode": {
        "type": "string",
        "title": "CountryCode",
        "description": "Our storage code for a country. Not always the ISO code: `UK` and `USA` are what every row in the database uses. On input, the ISO forms `GB` and `US`, the alpha-3 forms, and the English names are all accepted in any casing.",
        "enum": [
          "NL",
          "UK",
          "USA",
          "DE",
          "PH",
          "PL"
        ]
      },
      "Account": {
        "type": "object",
        "title": "Account",
        "description": "The account behind the presented key. Deliberately a short allowlist — the underlying user record also holds the API token, the TOTP secret and thirteen ad-attribution columns, none of which belong in a response.",
        "required": [
          "object",
          "id",
          "email",
          "balance",
          "parallel_orders_allowed",
          "scopes",
          "created_at"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "account"
          },
          "id": {
            "type": "integer",
            "examples": [
              48213
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "examples": [
              "dev@example.com"
            ]
          },
          "balance": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Money"
              }
            ],
            "description": "Total spendable balance across referral, purchased and top-up pots, in the order they are spent."
          },
          "parallel_orders_allowed": {
            "type": "boolean",
            "description": "Whether this account may hold several open one-time orders at once. When false, a second `POST /orders` while one is open is `concurrent_order_limit`."
          },
          "scopes": {
            "type": "array",
            "description": "What the presented key can do. A legacy `users.api_token` reports `[\"*\"]` verbatim rather than the expanded list, because that key genuinely is unscoped.",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "orders:read",
                "orders:write",
                "account:read"
              ]
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "examples": [
              "2025-11-02T18:41:07Z"
            ]
          }
        }
      },
      "WebhookEndpoint": {
        "type": "object",
        "title": "WebhookEndpoint",
        "description": "A URL we push events to. At most five per account.",
        "required": [
          "object",
          "id",
          "url",
          "description",
          "events",
          "enabled",
          "disabled_at",
          "consecutive_failures",
          "last_delivery_at",
          "created_at",
          "updated_at"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "webhook_endpoint"
          },
          "id": {
            "type": "integer",
            "examples": [
              74
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS, port 80 or 443, no embedded credentials, and not resolving to a private, loopback, link-local or reserved address — we fetch it from inside our own network.",
            "examples": [
              "https://hooks.example.com/juicysms"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free-text label for your own bookkeeping. Never sent to the endpoint.",
            "examples": [
              "production"
            ]
          },
          "events": {
            "type": "array",
            "description": "What this endpoint receives. Typed loosely rather than as an enum because a deployment newer than this document may know event names it does not.",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "sms.received",
                "rental.sms.received"
              ]
            ]
          },
          "enabled": {
            "type": "boolean",
            "description": "False pauses delivery without discarding the configuration or rotating the secret."
          },
          "disabled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Non-null means **we** switched the endpoint off after a run of consecutive failures, not you. Re-arm with `PATCH {\"enabled\": true}`, which also clears this and resets the counter.",
            "examples": [
              null
            ]
          },
          "consecutive_failures": {
            "type": "integer",
            "description": "Failed deliveries in a row. Reset by a success, or by re-enabling the endpoint.",
            "examples": [
              0
            ]
          },
          "last_delivery_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When we last attempted a delivery here. Null if we never have."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-30T14:22:09Z"
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "examples": [
              "2026-08-02T09:00:11Z"
            ]
          },
          "secret": {
            "type": "string",
            "description": "The HMAC signing key, present **only** in the 201 from `POST /webhook-endpoints` and in no other response, ever. Store it immediately: there is no way to read it back, and recovering it means deleting the endpoint and registering a new one.",
            "examples": [
              "whsec_9f2c1b7a4d0e6538ab21c4f7e90d3b6205a1c8d47e39f10b6c2a8d5e4f7093ab"
            ]
          },
          "secret_notice": {
            "type": "string",
            "description": "Accompanies `secret` on the create response, saying the same thing in prose for anyone reading a raw payload.",
            "examples": [
              "Store this now. It is shown only in this response and cannot be retrieved again."
            ]
          }
        }
      },
      "WebhookDelivery": {
        "type": "object",
        "title": "WebhookDelivery",
        "description": "One event on its way to one endpoint, across however many attempts it takes — a delivery is one row, not one row per attempt. Retried up to 6 times with a widening backoff (10s, 1m, 5m, 30m, 2h) on a 10-second timeout. Pruned after thirty days, and deleted with the endpoint.",
        "required": [
          "object",
          "id",
          "endpoint_id",
          "event",
          "succeeded",
          "attempt",
          "response_status",
          "response_body",
          "error",
          "duration_ms",
          "delivered_at",
          "next_attempt_at",
          "created_at",
          "payload"
        ],
        "additionalProperties": false,
        "properties": {
          "object": {
            "type": "string",
            "const": "webhook_delivery"
          },
          "id": {
            "type": "integer",
            "description": "Also the `JuicySMS-Delivery-Id` header on the request we sent, and `whd_<id>` in the envelope.",
            "examples": [
              990211
            ]
          },
          "endpoint_id": {
            "type": "integer",
            "examples": [
              74
            ]
          },
          "event": {
            "type": "string",
            "description": "The event type. Either a subscribable event or `ping`, which only `POST /webhook-endpoints/{endpoint}/test` produces.",
            "examples": [
              "sms.received",
              "ping"
            ]
          },
          "succeeded": {
            "type": "boolean",
            "description": "Whether the endpoint has acknowledged with a 2xx. False with a null `next_attempt_at` means the attempts are exhausted and this event will not be delivered."
          },
          "attempt": {
            "type": "integer",
            "description": "Tries made so far, out of a maximum of 6. Zero on a delivery that has been queued but not yet attempted.",
            "examples": [
              6
            ]
          },
          "response_status": {
            "type": [
              "integer",
              "null"
            ],
            "description": "What your server answered on the last try. Null when there was no response at all — a timeout, a TLS failure, or DNS.",
            "examples": [
              500
            ]
          },
          "response_body": {
            "type": [
              "string",
              "null"
            ],
            "description": "The first 2 KB of your response body, kept for debugging and marked `… [truncated]` when there was more. The cap is on what we read off the socket, not on what we store — a response is streamed and the connection dropped once we have our 2 KB, so an endpoint answering with a gigabyte costs us a gigabyte of neither memory nor bandwidth.",
            "examples": [
              "{\"error\":\"handler threw\"}"
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "The transport-level failure, when there was one — the reason no HTTP status exists to report.",
            "examples": [
              null
            ]
          },
          "duration_ms": {
            "type": "integer",
            "description": "How long the last attempt took, in milliseconds. Compare against the 10-second timeout when diagnosing dropped deliveries.",
            "examples": [
              812
            ]
          },
          "delivered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the endpoint acknowledged. Null until it does."
          },
          "next_attempt_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the next retry is due. Null once the delivery has succeeded or exhausted its attempts."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-09T09:21:59Z"
            ]
          },
          "payload": {
            "type": "object",
            "description": "The `data` member of the envelope that was signed and sent — not the whole envelope. Reconstruct the rest from `WebhookEventEnvelope` if you are verifying a signature after the fact. For `sms.received` it is `{order, message}`, for `rental.sms.received` it is `{rental, message}` and for `rental.expiring` it is `{rental}` — all shaped like the REST resources, so one parser covers both the pull and the push. Note that a `sms.received` `message` always reports `source: \"provider\"` and a null `id`: the body reaches the one-time completion path as a string whichever inventory served the number, so there is no `sms` row to point at even when one exists. A `rental.sms.received` `message` does have a row, and reports `source: \"sms\"` with its real id.",
            "additionalProperties": true
          }
        }
      },
      "WebhookEventEnvelope": {
        "type": "object",
        "title": "WebhookEventEnvelope",
        "description": "The JSON body we POST to your endpoint. Not returned by any operation in this API — it is documented here so you can type your receiver.\n\nThe request carries `JuicySMS-Signature: t=<unix>,v1=<hex>`, `JuicySMS-Event`, `JuicySMS-Delivery-Id` and `User-Agent: JuicySMS-Webhook/2.0`. The signature is `HMAC-SHA256(secret, \"<t>.<raw body>\")` — verify against the raw bytes, in constant time, and reject a `t` more than 300 seconds old.\n\n`attempt` is inside the signed body, so every retry is a distinct message with a distinct signature rather than a byte-identical resend.",
        "required": [
          "id",
          "object",
          "type",
          "api_version",
          "created_at",
          "attempt",
          "data"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "whd_990211"
            ]
          },
          "object": {
            "type": "string",
            "const": "event"
          },
          "type": {
            "type": "string",
            "examples": [
              "sms.received",
              "ping"
            ]
          },
          "api_version": {
            "type": "string",
            "examples": [
              "2026-08-09"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-09T09:21:59Z"
            ]
          },
          "attempt": {
            "type": "integer",
            "examples": [
              1
            ]
          },
          "data": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "Discovery": {
        "type": "object",
        "title": "Discovery",
        "description": "The API's self-description, as served by `GET /`. A signpost only — the detail is in the OpenAPI document it points at.",
        "required": [
          "object",
          "name",
          "version",
          "documentation_url",
          "openapi_url",
          "currency",
          "authentication"
        ],
        "additionalProperties": true,
        "properties": {
          "object": {
            "type": "string",
            "const": "api"
          },
          "name": {
            "type": "string",
            "examples": [
              "JuicySMS API"
            ]
          },
          "version": {
            "type": "string",
            "description": "The version of the deployment answering, matching the `X-Api-Version` header and `info.version` in the OpenAPI document.",
            "examples": [
              "2026-08-09"
            ]
          },
          "documentation_url": {
            "type": "string",
            "format": "uri"
          },
          "openapi_url": {
            "type": "string",
            "format": "uri"
          },
          "currency": {
            "type": "string",
            "description": "Named here because every amount in every response is denominated in it and nothing is ever converted.",
            "examples": [
              "EUR"
            ]
          },
          "authentication": {
            "type": "object",
            "description": "How to authenticate, spelled out for a developer who has just landed on the base URL.",
            "additionalProperties": true,
            "properties": {
              "scheme": {
                "type": "string",
                "examples": [
                  "Bearer"
                ]
              },
              "header": {
                "type": "string",
                "examples": [
                  "Authorization: Bearer <api_key>"
                ]
              },
              "note": {
                "type": "string"
              }
            }
          }
        }
      },
      "Health": {
        "type": "object",
        "title": "Health",
        "description": "Liveness only, answered from memory. Says nothing about stock, about your account, or about any dependency. Note it carries no `object` discriminator: it is a probe response, not a resource.",
        "required": [
          "status",
          "time"
        ],
        "additionalProperties": true,
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ]
          },
          "time": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-09T09:14:22Z"
            ]
          }
        }
      },
      "CreateOrderRequest": {
        "type": "object",
        "title": "CreateOrderRequest",
        "required": [
          "service_id",
          "country"
        ],
        "additionalProperties": false,
        "properties": {
          "service_id": {
            "type": "integer",
            "minimum": 1,
            "description": "From `GET /services`.",
            "examples": [
              12
            ]
          },
          "country": {
            "type": "string",
            "maxLength": 32,
            "description": "Storage code, ISO code, alpha-3 or English name, any casing. Must be a country sold one-time: `USA`, `UK`, `NL`, `PH`, `PL`, or the unlisted `DE`. Required. An unrecognised country is rejected rather than substituted.",
            "examples": [
              "NL",
              "GB",
              "usa"
            ]
          },
          "max_price": {
            "type": [
              "string",
              "number"
            ],
            "description": "A ceiling on what you are willing to pay, in EUR major units. If the current price exceeds it, nothing is allocated and the call fails with `price_above_maximum`. Without it, an integration that has been running for a year silently accepts whatever the price has become.",
            "examples": [
              "0.75"
            ]
          }
        }
      },
      "CreateRentalRequest": {
        "type": "object",
        "title": "CreateRentalRequest",
        "required": [
          "country",
          "package",
          "auto_renew"
        ],
        "additionalProperties": false,
        "properties": {
          "country": {
            "type": "string",
            "maxLength": 32,
            "description": "A country that can be rented. In practice only NL and UK carry for-hire stock.",
            "examples": [
              "NL"
            ]
          },
          "package": {
            "type": "string",
            "description": "A `key` from `GET /rental-packages`.",
            "examples": [
              "3m"
            ]
          },
          "auto_renew": {
            "type": "boolean",
            "description": "Required, not optional. Required rather than defaulted, because a recurring charge rides on it."
          }
        }
      },
      "ExtendRentalRequest": {
        "type": "object",
        "title": "ExtendRentalRequest",
        "required": [
          "package"
        ],
        "additionalProperties": false,
        "properties": {
          "package": {
            "type": "string",
            "description": "A `key` from `GET /rental-packages`. Its days are added to the current expiry, not to today. This does not change `renewal_package`.",
            "examples": [
              "1m"
            ]
          }
        }
      },
      "UpdateRentalRequest": {
        "type": "object",
        "title": "UpdateRentalRequest",
        "description": "Only the fields you send are changed. Omitting a field leaves it alone — it does not reset it.",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "auto_renew": {
            "type": "boolean",
            "description": "Whether to charge for another term when this one ends. Turning it off is how you cancel: the number stays yours until `expires_at`."
          },
          "renewal_package": {
            "type": "string",
            "description": "Which package the automatic renewal should buy. May differ from the package currently in force.",
            "examples": [
              "1m"
            ]
          }
        }
      },
      "CreateWebhookEndpointRequest": {
        "type": "object",
        "title": "CreateWebhookEndpointRequest",
        "required": [
          "url",
          "events"
        ],
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 500,
            "description": "HTTPS, port 80 or 443, no credentials in the URL, and the host must not resolve to a private, loopback, link-local or reserved address. Deliveries time out after 10 seconds, so acknowledge first and do your work afterwards.",
            "examples": [
              "https://hooks.example.com/juicysms"
            ]
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "description": "Which events to receive. At least one, and every name must be from the catalogue — there is no wildcard on registration, so a new event type needs a PATCH before it reaches you.",
            "items": {
              "$ref": "#/components/schemas/WebhookEventName"
            },
            "examples": [
              [
                "sms.received",
                "rental.sms.received"
              ]
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Free-text label for your own bookkeeping. Never sent to the endpoint.",
            "examples": [
              "production"
            ]
          },
          "enabled": {
            "type": "boolean",
            "default": true,
            "description": "Register the endpoint switched off — useful for provisioning before the receiver is deployed."
          }
        }
      },
      "UpdateWebhookEndpointRequest": {
        "type": "object",
        "title": "UpdateWebhookEndpointRequest",
        "description": "Only the fields you send are changed. `events` is replaced wholesale, not merged.",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 500,
            "description": "Validated exactly as strictly as on create.",
            "examples": [
              "https://hooks.example.com/juicysms"
            ]
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/WebhookEventName"
            },
            "examples": [
              [
                "sms.received",
                "rental.expiring"
              ]
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "enabled": {
            "type": "boolean",
            "description": "False pauses delivery without discarding the configuration or rotating the secret. True also re-arms an endpoint our circuit breaker switched off, clearing `disabled_at` and resetting `consecutive_failures`."
          }
        }
      }
    }
  }
}
