From 3cd1c9d42ada0ef823ef4cc4ee0881899dd1205b Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Sun, 15 Nov 2026 00:05:21 +0200 Subject: [PATCH] Version 0.6.1: the spec at API 1.20.5, payment receipts, content_type optional The saved spec follows the public document at API 1.20.5. billing.get_invoice_receipt and billing.get_topup_receipt return PaymentReceipt. documents.create_upload leaves content_type out unless it is given. The API then reads the type from the filename. NotFoundError.field and ConflictError.state read the error details. Tests cover the two receipts, the upload body and both details. --- CHANGELOG.md | 21 + README.md | 10 +- pyproject.toml | 2 +- spec/openapi.yaml | 796 ++++++++++++++++++++++++++++++- src/faxforagents/__init__.py | 2 +- src/faxforagents/async_client.py | 18 +- src/faxforagents/client.py | 18 +- src/faxforagents/errors.py | 18 + src/faxforagents/models.py | 23 +- tests/test_async.py | 35 ++ tests/test_client.py | 110 +++++ tests/test_surface.py | 2 + 12 files changed, 1034 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba4a51..e01de0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.6.1 (2026-11-15) + +Follows API 1.20.5. The saved spec has 147 operations and the models are regenerated from it. + +New: `billing.get_invoice_receipt(invoice_id)` and `billing.get_topup_receipt(topup_session_id)`, on both clients. +Each returns the new model `PaymentReceipt`. `url` is the hosted receipt and `pdf_url` the same receipt as a PDF. +Both need the scope `billing:read`. A payment that was not made raises `ConflictError`. + +Changed: `documents.create_upload` no longer needs `content_type`. Its default was `application/pdf`. It is now `None`, and the field is left out of the request. +The API then reads the type from the filename. `scan.png` opens as `image/png`, and 0.6.0 opened it as a PDF. +A filename of an unsupported format, such as `letter.docx`, raises `InvalidRequestError` with the code `unsupported_format` on this call. +`UploadCreate.content_type` is `str | None` with the default `None`. `documents.upload` names the type itself, as before. + +New: `NotFoundError.field` is `details["field"]`. On `quotes.create` it is `document_id` or `from`, the id that was not found. +New: `ConflictError.state` is `details["state"]`. `faxes.get_receipt` on a fax that has not ended carries the fax state there. +Both are `None` when the server sent no such detail. + +The API now refuses an auto top-up threshold above 1000 USD and an agent `contact_email` that is not an address. Both raise `InvalidRequestError`. +`Capabilities.scopes` is described as every scope the platform defines. `me()` returns the scopes of the API key. +Other model names, fields and optionality are kept. + ## 0.6.0 (2026-11-13) Follows API 1.20.0. The saved spec has 145 operations and the models are regenerated from it. diff --git a/README.md b/README.md index 9b2b05f..ee36a61 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ The same in async form, with `AsyncClient` and `await` on every call. - `documents.upload(path)` runs the whole upload path for a PDF or an image (PNG, JPG, TIFF): the upload session, the signed PUT with the content type read from the filename, the sha256 check, the document. An image becomes gray letter pages. +- `documents.create_upload(filename=..., size_bytes=...)` needs no `content_type`. The API reads the + type from the filename and refuses a filename of another format at once with `unsupported_format`. - `faxes.wait(fax_id)` and `approvals.wait(approval_id)` poll with a timeout. - `verify_webhook(secret, headers, body)` checks the Standard Webhooks signature on a delivery. - Models are pydantic v2, generated from the public OpenAPI document. Money is an integer count of @@ -108,7 +110,7 @@ each call needs. | `ffa.faxes` | `create`, `list`, `iter`, `get`, `wait`, `list_events`, `list_attempts`, `get_receipt`, `get_receipt_pdf`, `cancel` | | `ffa.webhooks` | `list`, `create`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `replay_delivery` | | `ffa.events` | `list`, `iter` | -| `ffa.billing` | `balance`, `list_ledger`, `iter_ledger`, `iter_topup_sessions`, `iter_invoices`, settings, top-ups, auto top-ups, budgets, `usage`, exports, invoices, subscriptions, `create_portal_session` | +| `ffa.billing` | `balance`, `list_ledger`, `iter_ledger`, `iter_topup_sessions`, `iter_invoices`, settings, top-ups, auto top-ups, budgets, `usage`, exports, invoices, `get_invoice_receipt`, `get_topup_receipt`, subscriptions, `create_portal_session` | | `ffa.platform` | `capabilities`, `service_status` | | `ffa.sandbox` | `list_scenarios`, `create_scenario`, `create_inbound_fax`, `reset` | | `ffa.approvals` | `request`, `request_help`, `list`, `iter`, `get`, `wait`, `approve`, `reject`, `revoke` | @@ -152,6 +154,10 @@ except APIError as exc: `ServerError` (5xx) are all `APIError`. `APIConnectionError` and `APITimeoutError` mean the API was not reached. +`NotFoundError.field` names the id that was not found when the API says which: on a quote it is +`document_id` or `from`. `ConflictError.state` is the state of a fax whose receipt was asked for +before the fax ended. Both are `None` when the API sent no such detail. + Every refusal is the same envelope, a 422 for a field that fails validation included. A 413 `payload_too_large` is a plain `APIError`: the body is over the limit of its route. The published limits are in one table at . @@ -299,7 +305,7 @@ ruff check src tests && mypy src sh scripts/truthcheck.sh ``` -`spec/openapi.yaml` follows the public document at API version 1.20.0 and keeps the schemas of the earlier live documents. +`spec/openapi.yaml` follows the public document at API version 1.20.5 and keeps the schemas of the earlier live documents. The two validation error schemas are the exception. 0.5.0 removed them because the server never sent that shape. 0.6.0 removed two cost fields of an extraction, because the server stopped sending them. It also retains earlier public model names and route-documented response additions. diff --git a/pyproject.toml b/pyproject.toml index e4bb447..3f52a60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "faxforagents" -version = "0.6.0" +version = "0.6.1" description = "Python client for Fax for Agents: a fax API, a hosted MCP server and SDKs built so AI agents can send and receive faxes." readme = "README.md" requires-python = ">=3.10" diff --git a/spec/openapi.yaml b/spec/openapi.yaml index a9b465a..9ce72e6 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -1,4 +1,4 @@ -# Fax for Agents API, refreshed from the public document of https://github.com/faxforagents/openapi at API 1.20.0. +# Fax for Agents API, refreshed from the public document of https://github.com/faxforagents/openapi at API 1.20.5. # All earlier schemas are kept. Existing SDK routes, model names and serializer additions are retained. openapi: 3.1.0 info: @@ -26,7 +26,7 @@ info: x-logo: url: https://faxforagents.com/brand/logo-512.png altText: Fax for Agents - version: 1.20.0 + version: 1.20.5 servers: - url: https://api.faxforagents.com description: Production (live and test keys) @@ -121,6 +121,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -162,6 +164,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -230,6 +236,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -280,6 +288,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -318,6 +328,8 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '413': $ref: '#/components/responses/PayloadTooLarge' '422': @@ -400,6 +412,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '413': $ref: '#/components/responses/PayloadTooLarge' '422': @@ -458,6 +472,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -508,6 +526,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -548,6 +568,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '413': $ref: '#/components/responses/PayloadTooLarge' '422': @@ -611,6 +633,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -656,6 +680,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -705,6 +731,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -744,6 +772,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -783,6 +813,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -830,6 +862,8 @@ paths: description: Successful Response '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' security: @@ -867,6 +901,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -958,6 +994,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1016,6 +1056,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1074,6 +1116,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1163,6 +1209,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1208,6 +1256,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1248,6 +1298,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1287,6 +1339,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1328,6 +1382,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1367,6 +1423,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1417,6 +1475,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1456,6 +1516,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -1497,6 +1559,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1569,6 +1635,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1618,6 +1688,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1668,6 +1740,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1718,6 +1792,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1782,6 +1858,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1838,6 +1916,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1896,6 +1976,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -1932,6 +2014,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -1984,6 +2068,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2022,6 +2108,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' security: @@ -2059,6 +2147,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2111,6 +2203,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2170,6 +2266,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2215,6 +2313,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2228,6 +2328,51 @@ paths: mutating: false idempotency_key: false agent_key_allowed: true + /v1/topup-sessions/{topup_session_id}/receipt: + get: + tags: + - billing + summary: Stripe's receipt for a paid top-up (manual, setup or automatic) + operationId: get_topup_receipt + parameters: + - name: topup_session_id + in: path + required: true + schema: + type: string + title: Topup Session Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentReceipt' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '422': + $ref: '#/components/responses/UnprocessableContent' + '429': + $ref: '#/components/responses/TooManyRequests' + '503': + $ref: '#/components/responses/ServiceUnavailable' + security: + - bearerAuth: [] + - apiKeyHeader: [] + x-required-scope: billing:read + x-llm: + scope: billing:read + mutating: false + idempotency_key: false + agent_key_allowed: true /v1/auto-topups: post: tags: @@ -2257,6 +2402,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2298,6 +2447,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -2352,6 +2503,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2382,6 +2535,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -2425,6 +2580,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2491,6 +2650,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2555,6 +2718,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2599,6 +2764,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2654,6 +2823,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2749,6 +2920,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2790,6 +2963,53 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + '422': + $ref: '#/components/responses/UnprocessableContent' + '429': + $ref: '#/components/responses/TooManyRequests' + '503': + $ref: '#/components/responses/ServiceUnavailable' + security: + - bearerAuth: [] + - apiKeyHeader: [] + x-required-scope: billing:read + x-llm: + scope: billing:read + mutating: false + idempotency_key: false + agent_key_allowed: true + /v1/invoices/{invoice_id}/receipt: + get: + tags: + - billing + summary: Stripe's receipt for a paid invoice + operationId: get_invoice_receipt + parameters: + - name: invoice_id + in: path + required: true + schema: + type: string + title: Invoice Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentReceipt' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2828,6 +3048,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -2872,6 +3094,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' security: @@ -2906,6 +3130,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3010,6 +3236,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -3031,6 +3259,8 @@ paths: tags: - sandbox summary: Select the outcome of the next sends + description: 'The choice lasts 24 hours. A destination has one fixture: a second call for the same destination + replaces the first, and a call with no destination replaces the organization-wide one.' operationId: create_sandbox_scenario requestBody: required: true @@ -3051,6 +3281,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3109,6 +3343,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3157,6 +3395,8 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3204,6 +3444,8 @@ paths: $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' security: @@ -3233,6 +3475,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' security: @@ -3270,6 +3514,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3312,6 +3560,8 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3360,6 +3610,8 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3451,6 +3703,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -3511,6 +3765,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3556,6 +3814,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3586,6 +3846,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -3627,6 +3889,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3689,6 +3955,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3715,6 +3983,13 @@ paths: schema: $ref: '#/components/schemas/ApprovalRequestCreate' responses: + '200': + description: The quote already has a pending request. It is returned as it stands and no second request + is made. + content: + application/json: + schema: + $ref: '#/components/schemas/ApprovalRequest' '201': description: Successful Response content: @@ -3729,6 +4004,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3795,6 +4074,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3840,6 +4121,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3896,6 +4179,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -3952,6 +4239,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4008,6 +4299,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4038,6 +4333,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -4079,6 +4376,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4166,6 +4467,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4197,6 +4500,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -4244,6 +4549,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4300,6 +4609,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4355,6 +4666,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4394,6 +4709,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4434,6 +4751,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4475,6 +4796,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -4518,6 +4841,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4567,6 +4894,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4622,6 +4951,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4678,6 +5011,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4734,6 +5071,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4794,6 +5133,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4839,6 +5180,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4896,6 +5239,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4945,6 +5292,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -4986,6 +5335,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5033,6 +5384,8 @@ paths: description: Successful Response '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' security: @@ -5135,6 +5488,8 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5189,6 +5544,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5237,6 +5594,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -5280,6 +5639,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5318,6 +5679,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5363,6 +5726,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5418,6 +5783,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5474,6 +5843,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5514,6 +5887,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5572,6 +5949,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5613,6 +5994,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -5656,6 +6039,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5696,6 +6081,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5746,6 +6135,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5804,6 +6197,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5879,6 +6276,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5924,6 +6323,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -5980,6 +6381,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6022,6 +6427,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6095,6 +6504,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6140,6 +6551,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6182,6 +6595,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6255,6 +6672,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6300,6 +6719,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6330,6 +6751,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '429': $ref: '#/components/responses/TooManyRequests' '404': @@ -6371,6 +6794,10 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6426,6 +6853,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6490,6 +6919,10 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': description: 'The form was refused (invalid_request), the number is not a fax destination (invalid_destination), the route is not sold one fax at a time (unsupported_destination), or the document is too large (document_too_large). @@ -6752,6 +7185,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6811,6 +7246,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6867,6 +7306,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6908,6 +7351,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -6960,6 +7405,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7026,6 +7473,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7104,6 +7553,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7181,6 +7634,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' + '413': + $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7252,6 +7709,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7308,6 +7767,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7355,6 +7816,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '410': + $ref: '#/components/responses/Gone' '422': $ref: '#/components/responses/UnprocessableContent' '429': @@ -7427,6 +7890,13 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + Gone: + description: 'What the call asks for is no longer kept. document_expired and inbox_item_expired: the document + or the received fax passed its retention date. organization_deleted: the organization is being deleted.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' PayloadTooLarge: description: 'The body is larger than the limit of this call: payload_too_large.' content: @@ -7620,6 +8090,7 @@ components: anyOf: - type: string maxLength: 254 + format: email - type: 'null' title: Contact Email description: Where decided approvals are mailed. @@ -7705,6 +8176,7 @@ components: anyOf: - type: string maxLength: 254 + format: email - type: 'null' title: Contact Email additionalProperties: false @@ -8292,9 +8764,11 @@ components: properties: threshold_micro: type: integer + maximum: 1000000000.0 minimum: 0.0 title: Threshold Micro - description: Top up when the available balance drops below this. + description: Top up when a hold or a charge leaves the available balance below this. At most 1000 USD, + the largest single top-up. amount_micro: type: integer maximum: 1000000000.0 @@ -8356,6 +8830,12 @@ components: - available_micro - held_micro title: Balance + examples: + - environment: test + currency: USD + available_micro: 100000000 + held_micro: 0 + top_up_url: https://app.faxforagents.com/app/billing BillingSettings: properties: org_id: @@ -8600,6 +9080,8 @@ components: type: string type: array title: Scopes + description: 'Every scope the platform defines, the list a new key is made from. It is not what the calling + credential holds: GET /v1/me returns that under scopes.' approvals: type: boolean title: Approvals @@ -8979,6 +9461,24 @@ components: - expires_at - deleted_at title: Document + examples: + - id: doc_01a1dba3f82e734db276332829bc4f2f + environment: test + status: ready + source: upload + filename: contract.pdf + content_type: application/pdf + size_bytes: 551 + sha256: c00b74dede1923d27ba294643d5e79cb5454089e3f1b7b151cc46aa520a2410f + page_count: 2 + render_status: rendered + render_reason: null + validation: + warnings: [] + upload_id: up_01a1dba3f80475159f9195dc92d38428 + created_at: '2026-11-14T19:58:38.630Z' + expires_at: '2026-12-14T19:58:38.739Z' + deleted_at: null DocumentCreate: properties: upload_id: @@ -9018,6 +9518,8 @@ components: type: object title: DocumentCreate description: Send exactly one of text, markdown, html or upload_id. + examples: + - text: Hello from Fax for Agents DocumentPage: properties: items: @@ -9610,6 +10112,8 @@ components: description: |- M9: an approved request authorizes the send; without one a quote that needs authorization is evaluated against the caller's delegated policies. + examples: + - quote_id: quote_01a1dba3f8b371f6aaa8664b3bf14d20 FaxJob: properties: id: @@ -9787,6 +10291,48 @@ components: - completed_at - cancel_requested_at title: FaxJob + examples: + - id: fax_01a1dba3f8dc7609be5d7b497690cf47 + environment: test + direction: outbound + state: delivered + failure_code: null + to: '+15550000001' + from: null + route: us_ca + document_id: doc_01a1dba3f82e734db276332829bc4f2f + document_sha256: c00b74dede1923d27ba294643d5e79cb5454089e3f1b7b151cc46aa520a2410f + transmitted_sha256: 9f18c276fb5160f70fecb50f8e53768a7aa631e3e2a140447d3cb4f0377eb6e4 + quote_id: quote_01a1dba3f8b371f6aaa8664b3bf14d20 + billable_pages: 2 + cover_pages: 0 + quality: fine + sender_name: null + delete_after_send: false + pages_sent: 2 + amount_micro: 0 + captured_micro: 0 + currency: USD + provider: sandbox + provider_ref: sbx_311dd5d5bc5dfd76 + client_reference: null + attempt_count: 1 + created_at: '2026-11-14T19:58:38.794Z' + updated_at: '2026-11-14T19:58:38.910Z' + completed_at: '2026-11-14T19:58:38.910Z' + cancel_requested_at: null + evidence: + quality: fine + provider: sandbox + duration_s: 24 + pages_sent: 2 + pages_total: 2 + sender_name: null + completed_at: '2026-11-14T19:58:38.905Z' + attempt_number: 1 + remote_station_id: SBX-OK + one_time_order_id: null + one_time: null FaxJobPage: properties: items: @@ -10082,6 +10628,33 @@ components: - document_id - received_at title: InboxItemDetail + examples: + - id: inb_01a1dba3f9a170e4bfe4f79df926fce6 + from: '+15550000009' + to: '+15550100001' + pages: 2 + status: received + document_id: doc_01a1dba3f9a877228761c3894daefe4b + received_at: '2026-11-14T19:58:39.009Z' + amount_micro: 0 + note: null + environment: test + number_id: null + project_id: proj_01a1dba3f790751897c1a738a5631341 + partial_content: false + media_status: stored + duration_secs: null + provider: sandbox + read_at: null + expires_at: '2027-02-12T19:58:39.009Z' + document: + id: doc_01a1dba3f9a877228761c3894daefe4b + sha256: e918cbc6023a846d99025bfe490299eb7cb18829b1d9f48fed91e59a15c87ed0 + page_count: 2 + status: ready + download: /v1/inbox/inb_01a1dba3f9a170e4bfe4f79df926fce6/download + text: null + extraction_id: null InboxItemSummary: properties: id: @@ -10203,6 +10776,27 @@ components: required: - items title: InboxPage + examples: + - items: + - id: inb_01a1dba3f9a170e4bfe4f79df926fce6 + from: '+15550000009' + to: '+15550100001' + pages: 2 + status: received + document_id: doc_01a1dba3f9a877228761c3894daefe4b + received_at: '2026-11-14T19:58:39.009Z' + amount_micro: 0 + note: null + environment: test + number_id: null + project_id: proj_01a1dba3f790751897c1a738a5631341 + partial_content: false + media_status: stored + duration_secs: null + provider: sandbox + read_at: null + expires_at: '2027-02-12T19:58:39.009Z' + next_cursor: null InboxUpdate: properties: status: @@ -11921,6 +12515,55 @@ components: - height_px - url title: PagePreview + PaymentReceipt: + properties: + url: + type: string + title: Url + description: Stripe's hosted receipt for the payment. It says what was paid and when. + pdf_url: + anyOf: + - type: string + - type: 'null' + title: Pdf Url + description: The same receipt as a PDF. + amount_micro: + type: integer + title: Amount Micro + amount_refunded_micro: + type: integer + title: Amount Refunded Micro + currency: + type: string + title: Currency + payment_intent_id: + type: string + title: Payment Intent Id + paid_at: + anyOf: + - type: string + - type: 'null' + title: Paid At + topup_session_id: + anyOf: + - type: string + - type: 'null' + title: Topup Session Id + invoice_id: + anyOf: + - type: string + - type: 'null' + title: Invoice Id + additionalProperties: true + type: object + required: + - url + - amount_micro + - amount_refunded_micro + - currency + - payment_intent_id + - paid_at + title: PaymentReceipt Policy: properties: id: @@ -12485,6 +13128,34 @@ components: - consumed_by_job_id - created_at title: Quote + examples: + - id: quote_01a1dba3f8b371f6aaa8664b3bf14d20 + environment: test + document_id: doc_01a1dba3f82e734db276332829bc4f2f + document_sha256: c00b74dede1923d27ba294643d5e79cb5454089e3f1b7b151cc46aa520a2410f + to: '+15550000001' + from: null + route: us_ca + document_pages: 2 + cover_pages: 0 + billable_pages: 2 + amount_micro: 0 + currency: USD + price_version: launch-v3 + per_page_micro: 0 + route_label: United States and Canada + cover: null + quality: fine + sender_name: null + delete_after_send: false + authorization_required: false + warnings: + - code: sandbox + message: 'test key: sandbox provider, not charged' + expires_at: '2026-11-14T20:08:38.771Z' + expired: false + consumed_by_job_id: null + created_at: '2026-11-14T19:58:38.762Z' QuoteCreate: properties: document_id: @@ -12544,6 +13215,9 @@ components: - document_id - to title: QuoteCreate + examples: + - document_id: doc_01a1dba3f82e734db276332829bc4f2f + to: '+15550000001' Receipt: properties: fax_job_id: @@ -12651,6 +13325,58 @@ components: - note - generated_at title: Receipt + examples: + - fax_job_id: fax_01a1dba3f8dc7609be5d7b497690cf47 + state: delivered + failure_code: null + to: '+15550000001' + from: null + document_sha256: c00b74dede1923d27ba294643d5e79cb5454089e3f1b7b151cc46aa520a2410f + transmitted_sha256: 9f18c276fb5160f70fecb50f8e53768a7aa631e3e2a140447d3cb4f0377eb6e4 + billable_pages: 2 + cover_pages: 0 + pages_sent: 2 + amount_micro: 0 + captured_micro: 0 + currency: USD + provider: sandbox + created_at: '2026-11-14T19:58:38.794Z' + completed_at: '2026-11-14T19:58:38.910Z' + evidence: + quality: fine + provider: sandbox + duration_s: 24 + pages_sent: 2 + pages_total: 2 + sender_name: null + completed_at: '2026-11-14T19:58:38.905Z' + attempt_number: 1 + remote_station_id: SBX-OK + attempts: + - id: att_01a1dba3f93171b4b1f537d98d422723 + fax_job_id: fax_01a1dba3f8dc7609be5d7b497690cf47 + attempt_number: 1 + provider: sandbox + provider_ref: sbx_311dd5d5bc5dfd76 + status: delivered + pages_sent: 2 + failure_code: null + evidence: + quality: fine + provider: sandbox + duration_s: 24 + pages_sent: 2 + pages_total: 2 + sender_name: null + completed_at: '2026-11-14T19:58:38.905Z' + attempt_number: 1 + remote_station_id: SBX-OK + error: null + started_at: '2026-11-14T19:58:38.860Z' + finished_at: '2026-11-14T19:58:38.907Z' + note: Delivery evidence as the carrier reported it. It is not legal proof of acceptance by the recipient. + generated_at: '2026-11-14T19:58:38.943Z' + one_time: null ReleaseRequest: properties: confirm: @@ -12737,6 +13463,10 @@ components: required: - to title: SandboxInboundCreate + examples: + - to: '+15550100001' + from: '+15550000009' + pages: 2 SandboxReset: properties: reset: @@ -13211,24 +13941,32 @@ components: minLength: 1 title: Filename content_type: - type: string - maxLength: 100 + anyOf: + - type: string + maxLength: 100 + - type: 'null' title: Content Type - description: application/pdf, image/png, image/jpeg or image/tiff. An image becomes 8-bit gray letter + description: 'application/pdf, image/png, image/jpeg or image/tiff. An image becomes 8-bit gray letter pages, since fax is monochrome. A frame wider than it is tall is rotated. A frame above 300 dpi letter is downsampled. Every TIFF frame is a page. PNG and JPEG take the first frame. The bytes must be the - declared type. - default: application/pdf + declared type. Left out, the type is read from the filename: .pdf, .png, .jpg, .jpeg, .tif or .tiff. + A name with no known ending is taken as application/pdf. A filename that ends in an office, text or + other image format is refused here with unsupported_format.' size_bytes: type: integer minimum: 1.0 title: Size Bytes + description: The exact number of bytes of the file. The session is refused at complete with 409 conflict + when the bytes received differ. additionalProperties: false type: object required: - filename - size_bytes title: UploadCreate + examples: + - filename: contract.pdf + size_bytes: 551 UploadSession: properties: id: @@ -13294,6 +14032,20 @@ components: - expires_at - completed_at title: UploadSession + examples: + - id: up_01a1dba3f80475159f9195dc92d38428 + status: pending + filename: contract.pdf + content_type: application/pdf + declared_size: 551 + received_size: 0 + sha256: null + created_at: '2026-11-14T19:58:38.595Z' + expires_at: '2026-11-14T20:58:38.596Z' + completed_at: null + upload_url: https://api.faxforagents.com/v1/uploads/up_01a1dba3f80475159f9195dc92d38428/content?exp=1794689918&sig=3f6c... + upload_method: PUT + upload_url_expires_at: '2026-11-14T20:58:38.596Z' UsageExport: properties: id: @@ -13492,6 +14244,17 @@ components: - created_at - updated_at title: Webhook + examples: + - id: wh_01a1dba3fb7f70a2b6be610f4ee731d5 + url: https://example.com/hooks/fax + description: null + event_types: + - fax.delivered + - fax.failed + status: active + previous_secret_expires_at: null + created_at: '2026-11-14T19:58:39.486Z' + updated_at: '2026-11-14T19:58:39.486Z' WebhookCreate: properties: url: @@ -13517,6 +14280,11 @@ components: required: - url title: WebhookCreate + examples: + - url: https://example.com/hooks/fax + event_types: + - fax.delivered + - fax.failed WebhookList: properties: items: @@ -13610,6 +14378,18 @@ components: - updated_at - secret title: WebhookWithSecret + examples: + - id: wh_01a1dba3fb7f70a2b6be610f4ee731d5 + url: https://example.com/hooks/fax + description: null + event_types: + - fax.delivered + - fax.failed + status: active + previous_secret_expires_at: null + created_at: '2026-11-14T19:58:39.486Z' + updated_at: '2026-11-14T19:58:39.486Z' + secret: whsec_... InsufficientBalance: title: InsufficientBalance description: The error envelope of 402 insufficient_balance, with its details written out. diff --git a/src/faxforagents/__init__.py b/src/faxforagents/__init__.py index 029aff9..5449f3d 100644 --- a/src/faxforagents/__init__.py +++ b/src/faxforagents/__init__.py @@ -18,7 +18,7 @@ from __future__ import annotations -__version__ = "0.6.0" +__version__ = "0.6.1" from faxforagents._http import ORDER_TOKEN_HEADER from faxforagents.async_client import AsyncClient diff --git a/src/faxforagents/async_client.py b/src/faxforagents/async_client.py index eabf663..88aa676 100644 --- a/src/faxforagents/async_client.py +++ b/src/faxforagents/async_client.py @@ -113,11 +113,13 @@ async def create_upload( *, filename: str, size_bytes: int, - content_type: str = "application/pdf", + content_type: str | None = None, idempotency_key: str | None = None, ) -> m.UploadSession: """Open an upload session with a signed PUT URL (25 MiB, one hour). ``content_type`` is - application/pdf, image/png, image/jpeg or image/tiff. Scope: document:write.""" + application/pdf, image/png, image/jpeg or image/tiff. Left out, the API reads the type from + the filename and the session says which it took. A filename of another format is refused + here with ``unsupported_format``. Scope: document:write.""" body = m.UploadCreate(filename=filename, size_bytes=size_bytes, content_type=content_type) return await self._h.request( "POST", "/v1/uploads", model=m.UploadSession, json=body, idempotency_key=idempotency_key @@ -463,6 +465,13 @@ async def get_topup_session(self, topup_session_id: str) -> m.TopupSession: """Read a top-up session. Scope: billing:read.""" return await self._h.request("GET", f"/v1/topup-sessions/{topup_session_id}", model=m.TopupSession) + async def get_topup_receipt(self, topup_session_id: str) -> m.PaymentReceipt: + """The payment receipt of a paid top-up, an automatic one included. ``url`` is the hosted + receipt. ``ConflictError`` while the top-up is not paid. Scope: billing:read.""" + return await self._h.request( + "GET", f"/v1/topup-sessions/{topup_session_id}/receipt", model=m.PaymentReceipt + ) + async def list_auto_topups(self) -> m.AutoTopupList: """Auto top-up settings. Scope: billing:read.""" return await self._h.request("GET", "/v1/auto-topups", model=m.AutoTopupList) @@ -539,6 +548,11 @@ async def get_invoice(self, invoice_id: str) -> m.Invoice: """One invoice. Scope: billing:read.""" return await self._h.request("GET", f"/v1/invoices/{invoice_id}", model=m.Invoice) + async def get_invoice_receipt(self, invoice_id: str) -> m.PaymentReceipt: + """The payment receipt of a paid invoice. ``url`` is the hosted receipt. ``ConflictError`` + while the invoice is not paid. Scope: billing:read.""" + return await self._h.request("GET", f"/v1/invoices/{invoice_id}/receipt", model=m.PaymentReceipt) + async def create_subscription_checkout( self, *, idempotency_key: str | None = None ) -> m.SubscriptionCheckout: diff --git a/src/faxforagents/client.py b/src/faxforagents/client.py index be7918c..159f0af 100644 --- a/src/faxforagents/client.py +++ b/src/faxforagents/client.py @@ -162,11 +162,13 @@ def create_upload( *, filename: str, size_bytes: int, - content_type: str = "application/pdf", + content_type: str | None = None, idempotency_key: str | None = None, ) -> m.UploadSession: """Open an upload session with a signed PUT URL (25 MiB, one hour). ``content_type`` is - application/pdf, image/png, image/jpeg or image/tiff. Scope: document:write.""" + application/pdf, image/png, image/jpeg or image/tiff. Left out, the API reads the type from + the filename and the session says which it took. A filename of another format is refused + here with ``unsupported_format``. Scope: document:write.""" body = m.UploadCreate(filename=filename, size_bytes=size_bytes, content_type=content_type) return self._h.request( "POST", "/v1/uploads", model=m.UploadSession, json=body, idempotency_key=idempotency_key @@ -502,6 +504,13 @@ def get_topup_session(self, topup_session_id: str) -> m.TopupSession: """Read a top-up session. Scope: billing:read.""" return self._h.request("GET", f"/v1/topup-sessions/{topup_session_id}", model=m.TopupSession) + def get_topup_receipt(self, topup_session_id: str) -> m.PaymentReceipt: + """The payment receipt of a paid top-up, an automatic one included. ``url`` is the hosted + receipt. ``ConflictError`` while the top-up is not paid. Scope: billing:read.""" + return self._h.request( + "GET", f"/v1/topup-sessions/{topup_session_id}/receipt", model=m.PaymentReceipt + ) + def list_auto_topups(self) -> m.AutoTopupList: """Auto top-up settings. Scope: billing:read.""" return self._h.request("GET", "/v1/auto-topups", model=m.AutoTopupList) @@ -574,6 +583,11 @@ def get_invoice(self, invoice_id: str) -> m.Invoice: """One invoice. Scope: billing:read.""" return self._h.request("GET", f"/v1/invoices/{invoice_id}", model=m.Invoice) + def get_invoice_receipt(self, invoice_id: str) -> m.PaymentReceipt: + """The payment receipt of a paid invoice. ``url`` is the hosted receipt. ``ConflictError`` + while the invoice is not paid. Scope: billing:read.""" + return self._h.request("GET", f"/v1/invoices/{invoice_id}/receipt", model=m.PaymentReceipt) + def create_subscription_checkout(self, *, idempotency_key: str | None = None) -> m.SubscriptionCheckout: """A hosted Checkout for the Agent workspace subscription. Scope: billing:write.""" return self._h.request( diff --git a/src/faxforagents/errors.py b/src/faxforagents/errors.py index 4fde184..3204f45 100644 --- a/src/faxforagents/errors.py +++ b/src/faxforagents/errors.py @@ -64,10 +64,28 @@ class ScopeError(APIError): class NotFoundError(APIError): """404 not_found: not an id of this organization, or deleted.""" + @property + def field(self) -> str | None: + """``details["field"]``: the request field whose id was not found, else ``None``. + + ``quotes.create`` takes two ids. The value is ``document_id`` or ``from``, the sender number. + Servers send it since API 1.20.5.""" + name = self.details.get("field") if isinstance(self.details, dict) else None + return name if isinstance(name, str) else None + class ConflictError(APIError): """409: the resource is in a state that refuses this change (``quote_consumed``, ``not_cancelable``).""" + @property + def state(self) -> str | None: + """``details["state"]``: the state the resource is in, else ``None``. + + ``faxes.get_receipt`` on a fax that has not ended answers 409 with the fax state here. Read the + fax until it has ended, then ask again. Servers send it since API 1.20.5.""" + value = self.details.get("state") if isinstance(self.details, dict) else None + return value if isinstance(value, str) else None + class OneTimeOffer(TypedDict, total=False): """``details["one_time_offer"]`` of 402 insufficient_balance: the card path for this exact send. diff --git a/src/faxforagents/models.py b/src/faxforagents/models.py index 9d8ec0a..12e6caf 100644 --- a/src/faxforagents/models.py +++ b/src/faxforagents/models.py @@ -1,6 +1,6 @@ """Typed models of the Fax for Agents API. -GENERATED by scripts/gen_models.py from spec/openapi.yaml (API version 1.20.0). Do not edit by hand. +GENERATED by scripts/gen_models.py from spec/openapi.yaml (API version 1.20.5). Do not edit by hand. Money fields ending in ``_micro`` are integer USD micro-units (39000 is $0.039). Timestamps are ISO 8601 strings in UTC. Response models accept fields the API adds later. Request models refuse unknown fields. """ @@ -472,7 +472,7 @@ class AutoTopup(_Response): class AutoTopupCreate(_Request): - threshold_micro: int = Field(..., description="Top up when the available balance drops below this.") + threshold_micro: int = Field(..., description="Top up when a hold or a charge leaves the available balance below this. At most 1000 USD, the largest single top-up.") amount_micro: int = Field(..., description="Whole dollars, 10 to 1000 USD.") daily_cap_micro: int = Field(..., description="Never more than this per UTC day.") @@ -549,7 +549,7 @@ class Capabilities(_Response): max_upload_bytes: int price_version: str rates: dict[str, Any] - scopes: list[str] + scopes: list[str] = Field(..., description="Every scope the platform defines, the list a new key is made from. It is not what the calling credential holds: GET /v1/me returns that under scopes.") approvals: bool extraction: bool | None = None extraction_pricing: dict[str, Any] | None = Field(None, description="What POST /v1/extractions charges, in micro-units. page_micro is for every page read. escalated_page_micro is added for a page the stronger model reads again. max_pages is the most pages one extraction reads. Sandbox extractions are free.") @@ -1460,6 +1460,18 @@ class PasskeyRegisterVerify(_Request): label: str | None = None +class PaymentReceipt(_Response): + url: str = Field(..., description="Stripe's hosted receipt for the payment. It says what was paid and when.") + pdf_url: str | None = Field(None, description="The same receipt as a PDF.") + amount_micro: int + amount_refunded_micro: int + currency: str + payment_intent_id: str + paid_at: str | None + topup_session_id: str | None = None + invoice_id: str | None = None + + class PolicyCreate(_Request): name: str purpose: str | None = Field(None, description="Why this policy exists. Shown, never evaluated.") @@ -1893,8 +1905,8 @@ class UploadComplete(_Request): class UploadCreate(_Request): filename: str - content_type: str = Field("application/pdf", description="application/pdf, image/png, image/jpeg or image/tiff. An image becomes 8-bit gray letter pages, since fax is monochrome. A frame wider than it is tall is rotated. A frame above 300 dpi letter is downsampled. Every TIFF frame is a page. PNG and JPEG take the first frame. The bytes must be the declared type.") - size_bytes: int + content_type: str | None = Field(None, description="application/pdf, image/png, image/jpeg or image/tiff. An image becomes 8-bit gray letter pages, since fax is monochrome. A frame wider than it is tall is rotated. A frame above 300 dpi letter is downsampled. Every TIFF frame is a page. PNG and JPEG take the first frame. The bytes must be the declared type. Left out, the type is read from the filename: .pdf, .png, .jpg, .jpeg, .tif or .tiff. A name with no known ending is taken as application/pdf. A filename that ends in an office, text or other image format is refused here with unsupported_format.") + size_bytes: int = Field(..., description="The exact number of bytes of the file. The session is refused at complete with 409 conflict when the bytes received differ.") class UploadSession(_Response): @@ -2206,6 +2218,7 @@ class faxforagents__auth__schemas__Me(_Response): "PasskeyList", "PasskeyLoginVerify", "PasskeyRegisterVerify", + "PaymentReceipt", "PolicyCreate", "PolicyEvaluation", "PolicyEvaluationCreate", diff --git a/tests/test_async.py b/tests/test_async.py index a41310a..2c02926 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -107,3 +107,38 @@ async def test_async_create_api_key_sends_connected_app_only_when_given( assert route.calls.last.request.headers["idempotency-key"] == "k1" assert (await aclient.identity.create_api_key("plain")).connected_app is False assert "connected_app" not in json.loads(route.calls.last.request.content) + + +async def test_async_receipts_and_an_upload_without_a_type( + aclient: AsyncClient, api: respx.MockRouter +) -> None: + receipt = { + "url": "https://pay.example.test/receipts/1", + "amount_micro": 25_000_000, + "amount_refunded_micro": 0, + "currency": "USD", + "payment_intent_id": "pi_1", + "paid_at": None, + } + api.get("/v1/invoices/in_1/receipt").respond(200, json={**receipt, "invoice_id": "in_1"}) + api.get("/v1/topup-sessions/tus_1/receipt").respond(200, json={**receipt, "topup_session_id": "tus_1"}) + assert (await aclient.billing.get_invoice_receipt("in_1")).invoice_id == "in_1" + assert (await aclient.billing.get_topup_receipt("tus_1")).topup_session_id == "tus_1" + create = api.post("/v1/uploads").respond( + 201, + json={ + "id": "upl_" + "6" * 32, + "status": "open", + "filename": "scan.png", + "content_type": "image/png", + "declared_size": 9, + "received_size": 0, + "sha256": None, + "created_at": "2026-11-14T00:00:00Z", + "expires_at": "2026-11-14T01:00:00Z", + "completed_at": None, + }, + ) + session = await aclient.documents.create_upload(filename="scan.png", size_bytes=9) + assert session.content_type == "image/png" + assert b"content_type" not in create.calls.last.request.content diff --git a/tests/test_client.py b/tests/test_client.py index 64e12be..48eb327 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -518,3 +518,113 @@ def test_extraction_parses_without_the_provider_cost(client: Client, api: respx. assert extraction.price_micro == 0 assert "provider_cost_micro" not in type(extraction).model_fields assert "cost_micro" not in type(extraction.pages[0]).model_fields + + +def upload_session(**over: object) -> dict[str, object]: + return { + "id": "upl_" + "6" * 32, + "status": "open", + "filename": "scan.png", + "content_type": "image/png", + "declared_size": 9, + "received_size": 0, + "sha256": None, + "created_at": "2026-11-14T00:00:00Z", + "expires_at": "2026-11-14T01:00:00Z", + "completed_at": None, + "upload_url": f"{BASE}/v1/uploads/upl_6666/content?exp=1&sig=abc", + "upload_method": "PUT", + "upload_url_expires_at": "2026-11-14T00:10:00Z", + **over, + } + + +def test_create_upload_leaves_the_content_type_to_the_api(client: Client, api: respx.MockRouter) -> None: + """API 1.20.5 reads the type from the filename. 0.6.0 sent application/pdf for every file.""" + create = api.post("/v1/uploads").respond(201, json=upload_session()) + session = client.documents.create_upload(filename="scan.png", size_bytes=9) + assert json.loads(create.calls.last.request.content) == {"filename": "scan.png", "size_bytes": 9} + assert session.content_type == "image/png" + client.documents.create_upload(filename="scan.png", size_bytes=9, content_type="image/png") + assert json.loads(create.calls.last.request.content) == { + "filename": "scan.png", + "size_bytes": 9, + "content_type": "image/png", + } + + +def test_create_upload_refuses_an_office_filename_at_once(api: respx.MockRouter) -> None: + api.post("/v1/uploads").respond( + 422, json=error("unsupported_format", "letter.docx is not a PDF or an image", details=None) + ) + with Client(KEY, base_url=BASE, max_retries=0) as c, pytest.raises(InvalidRequestError) as exc: + c.documents.create_upload(filename="letter.docx", size_bytes=9) + assert exc.value.code == "unsupported_format" and exc.value.status == 422 + + +def receipt(**over: object) -> dict[str, object]: + return { + "url": "https://pay.example.test/receipts/1", + "pdf_url": "https://pay.example.test/receipts/1/pdf", + "amount_micro": 25_000_000, + "amount_refunded_micro": 0, + "currency": "USD", + "payment_intent_id": "pi_1", + "paid_at": "2026-11-14T19:15:26Z", + **over, + } + + +def test_payment_receipts_of_an_invoice_and_a_topup(client: Client, api: respx.MockRouter) -> None: + """The two read routes of API 1.20.5. Either id may be absent from the answer.""" + invoice = api.get("/v1/invoices/in_1/receipt").respond(200, json=receipt(invoice_id="in_1")) + topup = api.get("/v1/topup-sessions/tus_1/receipt").respond(200, json=receipt(topup_session_id="tus_1")) + paid = client.billing.get_invoice_receipt("in_1") + assert paid.url.endswith("/receipts/1") and paid.amount_micro == 25_000_000 + assert paid.invoice_id == "in_1" and paid.topup_session_id is None + added = client.billing.get_topup_receipt("tus_1") + assert added.topup_session_id == "tus_1" and added.invoice_id is None and added.pdf_url + for route in (invoice, topup): + assert route.calls.last.request.method == "GET" + assert "idempotency-key" not in route.calls.last.request.headers + + +def test_an_unpaid_topup_has_no_receipt(client: Client, api: respx.MockRouter) -> None: + api.get("/v1/topup-sessions/tus_2/receipt").respond( + 409, json=error("conflict", "this top-up was not paid", details={"status": "open"}) + ) + with pytest.raises(ConflictError) as exc: + client.billing.get_topup_receipt("tus_2") + assert exc.value.details == {"status": "open"} and exc.value.state is None + + +def test_quote_not_found_names_the_field(client: Client, api: respx.MockRouter) -> None: + """API 1.20.5: the 404 of a quote says which of its two ids was not found.""" + route = api.post("/v1/fax-quotes") + route.side_effect = [ + httpx.Response(404, json=error("not_found", "no such document", details={"field": "document_id"})), + httpx.Response(404, json=error("not_found", "no such sender number", details={"field": "from"})), + ] + with pytest.raises(NotFoundError) as doc: + client.quotes.create(document_id="doc_" + "0" * 32, to="+15550000001") + assert doc.value.field == "document_id" + with pytest.raises(NotFoundError) as sender: + client.quotes.create(document_id="doc_" + "2" * 32, to="+15550000001", from_="+14155550199") + assert sender.value.field == "from" + # an older server sends no details, and a field that is not a name is not returned + api.get("/v1/faxes/fax_x").respond(404, json=error("not_found", "no", details=None)) + with pytest.raises(NotFoundError) as plain: + client.faxes.get("fax_x") + assert plain.value.field is None + assert NotFoundError(status=404, code="not_found", message="no", details={"field": 7}).field is None + + +def test_an_early_receipt_names_the_fax_state(client: Client, api: respx.MockRouter) -> None: + """API 1.20.5: 409 conflict on a receipt asked for too early carries details.state.""" + api.get("/v1/faxes/fax_y/receipt").respond( + 409, json=error("conflict", "no receipt yet: the fax is active", details={"state": "active"}) + ) + with pytest.raises(ConflictError) as exc: + client.faxes.get_receipt("fax_y") + assert exc.value.state == "active" and exc.value.code == "conflict" and exc.value.retryable is False + assert ConflictError(status=409, code="quote_consumed", message="used").state is None diff --git a/tests/test_surface.py b/tests/test_surface.py index 21d5ac2..5f1761f 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -108,6 +108,7 @@ def test_every_operation_has_a_method() -> None: "create_topup_session": "billing.create_topup_session", "list_topup_sessions": "billing.list_topup_sessions", "get_topup_session": "billing.get_topup_session", + "get_topup_receipt": "billing.get_topup_receipt", "list_auto_topups": "billing.list_auto_topups", "create_auto_topup": "billing.create_auto_topup", "delete_auto_topup": "billing.delete_auto_topup", @@ -120,6 +121,7 @@ def test_every_operation_has_a_method() -> None: "get_usage_export_content": "billing.get_usage_export", "list_invoices": "billing.list_invoices", "get_invoice": "billing.get_invoice", + "get_invoice_receipt": "billing.get_invoice_receipt", "create_subscription_checkout": "billing.create_subscription_checkout", "list_subscriptions": "billing.list_subscriptions", "create_portal_session": "billing.create_portal_session",