From 9710cd62fe2ea3b184b95fb9bb63974066ba7fae Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:26:04 +0200 Subject: [PATCH] Version 0.4.1: the spec at API 1.17.0 The saved spec follows the public document at API 1.17.0. create_from_quote sends accept_aup only when given. checkout takes accept_aup for the payer. read_by_token posts the token of a mailed link. get_by_token still works and warns that it is deprecated. Orders carry aup_accepted, status_url and queue. PaymentRequiredError has top_up_url and a typed one_time_offer. --- CHANGELOG.md | 16 +++ README.md | 37 ++++-- pyproject.toml | 2 +- spec/openapi.yaml | 197 +++++++++++++++++++++++++++++-- src/faxforagents/__init__.py | 4 +- src/faxforagents/async_client.py | 64 +++++++--- src/faxforagents/client.py | 64 +++++++--- src/faxforagents/errors.py | 33 +++++- src/faxforagents/models.py | 23 +++- tests/fixtures/README.md | 1 + tests/test_one_time.py | 107 ++++++++++++++++- tests/test_surface.py | 1 + 12 files changed, 484 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40427f1..3fe9069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.4.1 (2026-11-12) + +Follows API 1.17.0. The saved spec has 145 operations and the models are regenerated from it. + +`one_time_sends.create_from_quote` no longer requires `accept_aup`. It is sent only when given. `approval_id` still carries the approval of a card order. +`one_time_sends.checkout` takes `accept_aup`. An order whose `aup_accepted` is false needs `accept_aup=True` once, with `order_token`. The payer accepts the terms on the order page, never a key. +New call `one_time_sends.read_by_token(token)`: the read of a mailed confirmation link, with the token in the body. `get_by_token` still works and raises a `DeprecationWarning`. + +`OneTimeOrder` and `OneTimeOrderCreated` carry `aup_accepted`, `status_url` and `queue`. `OneTimeConfig` carries `queue`. +New model `OneTimeQueue` with `queued`, `jobs_ahead` and `estimated_wait_minutes`. +`Balance` carries `top_up_url`. `Capabilities` carries `extraction_pricing`. + +`PaymentRequiredError.top_up_url` reads `details["top_up_url"]`, the page where a person adds funds. +`PaymentRequiredError.one_time_offer` is typed as `OneTimeOffer`, a `TypedDict` exported by the package. It is still a plain dict at run time. +Other model names, fields and optionality are kept. + ## 0.4.0 (2026-11-12) New resource `one_time_sends` on both clients: one fax paid by card, with no wallet. diff --git a/README.md b/README.md index b27f322..82cb914 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ each call needs. | `ffa.organizations` | `list`, `current`, `update_current`, `request_data_export`, `request_data_deletion`, `download_export_archive` | | `ffa.members` | `list`, `update`, `remove`, `list_invitations`, `invite`, `revoke_invitation` | | `ffa.oauth` | `list_grants`, `iter_grants`, `revoke_grant` | -| `ffa.one_time_sends` | `config`, `create`, `create_from_quote`, `list`, `iter`, `get`, `wait`, `checkout`, `get_by_token`, `confirm`, `resend_confirmation`, `get_receipt_pdf` | +| `ffa.one_time_sends` | `config`, `create`, `create_from_quote`, `list`, `iter`, `get`, `wait`, `checkout`, `read_by_token`, `confirm`, `resend_confirmation`, `get_receipt_pdf` | `ffa.request("GET", "/v1/anything")` reaches an operation the resources do not cover yet. @@ -214,16 +214,33 @@ With a live key, `create_from_quote` turns a quote the wallet does not cover int from faxforagents import PaymentRequiredError try: - fax = ffa.faxes.create(quote_id=quote.id) -except PaymentRequiredError: - order = ffa.one_time_sends.create_from_quote( - quote_id=quote.id, sender_name="Dana Example", accept_aup=True, approval_id=approval_id - ) + fax = ffa.faxes.create(quote_id=quote.id, approval_id=approval_id) +except PaymentRequiredError as exc: + print(exc.top_up_url) # where a person adds funds to the wallet + offer = exc.one_time_offer # None when this send has no card path + if offer: + print(offer["amount_micro"]) # the one-time price of this document + order = ffa.one_time_sends.create_from_quote( + quote_id=quote.id, sender_name="Dana Example", approval_id=offer["approval_id"] + ) + print(order.status_url) # hand this page to the person who pays ``` -Paying by card does not replace approval. The send must have an approval or a delegated policy, as a -wallet send does. `confirm(order_id, token=...)` and `get_by_token(token)` serve a page of your own -that receives the mailed link. The amounts are on the [pricing page](https://faxforagents.com/pricing/) +Paying by card does not replace approval. With a key, a card order must have an approval or a +delegated policy, as a wallet send does. Pass the approval as `approval_id`. Without either the answer is 403 +`approval_required`. + +The person who pays accepts the acceptable use policy and the terms on the order page, never the key. +With a key, the order answers `aup_accepted` false and a `status_url`. Give that link to the payer. +On a page of your own, `checkout(order.id, order_token=token, accept_aup=True)` records the payer's +acceptance and opens the payment. + +`order.queue` and `config().queue` tell whether a fax would wait for a carrier line, with `jobs_ahead` +and `estimated_wait_minutes`. A queued order is kept and sent in turn. + +`confirm(order_id, token=...)` and `read_by_token(token)` serve a page of your own that receives the +mailed link. `read_by_token` posts the token, so it stays out of the address. `get_by_token` still +works and is deprecated. The amounts are on the [pricing page](https://faxforagents.com/pricing/) and in `config().routes`, in micro-units. ## Webhooks @@ -277,7 +294,7 @@ ruff check src tests && mypy src sh scripts/truthcheck.sh ``` -`spec/openapi.yaml` follows the public document at API version 1.16.1 and keeps the schemas of the earlier live documents. +`spec/openapi.yaml` follows the public document at API version 1.17.0 and keeps the schemas of the earlier live documents. It also retains earlier public model names and route-documented response additions. To refresh it, merge the live schemas while preserving existing declarations, then run `uv run python scripts/gen_models.py`. Keep new response fields optional so older payloads and existing callers remain valid. diff --git a/pyproject.toml b/pyproject.toml index e5aca8a..d8c1d77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "faxforagents" -version = "0.4.0" +version = "0.4.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 ade55fc..6afc166 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.16.1. +# Fax for Agents API, refreshed from the public document of https://github.com/faxforagents/openapi at API 1.17.0. # 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.16.1 + version: 1.17.0 servers: - url: https://api.faxforagents.com description: Production (live and test keys) @@ -1288,7 +1288,8 @@ paths: $ref: '#/components/schemas/HTTPValidationError' '402': description: The wallet does not cover this fax (insufficient_balance). details names available_micro - and needed_micro. Nothing was held. + and needed_micro, top_up_url where a person adds funds, and one_time_offer when the send can be paid + by card instead. Nothing was held. content: application/json: schema: @@ -8456,16 +8457,64 @@ paths: agent_key_allowed: true hint: For a live quote the wallet does not cover. The price is the one-time price of the card, not the quote's. A key still needs approval_id or a delegated policy that allows the send. + /v1/one-time-sends/by-token: + post: + tags: + - one-time-sends + summary: What a confirmation link is for, without using it + description: |- + The confirm page reads this before the click, with the token in the body. Nothing is consumed by reading it, and nothing is changed: the call is a read that keeps the token out of the address. Idempotency-Key is accepted but ignored. + + No API key is needed. + operationId: read_one_time_order_by_token + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OneTimeConfirm' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OneTimeConfirmSummary' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + '404': + description: The confirmation link is not valid (link_invalid). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '429': + description: Rate limit reached (rate_limited). Retry after the seconds in Retry-After. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + x-llm: + scope: none + mutating: false + idempotency_key: false + agent_key_allowed: true + security: [] /v1/one-time-sends/by-token/{token}: get: tags: - one-time-sends - summary: What a confirmation link is for, without using it + summary: What a confirmation link is for, without using it (token in the path) description: |- - The confirm page reads this before the click. Nothing is consumed by reading it. + Kept for pages loaded before 2026-11-12. The token in a path reaches access logs, so new callers post it to /v1/one-time-sends/by-token instead. Nothing is consumed by reading it. No API key is needed. operationId: get_one_time_order_by_token + deprecated: true parameters: - name: token in: path @@ -8650,7 +8699,8 @@ paths: summary: Open the card payment of a one-time order description: 'Answers the hosted payment page of the order. The card is authorized there, not charged: it is charged when the carrier confirms delivery. A second call answers the same page while it can still be - paid, and a new one after it expired.' + paid, and a new one after it expired. An order whose aup_accepted is false needs {"accept_aup": true} in + the body once, from the status token or a session: the person who pays accepts the policy, never a key.' operationId: create_one_time_checkout parameters: - name: one_time_order_id @@ -8723,6 +8773,21 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + requestBody: + required: false + content: + application/json: + schema: + additionalProperties: false + properties: + accept_aup: + default: false + description: 'True: the person who pays accepts the acceptable use policy and the terms. Needed + once, for an order whose aup_accepted is false, and taken only from the status token or a session.' + title: Accept Aup + type: boolean + title: OneTimeCheckoutRequest + type: object x-llm: scope: fax:send mutating: true @@ -10984,6 +11049,12 @@ components: held_micro: type: integer title: Held Micro + top_up_url: + anyOf: + - type: string + - type: 'null' + title: Top Up Url + description: The dashboard page where a person adds funds to the wallet. A key cannot top up. additionalProperties: true type: object required: @@ -11242,6 +11313,13 @@ components: extraction: type: boolean title: Extraction + extraction_pricing: + additionalProperties: true + type: object + title: Extraction Pricing + 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. limits: additionalProperties: type: integer @@ -12969,6 +13047,42 @@ components: needed_micro: type: integer description: What this fax holds. + top_up_url: + type: string + description: The dashboard page where a person adds funds to the wallet. A key cannot top up. + one_time_offer: + type: object + description: 'The card path for this exact send, present when it qualifies: the one-time product + is open, the key is live, the route is sold one fax at a time and the document is within the + page cap. Pass the quote to POST /v1/one-time-sends/from-quote. A pointer, never a promise: + the create runs every check again.' + required: + - amount_micro + - currency + - document_pages + - included_pages + - extra_page_micro + - max_document_pages + properties: + amount_micro: + type: integer + description: The one-time price of this document to this number. + currency: + type: string + document_pages: + type: integer + included_pages: + type: integer + extra_page_micro: + type: integer + max_document_pages: + type: integer + approval_id: + type: + - string + - 'null' + description: The approval that authorized this send. It authorizes the order of the same quote + too. Inventory: properties: country: @@ -13853,6 +13967,12 @@ components: terms_url: type: string title: Terms Url + queue: + anyOf: + - $ref: '#/components/schemas/OneTimeQueue' + - type: 'null' + description: The wait a fax ordered now would face for a carrier line. Null when one-time sends are closed + here or the carrier has no line limit. additionalProperties: true type: object required: @@ -13989,6 +14109,9 @@ components: accept_aup: type: boolean title: Accept Aup + description: 'A session sends true: the signed-in person accepts the acceptable use policy and the terms. + A key''s value is not recorded: the person who pays accepts on the order''s page.' + default: false email: anyOf: - type: string @@ -14023,7 +14146,6 @@ components: required: - quote_id - sender_name - - accept_aup title: OneTimeFromQuote OneTimeJob: properties: @@ -14228,6 +14350,12 @@ components: - type: 'null' title: Resend Allowed At description: When the confirmation mail may be asked for again; null when it may not. + queue: + anyOf: + - $ref: '#/components/schemas/OneTimeQueue' + - type: 'null' + description: The wait for a carrier line while the order has not ended and its fax is not on a line yet. + Null for an ended order, a fax that is sending, and a test order. released_at: anyOf: - type: string @@ -14256,6 +14384,19 @@ components: aup_version: type: string title: Aup Version + aup_accepted: + type: boolean + title: Aup Accepted + description: Whether a person accepted the acceptable use policy and the terms for this order. False for + an order a key created until the payer accepts on the order's page; the checkout needs it. + default: true + status_url: + anyOf: + - type: string + - type: 'null' + title: Status Url + description: The order's page for the person who pays or confirms, with a fresh status token. Present + on a read by a key or session of the order's organization while the order is not done. warnings: items: additionalProperties: true @@ -14465,6 +14606,12 @@ components: - type: 'null' title: Resend Allowed At description: When the confirmation mail may be asked for again; null when it may not. + queue: + anyOf: + - $ref: '#/components/schemas/OneTimeQueue' + - type: 'null' + description: The wait for a carrier line while the order has not ended and its fax is not on a line yet. + Null for an ended order, a fax that is sending, and a test order. released_at: anyOf: - type: string @@ -14493,6 +14640,15 @@ components: aup_version: type: string title: Aup Version + aup_accepted: + type: boolean + title: Aup Accepted + description: Whether a person accepted the acceptable use policy and the terms for this order. False for + an order a key created until the payer accepts on the order's page; the checkout needs it. + default: true + status_url: + type: string + title: Status Url warnings: items: additionalProperties: true @@ -14515,9 +14671,6 @@ components: status_token: type: string title: Status Token - status_url: - type: string - title: Status Url additionalProperties: true type: object required: @@ -14551,10 +14704,10 @@ components: - fax_job_id - failure_code - aup_version + - status_url - created_at - updated_at - status_token - - status_url title: OneTimeOrderCreated description: |- The create answer: the order plus the status token. The token reads this one order, opens its @@ -14607,6 +14760,28 @@ components: - extra_page_micro - cover_included title: OneTimePrice + OneTimeQueue: + properties: + queued: + type: boolean + title: Queued + jobs_ahead: + type: integer + title: Jobs Ahead + estimated_wait_minutes: + type: integer + title: Estimated Wait Minutes + additionalProperties: true + type: object + required: + - queued + - jobs_ahead + - estimated_wait_minutes + title: OneTimeQueue + description: |- + The wait for a carrier line. ``queued`` is true when a fax sent now, or this order's waiting fax, + is estimated to wait more than ten minutes for a line: the order is kept and sent in turn, never + dropped. The minutes are an estimate from the number of faxes ahead and the carrier's line count. OneTimeRoute: properties: label: diff --git a/src/faxforagents/__init__.py b/src/faxforagents/__init__.py index 1ce1233..b65d253 100644 --- a/src/faxforagents/__init__.py +++ b/src/faxforagents/__init__.py @@ -18,7 +18,7 @@ from __future__ import annotations -__version__ = "0.4.0" +__version__ = "0.4.1" from faxforagents._http import ORDER_TOKEN_HEADER from faxforagents.async_client import AsyncClient @@ -32,6 +32,7 @@ FaxForAgentsError, InvalidRequestError, NotFoundError, + OneTimeOffer, PaymentRequiredError, RateLimitedError, ScopeError, @@ -51,6 +52,7 @@ "InvalidRequestError", "NotFoundError", "ORDER_TOKEN_HEADER", + "OneTimeOffer", "PaymentRequiredError", "RateLimitedError", "ScopeError", diff --git a/src/faxforagents/async_client.py b/src/faxforagents/async_client.py index fc73868..e7c7243 100644 --- a/src/faxforagents/async_client.py +++ b/src/faxforagents/async_client.py @@ -19,6 +19,7 @@ import hashlib import time import uuid +import warnings from collections.abc import AsyncIterator, Sequence from pathlib import Path from typing import Any @@ -1383,7 +1384,7 @@ async def create_from_quote( *, quote_id: str, sender_name: str, - accept_aup: bool, + accept_aup: bool | None = None, email: str | None = None, approval_id: str | None = None, delete_after_send: bool | None = None, @@ -1394,16 +1395,20 @@ async def create_from_quote( The price is the one-time price of the card, not the quote's. Paying by card does not replace approval: pass ``approval_id`` unless a delegated policy allows the send, or the answer is 403 - ``approval_required``.""" - body = m.OneTimeFromQuote( - quote_id=quote_id, - sender_name=sender_name, - accept_aup=accept_aup, - email=email, - approval_id=approval_id, - delete_after_send=delete_after_send, - source_ref=source_ref, - ) + ``approval_required``. + + A key does not accept the terms for the payer, so ``accept_aup`` is optional and is sent only + when given. The order answers ``aup_accepted`` false and a ``status_url``: hand that page to the + person who pays. They accept the acceptable use policy and the terms there.""" + body: dict[str, Any] = { + "quote_id": quote_id, + "sender_name": sender_name, + "accept_aup": accept_aup, + "email": email, + "approval_id": approval_id, + "delete_after_send": delete_after_send, + "source_ref": source_ref, + } return await self._h.request( "POST", "/v1/one-time-sends/from-quote", @@ -1455,23 +1460,52 @@ async def wait( return order async def checkout( - self, order_id: str, *, order_token: str | None = None, idempotency_key: str | None = None + self, + order_id: str, + *, + accept_aup: bool | None = None, + order_token: str | None = None, + idempotency_key: str | None = None, ) -> m.OneTimeCheckout: """Open the hosted card payment and return its ``checkout_url``. Scope with a key: fax:send. The card is authorized on that page and charged only when the carrier confirms delivery. A - second call answers the same page while it can still be paid.""" + second call answers the same page while it can still be paid. + + An order whose ``aup_accepted`` is false needs ``accept_aup=True`` once: the person who pays + accepts the acceptable use policy and the terms. The server takes it with ``order_token`` or a + session, never from a key.""" return await self._h.request( "POST", f"/v1/one-time-sends/{order_id}/checkout", model=m.OneTimeCheckout, - json={}, + json={"accept_aup": accept_aup}, headers=token_header(order_token), idempotency_key=idempotency_key, ) + async def read_by_token(self, token: str) -> m.OneTimeConfirmSummary: + """What a mailed confirmation link is for, without using it. No key needed. + + The token travels in the body, so it stays out of the address and of access logs. Nothing is + consumed and nothing changes. Needs API 1.17.0.""" + return await self._h.request( + "POST", + "/v1/one-time-sends/by-token", + model=m.OneTimeConfirmSummary, + json=m.OneTimeConfirm(token=token), + ) + async def get_by_token(self, token: str) -> m.OneTimeConfirmSummary: - """What a mailed confirmation link is for, without using it. No key needed.""" + """Deprecated: use ``read_by_token``. The same read with the token in the path. + + A token in a path reaches access logs. The API keeps this route for older callers and marks it + deprecated since 1.17.0.""" + warnings.warn( + "one_time_sends.get_by_token is deprecated: use read_by_token, which posts the token", + DeprecationWarning, + stacklevel=2, + ) return await self._h.request( "GET", f"/v1/one-time-sends/by-token/{token}", model=m.OneTimeConfirmSummary ) diff --git a/src/faxforagents/client.py b/src/faxforagents/client.py index 0da35de..c3236e9 100644 --- a/src/faxforagents/client.py +++ b/src/faxforagents/client.py @@ -20,6 +20,7 @@ import hashlib import time import uuid +import warnings from collections.abc import Iterator, Sequence from pathlib import Path from typing import Any @@ -1404,7 +1405,7 @@ def create_from_quote( *, quote_id: str, sender_name: str, - accept_aup: bool, + accept_aup: bool | None = None, email: str | None = None, approval_id: str | None = None, delete_after_send: bool | None = None, @@ -1415,16 +1416,20 @@ def create_from_quote( The price is the one-time price of the card, not the quote's. Paying by card does not replace approval: pass ``approval_id`` unless a delegated policy allows the send, or the answer is 403 - ``approval_required``.""" - body = m.OneTimeFromQuote( - quote_id=quote_id, - sender_name=sender_name, - accept_aup=accept_aup, - email=email, - approval_id=approval_id, - delete_after_send=delete_after_send, - source_ref=source_ref, - ) + ``approval_required``. + + A key does not accept the terms for the payer, so ``accept_aup`` is optional and is sent only + when given. The order answers ``aup_accepted`` false and a ``status_url``: hand that page to the + person who pays. They accept the acceptable use policy and the terms there.""" + body: dict[str, Any] = { + "quote_id": quote_id, + "sender_name": sender_name, + "accept_aup": accept_aup, + "email": email, + "approval_id": approval_id, + "delete_after_send": delete_after_send, + "source_ref": source_ref, + } return self._h.request( "POST", "/v1/one-time-sends/from-quote", @@ -1476,23 +1481,52 @@ def wait( return order def checkout( - self, order_id: str, *, order_token: str | None = None, idempotency_key: str | None = None + self, + order_id: str, + *, + accept_aup: bool | None = None, + order_token: str | None = None, + idempotency_key: str | None = None, ) -> m.OneTimeCheckout: """Open the hosted card payment and return its ``checkout_url``. Scope with a key: fax:send. The card is authorized on that page and charged only when the carrier confirms delivery. A - second call answers the same page while it can still be paid.""" + second call answers the same page while it can still be paid. + + An order whose ``aup_accepted`` is false needs ``accept_aup=True`` once: the person who pays + accepts the acceptable use policy and the terms. The server takes it with ``order_token`` or a + session, never from a key.""" return self._h.request( "POST", f"/v1/one-time-sends/{order_id}/checkout", model=m.OneTimeCheckout, - json={}, + json={"accept_aup": accept_aup}, headers=token_header(order_token), idempotency_key=idempotency_key, ) + def read_by_token(self, token: str) -> m.OneTimeConfirmSummary: + """What a mailed confirmation link is for, without using it. No key needed. + + The token travels in the body, so it stays out of the address and of access logs. Nothing is + consumed and nothing changes. Needs API 1.17.0.""" + return self._h.request( + "POST", + "/v1/one-time-sends/by-token", + model=m.OneTimeConfirmSummary, + json=m.OneTimeConfirm(token=token), + ) + def get_by_token(self, token: str) -> m.OneTimeConfirmSummary: - """What a mailed confirmation link is for, without using it. No key needed.""" + """Deprecated: use ``read_by_token``. The same read with the token in the path. + + A token in a path reaches access logs. The API keeps this route for older callers and marks it + deprecated since 1.17.0.""" + warnings.warn( + "one_time_sends.get_by_token is deprecated: use read_by_token, which posts the token", + DeprecationWarning, + stacklevel=2, + ) return self._h.request("GET", f"/v1/one-time-sends/by-token/{token}", model=m.OneTimeConfirmSummary) def confirm(self, order_id: str, *, token: str) -> m.OneTimeConfirmed: diff --git a/src/faxforagents/errors.py b/src/faxforagents/errors.py index 42de7a3..4fde184 100644 --- a/src/faxforagents/errors.py +++ b/src/faxforagents/errors.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, TypedDict, cast class FaxForAgentsError(Exception): @@ -69,17 +69,40 @@ class ConflictError(APIError): """409: the resource is in a state that refuses this change (``quote_consumed``, ``not_cancelable``).""" +class OneTimeOffer(TypedDict, total=False): + """``details["one_time_offer"]`` of 402 insufficient_balance: the card path for this exact send. + + A pointer, never a promise: ``one_time_sends.create_from_quote`` runs every check again.""" + + amount_micro: int + currency: str + document_pages: int + included_pages: int + extra_page_micro: int + max_document_pages: int + approval_id: str | None + + class PaymentRequiredError(APIError): """402 insufficient_balance: the wallet does not cover the fax. A person tops up.""" @property - def one_time_offer(self) -> dict[str, Any] | None: + def top_up_url(self) -> str | None: + """``details["top_up_url"]``: the dashboard page where a person adds funds, else ``None``. + + A key cannot top up. Servers send it since API 1.17.0.""" + url = self.details.get("top_up_url") if isinstance(self.details, dict) else None + return url if isinstance(url, str) else None + + @property + def one_time_offer(self) -> OneTimeOffer | None: """``details["one_time_offer"]`` when the server sent one, else ``None``. - A server that offers the card path for this exact send names its price here. Pass the quote to - ``one_time_sends.create_from_quote``. Servers at API 1.16.1 do not send it.""" + Present when this exact send can be paid by card: it names the one-time price in + ``amount_micro``, the page counts and the ``approval_id`` that authorizes the order. Pass the + quote to ``one_time_sends.create_from_quote``. Servers send it since API 1.17.0.""" offer = self.details.get("one_time_offer") if isinstance(self.details, dict) else None - return offer if isinstance(offer, dict) else None + return cast(OneTimeOffer, offer) if isinstance(offer, dict) else None class RateLimitedError(APIError): diff --git a/src/faxforagents/models.py b/src/faxforagents/models.py index 6b776ee..072f24d 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.16.1). Do not edit by hand. +GENERATED by scripts/gen_models.py from spec/openapi.yaml (API version 1.17.0). 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. """ @@ -486,6 +486,7 @@ class Balance(_Response): currency: str available_micro: int held_micro: int + top_up_url: str | None = Field(None, description="The dashboard page where a person adds funds to the wallet. A key cannot top up.") class BillingSettings(_Response): @@ -551,6 +552,7 @@ class Capabilities(_Response): scopes: list[str] 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.") limits: dict[str, int] one_time: dict[str, Any] | None = None @@ -1219,6 +1221,14 @@ class OneTimeCheckout(_Response): currency: str +class OneTimeQueue(_Response): + """The wait for a carrier line. ``queued`` is true when a fax sent now, or this order's waiting fax, is estimated to wait more than ten minutes for a line: the order is kept and sent in turn, never dropped. The minutes are an estimate from the number of faxes ahead and the carrier's line count.""" + + queued: bool + jobs_ahead: int + estimated_wait_minutes: int + + class OneTimeRoute(_Response): label: str flat_micro: int @@ -1243,6 +1253,7 @@ class OneTimeConfig(_Response): aup_version: str aup_url: str terms_url: str + queue: OneTimeQueue | None = Field(None, description="The wait a fax ordered now would face for a carrier line. Null when one-time sends are closed here or the carrier has no line limit.") class OneTimeConfirm(_Request): @@ -1305,12 +1316,15 @@ class OneTimeOrder(_Response): capture_deadline_at: str | None confirm_mail_sent_at: str | None resend_allowed_at: str | None = Field(None, description="When the confirmation mail may be asked for again; null when it may not.") + queue: OneTimeQueue | None = Field(None, description="The wait for a carrier line while the order has not ended and its fax is not on a line yet. Null for an ended order, a fax that is sending, and a test order.") released_at: str | None settled_at: str | None quote_id: str | None fax_job_id: str | None failure_code: str | None aup_version: str + aup_accepted: bool = Field(True, description="Whether a person accepted the acceptable use policy and the terms for this order. False for an order a key created until the payer accepts on the order's page; the checkout needs it.") + status_url: str | None = Field(None, description="The order's page for the person who pays or confirms, with a fresh status token. Present on a read by a key or session of the order's organization while the order is not done.") warnings: list[dict[str, Any]] | None = None fax: dict[str, Any] | None = Field(None, description="The fax job (the FaxJob shape) once the order was released; null before.") created_at: str @@ -1328,7 +1342,7 @@ class OneTimeConfirmed(_Response): class OneTimeFromQuote(_Request): quote_id: str sender_name: str = Field(..., description="Printed on the cover sheet. 2 to 120 characters.") - accept_aup: bool + accept_aup: bool = Field(False, description="A session sends true: the signed-in person accepts the acceptable use policy and the terms. A key's value is not recorded: the person who pays accepts on the order's page.") email: str | None = None approval_id: str | None = Field(None, description="An approved, unused approval request for this quote (POST /v1/approval-requests). A key needs it unless a delegated policy allows the send. A session needs none.") source_ref: str | None = None @@ -1367,18 +1381,20 @@ class OneTimeOrderCreated(_Response): capture_deadline_at: str | None confirm_mail_sent_at: str | None resend_allowed_at: str | None = Field(None, description="When the confirmation mail may be asked for again; null when it may not.") + queue: OneTimeQueue | None = Field(None, description="The wait for a carrier line while the order has not ended and its fax is not on a line yet. Null for an ended order, a fax that is sending, and a test order.") released_at: str | None settled_at: str | None quote_id: str | None fax_job_id: str | None failure_code: str | None aup_version: str + aup_accepted: bool = Field(True, description="Whether a person accepted the acceptable use policy and the terms for this order. False for an order a key created until the payer accepts on the order's page; the checkout needs it.") + status_url: str warnings: list[dict[str, Any]] | None = None fax: dict[str, Any] | None = Field(None, description="The fax job (the FaxJob shape) once the order was released; null before.") created_at: str updated_at: str status_token: str - status_url: str class OneTimeOrderPage(_Response): @@ -2175,6 +2191,7 @@ class faxforagents__auth__schemas__Me(_Response): "OAuthConsentDecision", "OAuthRedirect", "OneTimeCheckout", + "OneTimeQueue", "OneTimeRoute", "OneTimeConfig", "OneTimeConfirm", diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 8b47aba..607c22a 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -21,4 +21,5 @@ Server source revision: `5bee3b046dbcb5bc0fbf98ae59c1a40a1c8c1434`, API 1.16.1. `config` and `not_found` were recorded from `https://api.faxforagents.com` on 2026-11-12. Both routes answer without a key. The other cases follow the serializers `order_json` and `job_block` in `onetime/service.py` and the answers in `api/routes/onetime.py`. They are synthetic examples, not captured traffic: no order was created and no card was charged to make them. +The tests add the API 1.17.0 fields (`queue`, `aup_accepted`, `status_url`, `top_up_url`) on top of these cases. Ids, addresses, the token and the checkout address are test values. diff --git a/tests/test_one_time.py b/tests/test_one_time.py index 602e504..cefc238 100644 --- a/tests/test_one_time.py +++ b/tests/test_one_time.py @@ -177,9 +177,88 @@ def test_checkout(keyless: Client, api: respx.MockRouter) -> None: assert sent.headers["idempotency-key"] -def test_get_by_token_and_confirm(keyless: Client, api: respx.MockRouter) -> None: +def test_read_by_token_posts_the_token_in_the_body(keyless: Client, api: respx.MockRouter) -> None: + route = api.post("/v1/one-time-sends/by-token").respond(200, json=CASES["by_token"]) + summary = keyless.one_time_sends.read_by_token("mail-token") + assert summary.order_id == ORDER + sent = route.calls.last.request + assert json.loads(sent.content) == {"token": "mail-token"} + assert "mail-token" not in str(sent.url) + assert "authorization" not in sent.headers + + +def test_get_by_token_still_works_and_warns(keyless: Client, api: respx.MockRouter) -> None: api.get("/v1/one-time-sends/by-token/mail-token").respond(200, json=CASES["by_token"]) - summary = keyless.one_time_sends.get_by_token("mail-token") + with pytest.warns(DeprecationWarning, match="read_by_token"): + summary = keyless.one_time_sends.get_by_token("mail-token") + assert summary.order_id == ORDER + + +def test_from_quote_sends_accept_aup_only_when_given(client: Client, api: respx.MockRouter) -> None: + created = {**CASES["created"], "aup_accepted": False} + route = api.post("/v1/one-time-sends/from-quote").respond(201, json=created) + order = client.one_time_sends.create_from_quote( + quote_id="quote_" + "6" * 32, sender_name="Dana Example", approval_id="apr_1" + ) + assert order.aup_accepted is False + assert order.status_url + assert json.loads(route.calls.last.request.content) == { + "quote_id": "quote_" + "6" * 32, + "sender_name": "Dana Example", + "approval_id": "apr_1", + } + + +def test_checkout_accept_aup(keyless: Client, api: respx.MockRouter) -> None: + route = api.post(f"/v1/one-time-sends/{ORDER}/checkout").respond(201, json=CASES["checkout"]) + keyless.one_time_sends.checkout(ORDER, order_token=TOKEN) + assert json.loads(route.calls.last.request.content) == {} + keyless.one_time_sends.checkout(ORDER, order_token=TOKEN, accept_aup=True) + assert json.loads(route.calls.last.request.content) == {"accept_aup": True} + assert route.calls.last.request.headers["x-ffa-order-token"] == TOKEN + + +def test_queue_and_new_order_fields(keyless: Client, api: respx.MockRouter) -> None: + queue = {"queued": True, "jobs_ahead": 7, "estimated_wait_minutes": 14} + api.get("/v1/one-time-sends/config").respond(200, json={**CASES["config"], "queue": queue}) + config = keyless.one_time_sends.config() + assert config.queue is not None + assert config.queue.queued is True + assert config.queue.estimated_wait_minutes == 14 + body = { + **CASES["checkout_open"], + "queue": queue, + "aup_accepted": False, + "status_url": "https://faxforagents.com/send/status/x", + } + api.get(f"/v1/one-time-sends/{ORDER}").respond(200, json=body) + order = keyless.one_time_sends.get(ORDER, order_token=TOKEN) + assert order.queue is not None and order.queue.jobs_ahead == 7 + assert order.aup_accepted is False + assert order.status_url == "https://faxforagents.com/send/status/x" + api.get(f"/v1/one-time-sends/{ORDER}").respond(200, json=CASES["checkout_open"]) + older = keyless.one_time_sends.get(ORDER, order_token=TOKEN) + assert older.queue is None + assert older.aup_accepted is True + + +def test_balance_top_up_url(client: Client, api: respx.MockRouter) -> None: + api.get("/v1/balance").respond( + 200, + json={ + "available_micro": 0, + "held_micro": 0, + "currency": "USD", + "environment": "live", + "top_up_url": "https://app.faxforagents.com/app/billing", + }, + ) + assert client.billing.balance().top_up_url == "https://app.faxforagents.com/app/billing" + + +def test_get_by_token_and_confirm(keyless: Client, api: respx.MockRouter) -> None: + api.post("/v1/one-time-sends/by-token").respond(200, json=CASES["by_token"]) + summary = keyless.one_time_sends.read_by_token("mail-token") assert summary.state == "ok" assert summary.order_id == ORDER route = api.post(f"/v1/one-time-sends/{ORDER}/confirm").respond(200, json=CASES["confirmed"]) @@ -247,14 +326,34 @@ def test_insufficient_balance_one_time_offer(client: Client, api: respx.MockRout client.faxes.create(quote_id="quote_" + "6" * 32) assert exc.value.details["needed_micro"] == 78000 assert exc.value.one_time_offer is None - offer = {"amount_micro": 2490000, "currency": "USD", "document_pages": 2} + assert exc.value.top_up_url is None + offer = { + "amount_micro": 2490000, + "currency": "USD", + "document_pages": 2, + "included_pages": 5, + "extra_page_micro": 490000, + "max_document_pages": 20, + "approval_id": "apr_1", + } with_offer = error( - "insufficient_balance", details={"available_micro": 0, "needed_micro": 78000, "one_time_offer": offer} + "insufficient_balance", + details={ + "available_micro": 0, + "needed_micro": 78000, + "top_up_url": "https://app.faxforagents.com/app/billing", + "one_time_offer": offer, + }, ) api.post("/v1/faxes").respond(402, json=with_offer) with pytest.raises(PaymentRequiredError) as exc: client.faxes.create(quote_id="quote_" + "6" * 32) assert exc.value.one_time_offer == offer + found = exc.value.one_time_offer + assert found is not None + assert found["amount_micro"] == 2490000 + assert found["approval_id"] == "apr_1" + assert exc.value.top_up_url == "https://app.faxforagents.com/app/billing" async def test_async_flow(api: respx.MockRouter) -> None: diff --git a/tests/test_surface.py b/tests/test_surface.py index 42cba81..21d5ac2 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -204,6 +204,7 @@ def test_every_operation_has_a_method() -> None: "list_one_time_orders": "one_time_sends.list", "get_one_time_order": "one_time_sends.get", "get_one_time_order_by_token": "one_time_sends.get_by_token", + "read_one_time_order_by_token": "one_time_sends.read_by_token", "create_one_time_checkout": "one_time_sends.checkout", "confirm_one_time_order": "one_time_sends.confirm", "resend_one_time_confirmation": "one_time_sends.resend_confirmation",