Customer Invoices

React Package · @usethrottle/invoices

React hooks and a server proxy helper for surfacing a buyer's unified invoice list — card captures, Net-N issuances, and subscription charges — inside your storefront portal.

Secret key stays server-side
Browser hooks never call Throttle directly. They call your backend proxy, which holds the sk_* key and resolves the authenticated buyer's identity before forwarding the read.

Install

bash
pnpm add @usethrottle/invoices

Backend proxy · /server entrypoint

Mount createInvoiceProxyHandler from @usethrottle/invoices/server on a catch-all route. The handler accepts any framework that exposes a standard Request object (Next.js App Router, Hono, Remix, etc.).

createInvoiceProxyHandler(options)

  • apiKey: string — your sk_* secret key.
  • getCustomerId(request): string | null | Promise<string | null> — called on every request. Return the buyer's external id (e.g. your auth framework's user id). Returning null causes the proxy to reply 401 unauthorized.
  • baseUrl?: string — defaults to https://api.usethrottle.dev.

The proxy allows three read-only paths: /api/v1/invoices (list), /api/v1/invoices/:id (get), and /api/v1/invoices/:id/download (PDF URL). All other paths and non-GET methods return an error. List requests are transparently rewritten to /api/v1/customers/:customerId/invoices so the buyer can only read their own invoices.

app/api/throttle/invoices/[...path]/route.ts
// app/api/throttle/invoices/[...path]/route.ts
import { createInvoiceProxyHandler } from '@usethrottle/invoices/server';
import { auth } from '@/lib/auth';

const handler = createInvoiceProxyHandler({
  apiKey: process.env.THROTTLE_SECRET_KEY!,
  async getCustomerId(request) {
    // Return your internal user / customer id (e.g. a Clerk userId).
    // The proxy resolves it via GET /api/v1/customers/by-external/:externalId.
    const user = await auth();
    return user?.id ?? null;
  },
});

// Only GET is required; the proxy rejects everything else.
export { handler as GET };

Provider

Mount InvoiceProvider above any component tree that uses invoice hooks. The fetcher prop is the bridge to your backend proxy — every hook routes through it.

tsx
'use client';
import { InvoiceProvider } from '@usethrottle/invoices';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <InvoiceProvider
      fetcher={async (path, init) => fetch(`/api/throttle/invoices${path}`, init)}
    >
      {children}
    </InvoiceProvider>
  );
}

Hooks

useInvoices(params?, options?)

Lists invoices for the authenticated buyer. Returns a QueryState<PaginatedInvoices> with { data, isLoading, isFetching, error, refetch }.

params: status? (paid | open | refunded | partially_refunded | void), type? (order | net30 | subscription), cursor?, limit?, sort? (asc | desc).

Note: The SDK normalizes the REST meta.pagination envelope into a top-level pagination on the hook result — so with the hooks you read result.data.pagination.cursor / .hasMore, whereas raw REST responses carry it under meta.pagination.

tsx
import { useInvoices } from '@usethrottle/invoices';

