UnitPay Documentation Audit
The concept pages and SDK references are dense and genuinely well-written, but the docs describe a system whose vocabulary hasn't converged: the same endpoint, the same access-check field, the same subscription outcome, and the same auto-top-up rule each appear under two or three different names across pages — and the marketing homepage's flagship snippet uses an SDK, a key format, and an event field that the docs don't recognize.
1. Auth page tells you to set UNITPAY_SECRET_KEY but auto-reads UNITPAY_API_KEY (critical)
Location: /documentation/authentication
Problem: The page's .env block sets UNITPAY_SECRET_KEY=upay_sk_..., and the code sample reads process.env.UNITPAY_SECRET_KEY. But the prose one line above says: "The Node SDK reads UNITPAY_API_KEY automatically if you don't pass a key explicitly." Those are two different variable names. Every Node SDK page (/node/introduction) standardizes on UNITPAY_API_KEY, while /react/next-adapter uses UNITPAY_SECRET_KEY — so the docs are split against themselves.
Consequence: A developer who follows this page's .env example and relies on the documented auto-read behavior sets UNITPAY_SECRET_KEY, and the SDK — which looks for UNITPAY_API_KEY — finds nothing. Per /node/introduction, "new UnitPay() throws if no key is found." The onboarding path fails at instantiation.
The fix: Pick one env-var name (UNITPAY_API_KEY is used by more pages) and use it everywhere — the Authentication .env block, the Authentication code sample, the Next.js adapter, and the Node introduction. If both names are genuinely supported, state that explicitly in one place.
2. The homepage "10 Lines of Code" snippet doesn't match the documented SDK (critical)
Location: https://www.useunitpay.com/ vs /node/introduction, /documentation/authentication, /documentation/how-it-works
Problem: The marketing homepage's headline code sample uses import { UnitPay } from '@unitpay/sdk', a key of the form apiKey: 'up_live_...', and unitpay.track({ customerId, event: 'api_call', properties }). The docs use a different package (@unitpay/node), a different key format (upay_sk_… / upay_pk_…), and a different event field (track({ ..., eventName: 'ai-generation', quantity }) — no event, no properties).
Consequence: The homepage snippet is the first code most developers (and AI coding agents) copy. Installing @unitpay/sdk fails (docs ship @unitpay/node/@unitpay/react); a up_live_... key never appears anywhere in the docs' key model; and track({ event, properties }) doesn't match the documented { eventName, quantity } shape. Three independent breakages in the single most prominent example.
The fix: Regenerate the homepage sample from the same source as the docs, or explicitly reconcile the two — one package name, one key prefix scheme, one track signature. Whichever is real, delete the other.
3. Two documented paths for the access check: /v1/check vs /v1/customers/:id/check (critical)
Location: /documentation/how-it-works and /documentation/getting-started/setup vs /react/entitlements-and-gates
Problem: How-it-works and the Quickstart document the runtime check as a top-level POST /v1/check with customerId in the body. The React entitlements page documents POST /v1/customers/:id/check (customer in the path) and POST /v1/customers/:id/check/batch. These are two different URL structures for the same "check entitlement" operation.
Consequence: A developer wiring server-side checks off the core concept pages calls /v1/check; one wiring the same behavior from the React/entitlements docs calls /v1/customers/:id/check. At most one of these routes exists. Anyone who trusts the wrong page ships requests to a nonexistent endpoint, and there is no note saying they are aliases.
The fix: Document one canonical check endpoint. If both exist (e.g., a convenience top-level route and a REST-nested route), say so on both pages and cross-link them, specifying which the SDKs actually call.
4. The access check returns access in HTTP/Node but allowed in React (critical)
Location: /documentation/how-it-works and /node/usage vs /react/customer
Problem: The HTTP response ({ "access": true, ... }) and the Node UnitPayIngestion.check (returns ... { access: boolean, deniedReason? }) both name the boolean field access. The React useCustomer().check() returns { allowed, deniedReason } — the field is named allowed, not access.
Consequence: A developer moving a gate from server to client (or reading both docs) writes if (result.access) against the React hook, which never sets access — so the condition is undefined/falsy and every feature silently reads as denied. Because it fails silently (no error, just a wrong boolean), it's the kind of bug that ships to production.
The fix: Use one field name for the access boolean across HTTP, Node, and React. If the React hook intentionally renames it, call that out prominently on /react/customer with a "differs from the HTTP/Node access field" note.
5. Node SDK covers subscriptions but has no subscriptions.create (significant)
Location: /node/introduction
Problem: The Node SDK is introduced as covering "customers, subscriptions, usage, and portal sessions," but the enumerated subscription methods are only subscriptions.list, subscriptions.cancel, and subscriptions.uncancel. There is no subscriptions.create. Yet creating a subscription via POST /v1/subscriptions is a core flow documented in both the Quickstart and the Billing page.
Consequence: A server-side developer using @unitpay/node expects unitpay.subscriptions.create(...) and can't find it. The docs never say whether subscription creation is deliberately client-side only (via the React useCreateSubscription hook) or whether the method simply isn't documented. The developer is left guessing whether to hand-roll a raw POST /v1/subscriptions call.
The fix: Either add and document subscriptions.create in the Node SDK, or explicitly state that subscription creation is intentionally client-driven (React) and show the supported server-side path for creating a subscription.
6. The subscription outcome union is 4 kinds on one page and 7 on another (significant)
Location: /documentation/billing vs /react/settlement
Problem: The Billing page documents the POST /v1/subscriptions outcome as a union "discriminated by kind" with exactly four values: created_no_charge, charged_inline, requires_form, deferred. The React Settlement page documents the same SettleOutcome discriminated union with seven: it adds invoice_sent, invoice_added, and no_action.
Consequence: A developer who writes a switch on kind from the Billing table handles four cases and has no branch for invoice_sent, invoice_added, or no_action. When the server returns one of those (e.g., for NET-terms/send-invoice customers), the subscription silently falls through unhandled — no charge acknowledged, no callback fired.
The fix: Make the two tables enumerate the same set of kinds, or have the Billing page state that it lists a subset and link to the Settlement page as the authoritative full enumeration.
7. Node and React SDKs expose incompatible error taxonomies with no mapping (significant)
Location: /node/errors vs /react/errors
Problem: The Node SDK's errors all extend ApiError, with subclasses like BadRequestError, AuthenticationError (401), RateLimitError (429), ValidationError (422). The React SDK's errors all extend a differently-named base, UnitPayError, with an entirely different hierarchy: HttpError (carrying isAuthError for 401/403, isRateLimited for 429, isRetryable), NetworkError, TimeoutError, PmInUseError. Status handling differs too: Node maps 401 to a dedicated AuthenticationError; React folds 401/403 into HttpError.isAuthError.
Consequence: A developer using both SDKs (server + client, which UnitPay's own design assumes) must maintain two unrelated instanceof ladders for the same underlying API errors, and nothing documents how, say, a Node RateLimitError corresponds to a React HttpError with isRateLimited. Shared error-handling code is impossible to write from the docs.
The fix: Add a cross-reference table mapping each Node error class to its React equivalent (and to the underlying HTTP status), so developers can reason about API errors uniformly across the two SDKs.
8. Two enumerations of deniedReason that don't match (significant)
Location: /react/entitlements-and-gates (FeatureGate.onDenied) vs /react/customer (useCustomer().check)
Problem: On the same React page family, FeatureGate's onDenied reason enum is no_entitlement, no_active_subscription, usage_exceeded, credit_exhausted, not_loaded. But useCustomer().check() documents its deniedReason as one of not_loaded, no_entitlement, usage_exceeded, credit_exhausted — omitting no_active_subscription.
Consequence: A developer branching on deniedReason from useCustomer().check() won't write a case for no_active_subscription because that page says it can't occur. If the value can actually be returned there, the "customer has no active subscription" case falls through to a default/unknown branch, producing a wrong denial message.
The fix: Define the deniedReason enum once as a shared type and reference it from both the hook and the gate, or explicitly document why the two surfaces return different reason sets.
9. track response shape differs between the SDK and the curl examples (significant)
Location: /node/usage vs /documentation/how-it-works and /documentation/getting-started/setup
Problem: The curl and Node examples in How-it-works and the Quickstart show track returning { "accepted": 1, "rejected": 0, "results": [{ "status": "ok", "flow": "credit", "creditsDeducted": "1", "creditBalance": "41" }] } — a results[] array with per-event outcomes. But /node/usage documents the return as accepted: number, rejected: number, and optional rejections ({ index, reason, message }[]) — a rejections array, no results.
Consequence: A developer who reads the Quickstart parses response.results[0].creditBalance to confirm the deduction; the documented SDK return has no results field at all, so that access is undefined. Conversely, the SDK's rejections field never appears in the example responses, so failure handling can't be modeled from the examples.
The fix: Reconcile the documented track/usage response into one schema — decide whether success details live in results[] or are implied, and where rejections live — and use that shape in every example.
10. Auto-top-up rule has three different field-naming schemes for the same concept (significant)
Location: /react/credits-and-wallets (useAutoTopUp) and /documentation/credits/top-ups
Problem: useAutoTopUp reads a rule with fields enabled, threshold, packageId, amount, monthlyChargeLimit — but its set() writer takes threshold, creditPackageId (not packageId), and topupAmount (not amount). Separately, usePaymentMethodDependencies is described as returning autoTopupThreshold / autoTopupAmount / autoTopupPackageId. That is three naming conventions (packageId/amount, creditPackageId/topupAmount, autoTopupPackageId/autoTopupAmount) for the same auto-top-up fields.
Consequence: A developer who reads the current rule and tries to write it back with the same field names silently sets nothing — set({ packageId, amount }) doesn't match the { creditPackageId, topupAmount } the writer expects, so the values are dropped. This is a classic read/write asymmetry that produces a no-op update with no error.
The fix: Use one field name per concept across read, write, and the dependencies hook — or, if the wire formats genuinely differ, add a mapping table showing read-field → write-field → dependencies-field for each of threshold, package, and amount.
11. FeatureGate: two contradictory definitions of what fallback does (significant)
Location: /react/entitlements-and-gates
Problem: The shared-conventions intro states: "(FeatureGate is the exception — it uses fallback for the denied slot; see below.)" But FeatureGate's own Props section lists a separate noAccessComponent "Rendered when access is denied," and defines fallback as the "Type-safe entitlement default used while loading or on a cache miss" — i.e., the loading/cache-miss default, not the denied slot. Both statements are on the same page and cannot both be true.
Consequence: A developer follows the intro and puts their "access denied" UI in fallback, but per the Props section fallback renders during loading/cache-miss — so the denied UI flashes on every load, and the actual denied slot (noAccessComponent) is empty. Behavior is the opposite of intended.
The fix: Decide what fallback renders on FeatureGate, correct whichever statement is wrong, and show a minimal example distinguishing the denied slot from the loading slot.
12. "The four mutation hooks that move money" lists only three (significant)
Location: /react/subscriptions
Problem: The page says: "The four mutation hooks that move money (useCreateSubscription, useChangePlan, useAttachAddon) resolve to a SettleOutcome…" — the sentence promises four but names three.
Consequence: A developer can't tell whether a fourth money-moving hook was accidentally omitted from the list (and they're missing an API they should be handling with settlement callbacks) or whether the count is simply wrong. Since useTopUp and usePayInvoice also move money (per /react/settlement) but "live on other pages," the ambiguity is real.
The fix: Either correct the count to "three," or add the intended fourth hook to the list. Cross-link the other money-moving hooks (useTopUp, usePayInvoice) so the set is unambiguous.
13. Large marketed surface has zero documentation; docs have no pricing or changelog (significant)
Location: https://www.useunitpay.com/ vs /llms.txt (full page inventory)
Problem: The marketing site claims "TypeScript, Python, Go, and Ruby SDKs," an "MCP Server" ("Install in Cursor, Claude Code, or Windsurf"), Paddle as a payment processor, and a whole "Revenue Intelligence" surface (Pricing Studio, CPQ, Simulations, Margin/Cost Tracking, 50+ integrations). The docs inventory in llms.txt lists only Node and React SDKs plus core concept pages — no Python/Go/Ruby SDK page, no MCP page, no Paddle mention (the docs name only Stripe and Razorpay), and no pricing or changelog page. Pricing ("Free until $500K ARR," "$1,000/mo + 0.65%") exists only on marketing.
Consequence: A developer who chose UnitPay for its advertised Python SDK, MCP server, or Paddle support arrives at the docs and finds none of it — with no "coming soon" marker to distinguish roadmap from shipped. AI coding agents told to "add usage-based billing" via the advertised MCP server have nothing to index. And developers can't confirm pricing or track breaking changes from within the docs.
The fix: Bring the docs' claimed surface in line with marketing — either document the Python/Go/Ruby SDKs, MCP server, and Paddle, or mark them clearly as roadmap on the marketing page. Add a pricing reference and a changelog to the docs site.
14. Quickstart's check response silently drops the feature object shown elsewhere (minor)
Location: /documentation/getting-started/setup vs /documentation/how-it-works
Problem: How-it-works shows the check response as { "access": true, "remaining": 42, "creditBalance": 42, "feature": { "slug": "ai-generation", "type": "credit" } }. The Quickstart shows the identical call returning { "access": true, "remaining": 100, "creditBalance": 100 } — no feature object.
Consequence: A developer can't tell whether feature is always present, conditionally present, or was simply omitted for brevity in the Quickstart. Code that reads response.feature.type may throw on responses that omit it.
The fix: Show a single canonical check response schema and note which fields are always vs. conditionally present.
15. Denominations "Fiat credits" example renders as a garbled math blob (minor)
Location: /documentation/credits/denominations
Problem: The "Fiat credits" Example callout ("A customer buys $10 of API credit… their balance falls from $10.00 toward $0.00") is being parsed as LaTeX/MathJax because of the $…$ delimiters, and renders in-browser as a scrambled sequence of stacked symbols instead of prose.
Consequence: The one worked example that explains how a prepaid fiat wallet draws down is unreadable in the rendered docs — exactly the concept ("it's a wallet of prepaid dollars, not a count") the page is trying to teach.
The fix: Escape the dollar signs (or wrap the amounts in inline code / the <Money> component) so the math renderer doesn't capture them, and verify the callout renders as text.
16. verifyWebhook is documented but there is no webhooks reference (minor)
Location: /node/errors and /llms.txt
Problem: The Node errors page documents UnitPay.verifyWebhook(body, headers, secret) and says it "validates the Svix signature and returns the parsed, camelCased event." But the llms.txt page inventory contains no webhooks page — no event catalog, no payload schemas, no list of event types.
Consequence: A developer can verify a webhook signature but has no documented list of what events UnitPay sends, their payload shapes, or when they fire — so they can't write handlers against a known contract. Given renewals, past_due transitions, and settlement all happen asynchronously, this is a real gap for production integrations.
The fix: Add a webhooks reference page enumerating event types and payload schemas, and link it from the verifyWebhook documentation.
17. past_due is described alongside invoice statuses but is a subscription status (minor)
Location: /documentation/billing vs /react/invoices
Problem: The Billing page's narrative says a failed renewal makes "the subscription goes past_due and the open invoice can be paid." The invoices page defines the invoice status enum as draft · issued · partially_paid · paid · overdue · void · uncollectible — which does not include past_due (the invoice-side term is overdue). The two statuses overlap conceptually but belong to different objects.
Consequence: A developer reading Billing may check invoice.status === 'past_due', which never matches the documented invoice enum; the correct invoice value is overdue, while past_due is the subscription's status.
The fix: In the Billing narrative, disambiguate: state that the subscription becomes past_due and the invoice becomes overdue, and link to the invoice status enum.
What they do well
- The credit model is documented with real rigor — the grant priority table (PLG vs enterprise) and the explicit FIFO tie-break (
expiresAt ASC → createdAt ASC) give developers deterministic behavior to build on. - The core mental model ("two verbs: check and track," check is a read, track is the write) is crisp and repeated consistently across concept pages.
- The docs expose an llms.txt inventory and
.mdsource for pages, which is exactly the agent-friendly structure most doc sites still lack.
Top 3 recommendations
- Establish one vocabulary per concept and enforce it across pages and SDKs. The env-var name, the access-check field (
access/allowed), the auto-top-up fields, thedeniedReasonenum, and the subscription-outcomekindset each need a single source of truth that every page references — most of the critical findings are the same underlying problem. - Regenerate the marketing homepage snippet from the docs' actual SDK. Package name, key prefix, and
tracksignature must match what the docs ship, or the highest-traffic example fails on copy-paste. - Close the documented-vs-marketed surface gap. Either document the Python/Go/Ruby SDKs, MCP server, Paddle, webhooks, and pricing/changelog, or clearly mark unshipped items as roadmap so developers and agents don't chase features that aren't there.