CUSTOMER EXPERIENCE

Give customers their own usage console

Use the Modelvia workspace, or show usage and invoices inside your product. Each signed-in customer should see only their own activity and agreed charges.

Choose where customers sign in

Modelvia workspaceYour own application
Invite customers to the hosted workspace. Modelvia checks their current membership and shows scoped Usage and Billing pages.Your server verifies your application session, resolves the customer’s stored Modelvia binding, and requests their customer projection.
Use customer administrator or customer reader roles. Your client administrator manages invitations and payment settings.Keep the client integration key on your server. Your application owns customer login, membership checks and display.

ExampleCo is a fictional client business in these examples. Before either route, complete product setup and create the appropriate customer and project bindings.

Keep three credentials separate

CredentialPurpose and boundary
Client integration keyServer-to-server /v1/client/* access. It can manage and read all customers belonging to your client business. Store it as a server secret; never send it to a browser or include it in a public environment variable.
Customer project keyInference access for a customer, project and environment, subject to configured limits. It is not a login credential or a client-management key. Keep it in your backend or the approved private credential store for a local integration.
User login session / JWTA person’s identity. Hosted access uses Modelvia’s configured sign-in and current membership. A custom application verifies its own session on its own server; that session does not become a Modelvia integration key.

Never derive authorization from editable profile metadata, an email supplied by the browser, or a customer ID in a URL. A client key establishes your business’s scope; your backend must still establish which of that business’s customers the person may access.

Use the hosted workspace

  1. Have Modelvia onboarding bind your first client administrator to your business and confirm that invitation delivery and sign-in are configured.
  2. Open Team as that administrator, complete MFA when required, and invite a person to an existing customer as a customer administrator or customer reader.
  3. Have the person follow their own invitation and sign in. Verify Usage and Billing for that customer.
  4. Use customer reader for read-only access. Customer administrators can use an available checkout; they cannot change your business’s payment methods or record their own invoice as externally paid.

Signing up does not grant client administrator access. Customer invitations cannot select another client’s customer. Your business’s private supplier costs and aggregate service bill are separate from the customer’s view.

Read one customer’s usage and invoices

These are backend requests using your client integration key. Resolve {customerId} from a persisted membership before building the path.

GET pathUse it for
/v1/client/customers/{customerId}/usage-summary?period=2026-09Request count, input/output tokens, customer net charge and monthly headroom.
/v1/client/customers/{customerId}/analytics
…/analytics/trend
Customer-scoped breakdowns and trends. Start with the customer analytics read example.
/v1/client/customers/{customerId}/billing?period=2026-09Usage availability, customer charges, issued invoices and current payment choices.
/v1/client/customers/{customerId}/invoices/{invoiceId}
…/invoices/{invoiceId}/document
One issued resale invoice or its HTML document. Obtain invoice IDs from the customer’s billing response.

Do not forward the business-wide /v1/client/analytics response to a customer: it includes business cost information. The usage summary deliberately omits supplier costs and does not contain a customer ID; retain the trusted binding associated with the request. The billing response includes clientId and customerId, which your server should validate before rendering it.

If your customer is billed directly by Modelvia, use the hosted billing journey agreed during onboarding. Platform-billed invoice IDs do not belong to these resale-invoice document routes.

Bind the session before calling Modelvia

This framework-neutral server handler accepts only a month. Supply your application’s real session verifier and database lookup; they are required adapters, not Modelvia SDK functions. findActiveBinding must return the current authorized membership for the verified subject, including active, clientId and customerId.

Server-only customer usage handler
// Server-only JavaScript. Supply your existing authentication and database adapters.
export function createUsageHandler({ verifySession, findActiveBinding, clientId, clientKey }) {
  if (typeof verifySession !== "function" || typeof findActiveBinding !== "function" ||
      typeof clientId !== "string" || !clientId || typeof clientKey !== "string" || !clientKey) {
    throw new Error("Configure server authentication, bindings and Modelvia credentials first");
  }
  const reply = (body, status = 200) => Response.json(body, {
    status, headers: { "Cache-Control": "private, no-store" },
  });
  const decimal = (value) => typeof value === "string" && /^[0-9]+$/.test(value);
  const nullableDecimal = (value) => value === null || decimal(value);

  return async function GET(request) {
    if (request.method !== "GET") return reply({ error: "method_not_allowed" }, 405);
    const query = new URL(request.url).searchParams;
    const periods = query.getAll("period");
    if ([...query.keys()].some((key) => key !== "period") || periods.length > 1 ||
        (periods.length === 1 && !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(periods[0]))) {
      return reply({ error: "invalid_period_or_filter" }, 400);
    }
    try {
      // verifySession must verify the signature/session, expiry and current user state.
      // Return null for an unauthenticated user; never trust a user-supplied subject.
      const session = await verifySession(request);
      if (!session || typeof session.subject !== "string" || !session.subject) {
        return reply({ error: "unauthenticated" }, 401);
      }
      // Read a persisted, active membership for this verified subject.
      // The browser cannot choose a Modelvia customerId for this lookup.
      const binding = await findActiveBinding(session.subject);
      if (!binding || binding.active !== true || binding.clientId !== clientId ||
          typeof binding.customerId !== "string" || !/^[A-Za-z0-9_.-]+$/.test(binding.customerId)) {
        return reply({ error: "customer_access_unavailable" }, 403);
      }
      const url = new URL("/v1/client/customers/" +
        encodeURIComponent(binding.customerId) + "/usage-summary", "https://api.modelvia.dev");
      if (periods.length) url.searchParams.set("period", periods[0]);
      const response = await fetch(url, {
        headers: { Authorization: "Bearer " + clientKey },
        cache: "no-store", redirect: "error", signal: AbortSignal.timeout(10000),
      });
      if (!response.ok) return reply({ error: "usage_unavailable" }, 503);
      const data = await response.json();
      if (!data || typeof data.period !== "string" || !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(data.period) ||
          (periods.length && data.period !== periods[0]) ||
          !Number.isSafeInteger(data.requests) || data.requests < 0 ||
          !decimal(data.tokens?.input) || !decimal(data.tokens?.output) ||
          !nullableDecimal(data.money?.customerNetNanoAud) || !decimal(data.monthlyCapNanoAud) ||
          !nullableDecimal(data.remainingNanoAud) || !Number.isSafeInteger(data.updatedAt) || data.updatedAt < 0) {
        return reply({ error: "usage_unavailable" }, 503);
      }
      // Return only this documented projection, never a whole client analytics response.
      return reply({ period: data.period, requests: data.requests,
        tokens: { input: data.tokens.input, output: data.tokens.output },
        money: { customerNetNanoAud: data.money.customerNetNanoAud },
        monthlyCapNanoAud: data.monthlyCapNanoAud,
        remainingNanoAud: data.remainingNanoAud, updatedAt: data.updatedAt });
    } catch {
      return reply({ error: "usage_unavailable" }, 503);
    }
  };
}

Load clientId and clientKey from trusted server configuration. Register the returned handler on your application’s authenticated usage route. If your application lets a person switch organizations, the lookup must verify their membership in that selected organization before returning its binding.

Keep the application’s customer/company ID mapped to one exact Modelvia customerId. The project key ID identifies a credential, not the customer who owes an invoice. If the local mapping is missing, revoked or points to another client, show “Account connection needed” and stop the read; do not display an empty or zero-usage account. A gateway failure should show “Usage temporarily unavailable” while issued invoices remain accessible through their separate authorized read.

Display money, months and incomplete data correctly

  • period is a calendar month in Australia/Brisbane, UTC+10. Omit it for the current billing month. updatedAt is an epoch timestamp in milliseconds.
  • Amounts ending in NanoAud are decimal strings: one Australian dollar is 1,000,000,000 nanoAUD. Use integer arithmetic; do not parse them as floating-point dollars. Invoice amounts ending in Cents use a different unit.
  • A nullable charge or headroom value means its price is not yet known. Show “Pending calculation” and preserve null. Zero is a known value.
  • remainingNanoAud is customer-facing monthly headroom against net charges. It is not a prepaid balance or a guarantee that another request will pass all spending limits. Input tokens exclude cache-read and cache-write buckets.
  • Usage summaries sum the whole month at any volume; there is no size that refuses the read, and billing always reports usageAvailable: true. Recent request rows are a page, not the full month: pass requestsLimit (1–500) and, to page further, the previous response’s recentRequestsPage.nextCursor back as requestsCursor.
Format nanoAUD without floating-point loss
// Display rounding only. Keep the original string for calculations and comparisons.
export function formatNanoAud(value) {
  if (value === null) return "Pending calculation";
  if (typeof value !== "string" || !/^[0-9]+$/.test(value)) throw new Error("Invalid amount");
  const cents = (BigInt(value) + 5_000_000n) / 10_000_000n;
  return "A$" + (cents / 100n).toString() + "." + (cents % 100n).toString().padStart(2, "0");
}

Show loading, empty and unavailable states separately. A failed request must not replace a previous charge with zero. For internal, client-funded usage, customer charges remain zero and no customer resale invoice is created; business costs remain private.

Keep invoice documents isolated

The document route returns HTML. Recheck the signed-in customer’s binding and the invoice’s ownership before downloading it. Treat returned HTML as untrusted content: never insert it into your application with unsandboxed HTML rendering.

Prefer a download with Content-Disposition: attachment, a safe filename, Cache-Control: private, no-store and X-Content-Type-Options: nosniff. If you offer a separate document view, isolate it and apply a restrictive sandbox and Content Security Policy that blocks scripts, forms, top-level navigation and external resources. Modelvia’s hosted document view applies a restrictive document policy.

Next: configure customer payment methods and handle payment state safely.