function InvoiceList() {
  const { data, isLoading, error } = useInvoices({
    limit: 20,
    sort: 'desc',
    // Optional filters:
    // status: 'paid',
    // type: 'subscription',
    // cursor: '...',
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorView error={error} />;
  if (!data) return null;

  return (
    <>
      {data.data.map((invoice) => (
        <InvoiceRow key={invoice.id} invoice={invoice} />
      ))}
      {data.pagination?.hasMore && (
        <LoadMoreButton cursor={data.pagination.cursor} />
      )}
    </>
  );
}

useInvoice(id, options?)

Fetches a single invoice by id. Skips the fetch when id is null or undefined.

tsx
import { useInvoice } from '@usethrottle/invoices';

function InvoiceDetail({ id }: { id: string }) {
  const { data: invoice, isLoading } = useInvoice(id);
  if (isLoading || !invoice) return <Spinner />;

  return (
    <article>
      <h2>{invoice.number}</h2>
      <p>Status: {invoice.status}</p>
      <p>Total: {invoice.total / 100} {invoice.currency}</p>
      {invoice.amountRefunded ? (
        <p>Refunded: {invoice.amountRefunded / 100} {invoice.currency}</p>
      ) : null}
    </article>
  );
}

useDownloadInvoice()

Returns an async function (id: string) => Promise<string> that fetches the signed PDF URL and opens it in a new tab. Returns the raw URL so your UI can also use it as an anchor href.

tsx
import { useDownloadInvoice } from '@usethrottle/invoices';

function DownloadButton({ invoiceId }: { invoiceId: string }) {
  const download = useDownloadInvoice();

  return (
    <button onClick={() => download(invoiceId)}>
      Download PDF
    </button>
  );
}

To capture the returned URL for custom usage (e.g., render as an anchor href):

tsx
import { useDownloadInvoice } from '@usethrottle/invoices';

function InvoiceLink({ invoice }: { invoice: Invoice }) {
  const download = useDownloadInvoice();

  return (
    <a
      href="#"
      onClick={async (e) => {
        e.preventDefault();
        await download(invoice.id); // Opens PDF in new tab
      }}
    >
      View PDF
    </a>
    // Or capture the URL for custom use:
    // const url = await download(invoice.id);
    // href={url} download={invoice.number}
  );
}

Cursor pagination

Responses carry a pagination object inside data.pagination. When hasMore is true, pass the opaque cursor string in the next useInvoices call.

tsx
import { useState } from 'react';
import { useInvoices } from '@usethrottle/invoices';

function PaginatedList() {
  const [cursor, setCursor] = useState<string | null>(null);
  const { data, isLoading } = useInvoices({ cursor: cursor ?? undefined, limit: 20 });

  return (
    <>
      {data?.data.map((inv) => <Row key={inv.id} invoice={inv} />)}
      {data?.pagination?.hasMore && (
        <button
          disabled={isLoading}
          onClick={() => setCursor(data.pagination!.cursor)}
        >
          Load more
        </button>
      )}
    </>
  );
}

Cache invalidation

All hooks share a cache version. Call useInvoiceInvalidation() to bump the version and cause every mounted hook to re-fetch. Use this after a refund or a payment action that changes invoice state.

tsx
import { useInvoiceInvalidation } from '@usethrottle/invoices';

function RefundButton({ paymentId }: { paymentId: string }) {
  const invalidate = useInvoiceInvalidation();

  async function handleRefund() {
    await fetch(`/api/payments/${paymentId}/refund`, { method: 'POST' });
    // Tell all useInvoices / useInvoice hooks to re-fetch.
    invalidate();
  }

  return <button onClick={handleRefund}>Refund</button>;
}

REST routes

The following merchant-facing routes require an invoices:read API key scope.

  • GET /api/v1/invoices — list invoices for the application (all customers, all source types).
  • GET /api/v1/invoices/:id — fetch a single invoice.
  • GET /api/v1/invoices/:id/download — 302 redirect to a 10-minute signed PDF URL; append ?format=json to get { "data": { "url": "..." } } instead.
  • GET /api/v1/customers/:customerId/invoices — list invoices scoped to a specific customer (used internally by the proxy).

All list endpoints accept cursor, limit, sort (asc | desc), status, and type query parameters.

Invoice object fields

  • id — Throttle invoice id.
  • number — human-readable invoice number (e.g. INV-sprinter-001).
  • status paid | open | refunded | partially_refunded | void.
  • sourceType order | net30 | subscription.
  • currency — ISO-4217 currency code.
  • subtotal, taxTotal, shippingTotal, discountTotal, total — integer minor units.
  • amountRefunded — integer minor units refunded (0 when not refunded).
  • issuedAt — ISO-8601 invoice date.
  • dueAt — ISO-8601 due date for Net-N invoices; null for card captures and subscriptions.
  • periodStart, periodEnd — billing period for subscription invoices; null otherwise.
  • createdAt — ISO-8601 record creation timestamp.

Boundaries

  • The package is read-only — it surfaces invoices and PDFs but does not create or mutate payment records.
  • Payment collection stays in @usethrottle/checkout-react. This package renders the resulting invoices.
  • Merchant-wide views (all customers) are available via the REST routes with a secret key; the React hooks are scoped to the authenticated buyer's invoices through the proxy.