1. Customer accounts
Handwrite Admin
  • Handwrite Admin
    • Start here — staff console
    • Admin API specification notes
    • Admin socket specification notes
    • Staff role and permission matrix
    • Staff workflows and architecture
    • Admin source and validation status
    • Customer accounts
      • README — GET /api/admin/accounts/{sub}/billing
      • README — GET /api/admin/accounts/{sub}/cloud/documents/{id}
      • README — GET /api/admin/accounts/{sub}/cloud/documents
      • README — POST /api/admin/accounts/{sub}/cloud/keys/{keyId}/revoke
      • README — GET /api/admin/accounts/{sub}/cloud/keys
      • README — GET /api/admin/accounts/{sub}/cloud/oauth
      • README — GET /api/admin/accounts/{sub}/cloud/orgs
      • README — GET /api/admin/accounts/{sub}/cloud/settings
      • README — GET /api/admin/accounts/{sub}/cloud/usage
      • README — GET /api/admin/accounts/{sub}/cloud/webhooks
      • README — POST /api/admin/accounts/{sub}/delete
      • README — POST /api/admin/accounts/{sub}/disable
      • README — POST /api/admin/accounts/{sub}/enable
      • README — POST /api/admin/accounts/{sub}/mfa-reset
      • README — POST /api/admin/accounts/{sub}/password-reset
      • README — POST /api/admin/accounts/{sub}/purge
      • README — POST /api/admin/accounts/{sub}/restore
      • README — GET /api/admin/accounts/{sub}
      • README — PATCH /api/admin/accounts/{sub}
      • README — POST /api/admin/accounts/{sub}/sign-out
      • README — GET /api/admin/accounts
      • GET /api/admin/accounts/{sub}/billing
        GET
      • GET /api/admin/accounts/{sub}/cloud/documents/{id}
        GET
      • GET /api/admin/accounts/{sub}/cloud/documents
        GET
      • POST /api/admin/accounts/{sub}/cloud/keys/{keyId}/revoke
        POST
      • GET /api/admin/accounts/{sub}/cloud/keys
        GET
      • GET /api/admin/accounts/{sub}/cloud/oauth
        GET
      • GET /api/admin/accounts/{sub}/cloud/orgs
        GET
      • GET /api/admin/accounts/{sub}/cloud/settings
        GET
      • GET /api/admin/accounts/{sub}/cloud/usage
        GET
      • GET /api/admin/accounts/{sub}/cloud/webhooks
        GET
      • POST /api/admin/accounts/{sub}/delete
        POST
      • POST /api/admin/accounts/{sub}/disable
        POST
      • POST /api/admin/accounts/{sub}/enable
        POST
      • POST /api/admin/accounts/{sub}/mfa-reset
        POST
      • POST /api/admin/accounts/{sub}/password-reset
        POST
      • POST /api/admin/accounts/{sub}/purge
        POST
      • POST /api/admin/accounts/{sub}/restore
        POST
      • GET /api/admin/accounts/{sub}
        GET
      • PATCH /api/admin/accounts/{sub}
        PATCH
      • POST /api/admin/accounts/{sub}/sign-out
        POST
      • GET /api/admin/accounts
        GET
    • Staff administration
      • README — POST /api/admin/admins/{sub}/password-reset
      • README — PATCH /api/admin/admins/{sub}
      • README — DELETE /api/admin/admins/{sub}/sessions
      • README — GET /api/admin/admins
      • README — POST /api/admin/admins
      • POST /api/admin/admins/{sub}/password-reset
      • PATCH /api/admin/admins/{sub}
      • DELETE /api/admin/admins/{sub}/sessions
      • GET /api/admin/admins
      • POST /api/admin/admins
    • Operations
      • README — GET /api/admin/alarms
      • README — GET /api/admin/audit
      • README — GET /api/admin/billing
      • README — GET /api/admin/config
      • README — PUT /api/admin/config
      • README — GET /api/admin/deletions
      • README — GET /api/admin/icloud/probe
      • README — POST /api/admin/jobs/rollup
      • README — GET /api/admin/me
      • README — GET /api/admin/overview
      • README — GET /api/admin/usage/accounts/{sub}
      • README — GET /api/admin/usage/orgs/{id}
      • README — GET /api/admin/usage/rollup
      • GET /api/admin/alarms
      • GET /api/admin/audit
      • GET /api/admin/billing
      • GET /api/admin/config
      • PUT /api/admin/config
      • GET /api/admin/deletions
      • GET /api/admin/icloud/probe
      • POST /api/admin/jobs/rollup
      • GET /api/admin/me
      • GET /api/admin/overview
      • GET /api/admin/usage/accounts/{sub}
      • GET /api/admin/usage/orgs/{id}
      • GET /api/admin/usage/rollup
    • Authentication
      • README — POST /api/admin/auth/challenge
      • README — POST /api/admin/auth/sign-in
      • README — POST /api/admin/auth/sign-out-all
      • README — POST /api/admin/auth/sign-out
      • README — POST /api/admin/auth/step-up
      • POST /api/admin/auth/challenge
      • POST /api/admin/auth/sign-in
      • POST /api/admin/auth/sign-out-all
      • POST /api/admin/auth/sign-out
      • POST /api/admin/auth/step-up
    • Staff webhooks
      • README — DELETE /api/admin/hooks/{id}
      • README — POST /api/admin/hooks/{id}/test
      • README — GET /api/admin/hooks
      • README — POST /api/admin/hooks
      • README — staff webhook delivery
      • DELETE /api/admin/hooks/{id}
      • POST /api/admin/hooks/{id}/test
      • GET /api/admin/hooks
      • POST /api/admin/hooks
    • Organisations
      • README — POST /api/admin/orgs/{id}/api-access
      • README — GET /api/admin/orgs/{id}/audit
      • README — GET /api/admin/orgs/{id}/members
      • README — GET /api/admin/orgs/{id}
      • README — GET /api/admin/orgs/{id}/usage
      • POST /api/admin/orgs/{id}/api-access
      • GET /api/admin/orgs/{id}/audit
      • GET /api/admin/orgs/{id}/members
      • GET /api/admin/orgs/{id}
      • GET /api/admin/orgs/{id}/usage
    • Schools
      • README — POST /api/admin/schools/{orgId}/close
      • README — GET /api/admin/schools/{orgId}
      • README — PATCH /api/admin/schools/{orgId}
      • README — GET /api/admin/schools
      • POST /api/admin/schools/{orgId}/close
      • GET /api/admin/schools/{orgId}
      • PATCH /api/admin/schools/{orgId}
      • GET /api/admin/schools
    • Sessions
      • README — DELETE /api/admin/sessions/{id}
      • README — GET /api/admin/sessions
      • DELETE /api/admin/sessions/{id}
      • GET /api/admin/sessions
    • Support
      • README — GET /api/admin/support/canned/{id}
      • README — PUT /api/admin/support/canned/{id}
      • README — DELETE /api/admin/support/canned/{id}
      • README — GET /api/admin/support/canned
      • README — POST /api/admin/support/canned
      • README — GET /api/admin/support/tickets/{id}/attachments/{n}
      • README — GET /api/admin/support/tickets/{id}/link
      • README — POST /api/admin/support/tickets/{id}/link
      • README — POST /api/admin/support/tickets/{id}/note
      • README — POST /api/admin/support/tickets/{id}/reply
      • README — GET /api/admin/support/tickets/{id}
      • README — PATCH /api/admin/support/tickets/{id}
      • README — POST /api/admin/support/tickets/{id}/school
      • README — POST /api/admin/support/tickets/{id}/spam
      • README — GET /api/admin/support/tickets
      • GET /api/admin/support/canned/{id}
      • PUT /api/admin/support/canned/{id}
      • DELETE /api/admin/support/canned/{id}
      • GET /api/admin/support/canned
      • POST /api/admin/support/canned
      • GET /api/admin/support/tickets/{id}/attachments/{n}
      • GET /api/admin/support/tickets/{id}/link
      • POST /api/admin/support/tickets/{id}/link
      • POST /api/admin/support/tickets/{id}/note
      • POST /api/admin/support/tickets/{id}/reply
      • GET /api/admin/support/tickets/{id}
      • PATCH /api/admin/support/tickets/{id}
      • POST /api/admin/support/tickets/{id}/school
      • POST /api/admin/support/tickets/{id}/spam
      • GET /api/admin/support/tickets
    • Health
      • README — GET /api/health
      • GET /api/health
  1. Customer accounts

