UnitPay's docs (docs.useunitpay.com) are well-written, coherent conceptual and SDK guides for a dollars-and-credits billing engine — but the platform has no REST API reference at all, no webhook documentation, an undocumented payment component at the center of the money path, and several cross-page contradictions that will silently break both developers and AI agents.
1. No REST API reference for a billing API — every endpoint lives only in prose (critical)
Location: Entire docs surface; https://docs.useunitpay.com/api-reference → 404; the llms.txt index has no API-reference section
Problem: The docs reference at least a dozen raw REST endpoints — POST /v1/products, POST /v1/plans, POST /v1/credit-currencies, POST /v1/subscriptions, POST /v1/check, POST /v1/usage/track, GET /v1/credit-accounts, GET /v1/credit-grants, GET /v1/credit-ledger, PUT /v1/customers/:id/auto-topup-rules/:creditCurrencyId, POST /v1/invoices/:id/pay, POST /v1/onboarding/stream — but there is no reference page, no OpenAPI spec, and no request/response schemas for any of them. The Quickstart even says "See [Billing] for the catalog API", and the Billing page just names two endpoints in a sentence: "Create them with the catalog API (POST /v1/products, POST /v1/plans)". No parameters, no response shapes, no error codes for the REST surface (typed errors are documented only for the SDKs).
Consequence: Anyone not using the two JS SDKs — Python, Go, Ruby, curl users, or an AI agent asked to "create a plan" — has no way to discover required fields, validation rules, or error responses for a system that moves real money. The Quickstart tells you to create a "free plan with a monthly credit grant rule (POST /v1/plans)" and gives you nothing to build that request from.
The fix: Publish an API reference (ideally an OpenAPI spec) covering every /v1/* endpoint the docs already name, with request/response schemas and HTTP error codes, and link it from llms.txt.
2. Webhooks provision your subscriptions, but webhooks are undocumented (critical)
Location: /documentation/billing, /node/errors, /react/checkout — no webhooks page exists in the docs index
Problem: Webhooks are load-bearing in the core payment flow: Billing says "the subscription settles once the card is confirmed (a webhook provisions the subscription, invoice, and any credits)", and Checkout says "the webhook completer flips the session to once the PSP confirms." The Node SDK documents (Svix signatures) and the example reads . But there is no page describing how to register a webhook endpoint, what event types exist, what any payload looks like, or where the webhook secret comes from.
Code verification
Runtime snippet checks
Run the public examples from this audit in an isolated sandbox and attach the pass, fixed, skipped, and failed counts to the report.
Consequence: A developer can verify a signature but cannot know what events to expect, what fields they carry, or how to configure the endpoint in the first place. The only documented way to observe "subscription provisioned" — the moment credits are granted and invoices created — is a system with no event catalog.
The fix: Add a Webhooks page: endpoint configuration, the full event-type catalog with payload schemas, retry semantics, and where to find the signing secret.
3. <PaymentForm> — the component that collects every card — has no documentation, and its only link points to the wrong page (critical)
Location: /react/checkout (link target /react/entitlements-and-gates); also referenced on /documentation/billing, /react/invoices, /react/payment-methods, /react/subscriptions
Problem: The docs repeatedly instruct developers to mount <PaymentForm>: "mount the React SDK's payment form with it" (Billing), <PaymentForm mode="save_only" clientSecret={clientSecret} /> (Payment methods), "render the <PaymentForm> Elements step" (Checkout). That Checkout link points to the Entitlements & gates page, which contains zero mentions of PaymentForm (verified by grep on the page's .md). No page in the docs index documents the component's props, its mode values, or how clientSecret vs client.token wire in.
Consequence: The requires_form outcome — the path every first-time paying customer takes — dead-ends at an undocumented component behind a misdirected link. Developers must guess props (mode, clientSecret) from scattered inline snippets; agents following the link land on gates documentation and fail.
The fix: Create a /react/payment-form reference documenting all props and modes, and repoint every <PaymentForm> link to it.
4. Env var contradiction: docs tell you to set UNITPAY_SECRET_KEY, the SDK reads UNITPAY_API_KEY (significant)
Location: /documentation/authentication vs /node/introduction
Problem: The Authentication page's .env example is UNITPAY_SECRET_KEY=upay_sk_..., and every curl example uses $UNITPAY_SECRET_KEY. The very next paragraph says "The Node SDK reads UNITPAY_API_KEY automatically if you don't pass a key explicitly" — and the Node introduction confirms: "set UNITPAY_API_KEY and let the SDK read it" plus "new UnitPay() throws if no key is found." Node SDK examples alternate between process.env.UNITPAY_SECRET_KEY and process.env.UNITPAY_API_KEY across pages.
Consequence: A developer who follows the Authentication page's .env block and then instantiates new UnitPay() per the Node docs gets a throw at startup — the key exists under the wrong name. Agents that copy-paste the two canonical snippets from the two canonical pages produce broken code.
The fix: Standardize on one env var name (or make the SDK read both), and use it consistently in every .env block and code sample.
5. POST /v1/subscriptions outcome table omits three of the seven documented outcome kinds (significant)
Location: /documentation/billing vs /react/settlement
Problem: Billing presents the subscribe response as a closed set: "It's a single outcome object discriminated by kind" with a four-row table (created_no_charge, charged_inline, requires_form, deferred). The Settlement model page documents seven kinds, including invoice_sent ("NET-terms invoice issued + emailed (send-invoice customers)"), invoice_added, and no_action — and useCreateSubscription's own options include onInvoiceSent.
Consequence: A developer who codes the REST integration against the Billing table's exhaustive-looking switch will not handle invoice_sent or no_action, and those payments will be silently dropped by their handler when an SLG/send-invoice customer subscribes.
The fix: Either enumerate all possible kinds for POST /v1/subscriptions on the Billing page, or explicitly mark the table as the common self-serve subset with a link to the full union.
6. The track response shape contradicts itself across pages — and the numbers are strings (significant)
Location: /documentation/getting-started/setup and /documentation/how-it-works vs /node/usage
Problem: Both REST examples of POST /v1/usage/track return { "accepted": 1, "rejected": 0, "results": [{ "status": "ok", "flow": "credit", "creditsDeducted": "10", "creditBalance": "90" }] } — a results array whose numeric fields are JSON strings. The Node SDK's documented return type is "Promise<TrackEventResult> with accepted: number, rejected: number, and optional rejections" — no results array at all. Meanwhile POST /v1/check returns creditBalance as a number ("creditBalance": 100).
Consequence: Developers can't tell whether per-event results exist in the SDK, and code that does arithmetic on creditBalance from track (balance - cost) silently concatenates strings. Agents generating types from the examples produce string for a field that's number on the adjacent page.
The fix: Document the actual wire response of /v1/usage/track once, reconcile it with TrackEventResult, and make numeric fields consistently numeric across examples.
7. Docs mandate formatCredits / formatMoney / <Money> — none of them are documented anywhere (significant)
Problem: The Denominations page makes these helpers part of a hard rule: "Render unit credits with formatCredits(n)", "Render fiat credits as money with <Money> / formatMoney(cents, code) — never as a raw count", and "never formatMoney a unit count, never formatCredits a fiat balance." No page in the docs index documents these functions or the <Money> component — no import path, no package, no signature. The React credits page's own example hand-rolls formatting instead of using them.
Consequence: Developers are told correctness depends on two functions they cannot find. They either guess an import from @unitpay/react or reimplement the formatters — the exact divergence the "hard rule" exists to prevent.
The fix: Document formatCredits, formatMoney, and <Money> (package, imports, signatures) or rewrite the denominations guidance in terms of primitives that exist in the documented API.
8. llms-full.txt strips every parameter name from the reference — the AI-facing docs lose the most structured data (significant)
Problem: The per-page .md sources carry named parameters (<ParamField body="customerId" type="string" required>), but the generated llms-full.txt renders the same fields as <ParamField type="string"> — the body name attribute is gone on every ParamField across all 31 pages. In llms-full.txt, portalSessions.create appears to take two anonymous parameters ("The customer this session grants access to", type string) with no field names.
Consequence: llms-full.txt exists precisely so agents can ingest the whole docs in one fetch — and it's the one artifact where every parameter is nameless. An agent working from it must guess field names (customerId? customer? id?) for every SDK call and REST body in the reference.
The fix: Fix the llms-full.txt generation to preserve ParamField name attributes (or render them as Markdown definition lists with names inline).
9. Wallet status casing contradicts between the lifecycle table and the API example (minor)
Location: /documentation/credits/wallets-and-ledger vs /react/credits-and-wallets
Problem: The wallet lifecycle is documented as literal backticked values ACTIVE / FROZEN / CLOSED ("The ACTIVE / FROZEN / CLOSED lifecycle is part of v2 credit accounts"), but the Credit account object example on the React page shows "status": "active" in lowercase. The same React page documents ledger type as uppercase 'CREDIT' / 'DEBIT'.
Consequence: Code that branches on wallet.status === 'ACTIVE' per the lifecycle table never matches the lowercase value the API returns — a silent always-false comparison, the kind agents won't catch.
The fix: Show the literal wire casing in the lifecycle table (or normalize the API), and state the convention once.
10. Auto top-up rules described as "per-feature" but keyed per credit currency (minor)
Location: /documentation/credits/top-ups
Problem: "You configure a per-feature threshold and a quantity to purchase (PUT /v1/customers/:id/auto-topup-rules/:creditCurrencyId)" — the endpoint path, the React useAutoTopUp(creditCurrencyId) hook, and the wallet model all scope rules per credit currency, not per feature. The same sentence asserts "Purchase limits cap runaway spend, and burst suppression prevents duplicate purchases" with no documentation of either mechanism's configuration or defaults.
Consequence: Developers look for a feature-level threshold parameter that doesn't exist, and can't reason about the spend-cap and burst-suppression guardrails they're relying on for runaway-spend protection.
The fix: Change "per-feature" to "per credit currency," and document the purchase-limit and burst-suppression behavior (fields, defaults, observable effects).
11. The two SDKs document different automatic-retry status sets (minor)
Location: /node/errors vs /react/errors
Problem: The Node SDK retries "429, 409, and 5xx responses plus network errors"; the React SDK's HttpError.isRetryable is "true for 408 / 429 / 5xx" and retries those. One retries 409 but not 408; the other 408 but not 409 — with no note that the divergence is intentional.
Consequence: Developers assuming symmetric behavior (e.g., relying on 409 idempotency-conflict retries in the browser, or 408 retries on the server) get different failure semantics per runtime without warning.
The fix: Either align the retry sets or add a note to each errors page stating the difference and why.
12. Sandbox and live keys share the identical prefix, and marketing features are absent from the docs (minor)
Problem: Authentication says switching environments means "swap upay_sk_… (test) for upay_sk_… (live)" — the prefixes shown are identical, so nothing in a key tells you which environment it hits; the docs' own mitigation is "Keep them in separate environment variables so a test key never runs in production." Separately, the homepage promises "one prompt to our MCP server in Cursor," "Pricing Studio," and "Revenue Intelligence" — none of which appear anywhere in the docs index.
Consequence: A pasted key is environmentally ambiguous at a billing company — a mis-set env var charges real cards with no visual tell. And developers arriving for the advertised MCP server find no setup instructions at all.
The fix: Encode the environment in the key prefix (e.g. upay_sk_test_…) or document how to identify a key's environment; add docs for the MCP server or stop advertising it as an integration path.
What they do well
Exceptionally coherent conceptual model — the "two paths, two verbs" framing is consistent across every page, and cross-links are dense and mostly accurate.
Both llms.txt and llms-full.txt exist, and every page is fetchable as clean Markdown by appending .md.
Ship a real REST API reference (OpenAPI) for every /v1/* endpoint the docs already name — this is the single biggest gap for a payments API.
Document webhooks end-to-end (event catalog, payloads, endpoint setup, secrets) and the <PaymentForm> component — the two undocumented pillars of the money path.
Reconcile the cross-page contradictions: env var name, subscription outcome kinds, track response shape, and status casing — each one silently breaks copy-paste code and AI agents.