README — GET /api/admin/accounts/{sub}/billing

GET /api/admin/accounts/{sub}/billing#

GET /api/admin/accounts/:sub/billing (billing.read + a reason): the account's Voices plan, credit purchases, balance
and period usage, the org subscriptions it owns or pays for (no Stripe ids), and, when a restricted Stripe read key
is configured, live Stripe subscription status and invoices per org (live, omitted otherwise).
Reason: the X-Admin-Reason header, or a fresh marker from an earlier reason for this account in this session
(15 minutes), so reloading doesn't re-prompt. One audit entry billing.view per session+account per 15 minutes.

Caller and authorization#

Staff cookie session; permission billing.read.
This is the private staff console on https://admin.handwrites.io. A personal API key, organisation token, student login or isolated app-testing credential does not authenticate this route.

Request contract#

LocationNameRequiredMeaning
pathsubYesUse the ID from an authorized staff response; source validators reject malformed IDs.
headerX-Admin-ReasonConditional / optionalURI-encoded written audit reason; Cloud/billing may reuse a fresh target-specific session marker.
This operation does not define a JSON payload. Staff writes still require Content-Type: application/json and the session-bound CSRF header.

Response and failures#

Success: 200. GET /api/admin/accounts/:sub/billing (billing.read + a reason): the account's Voices plan, credit purchases, balance
and period usage, the org subscriptions it owns or pays for (no Stripe ids), and, when a restricted Stripe read key
is configured, live Stripe subscription status and invoices per org (live, omitted otherwise).
Reason: the X-Admin-Reason header, or a fresh marker from an earlier reason for this account in this session
(15 minutes), so reloading doesn't re-prompt. One audit entry billing.view per session+account per 15 minutes.
The wrapper returns {error:{code,message}} for CloudError failures, attaches Request-Id, and disables caching. Handle 401 by signing in; show role refusal for 403 forbidden, reauthenticate only for step_up_required, and collect a reason for 400 reason_required. A CSRF failure needs a current page/session token, not blind retries. Operation-specific 404/409/413/422/502/503 errors retain their status. Honor Retry-After on 429.

Implementation contract#

Source snapshot: admin/src/app/api/admin/accounts/[sub]/billing/route.ts. This reference describes the current working tree, not proof of deployment. The response service types and validators remain authoritative. The exact exported handler below makes conditional behavior reviewable. For helpers such as customer-routes or cloudRoute, see the shared API specification notes and the named service module.
/**
 * GET /api/admin/accounts/:sub/billing (billing.read + a reason): the account's Voices plan, credit purchases, balance
 * and period usage, the org subscriptions it owns or pays for (no Stripe ids), and, when a restricted Stripe read key
 * is configured, live Stripe subscription status and invoices per org (`live`, omitted otherwise).
 * Reason: the X-Admin-Reason header, or a fresh marker from an earlier reason for this account in this session
 * (15 minutes), so reloading doesn't re-prompt. One audit entry `billing.view` per session+account per 15 minutes.
 */
export const GET = adminRoute("billing.read", async (_request, ctx, { params }: Context) => {
  const { sub } = await params;
  if (!SUB.test(sub)) throw notFound("Account");
  const target = billingTarget(sub);
  const marked = await accessReason(ctx, target);
  const reason = ctx.reason && ctx.reason.length >= 3 && ctx.reason.length <= 300 ? ctx.reason : marked;
  if (!reason) throw new CloudError(400, "reason_required", "Say why you need this (3–300 characters). It goes into the audit log.");
  if (!marked) {
    // Audit first: if the entry can't be written, nothing is shown.
    await recordAdminAudit(ctx, { action: "billing.view", targetType: "account", targetId: sub, reason });
    await grantAccess(ctx, target, reason);
  }

  const orgRows = await billableOrgs(sub);
  const billing = await accountBilling(sub, Date.now(), orgRows);
  const body: Record<string, unknown> = { ...billing, reason: { cached: !!marked } };
  if (await stripeReadConfigured()) {
    const orgs = orgRows.filter((org) => org.stripe.subscriptionId || org.stripe.customerId);
    body.live = await Promise.all(orgs.map(async ({ view, stripe }) => {
      try {
        const [subscription, invoices] = await Promise.all([
          stripe.subscriptionId ? liveSubscription(stripe.subscriptionId) : Promise.resolve<LiveSubscription | null>(null),
          stripe.customerId ? liveInvoices(stripe.customerId) : Promise.resolve<LiveInvoice[]>([]),
        ]);
        return { orgId: view.orgId, status: "ok" as const, subscription, invoices };
      } catch (error) {
        return { orgId: view.orgId, status: "error" as const, code: error instanceof CloudError ? error.code : "error" };
      }
    }));
  }
  return adminJson(body);
}, { reason: false });
Do not use production customer data to test this operation. Use an authorized staff test account and a disposable target in an appropriate environment; email, deletion, role and credential mutations have real effects.
Modified at 2026-10-26 13:41:16
Previous
Admin source and validation status
Next
README — GET /api/admin/accounts/{sub}/cloud/documents/{id}
Built with