BUSINESS INTEGRATION
Integrate your product
For a business that wants its own customers served through Modelvia — a reseller, a SaaS product, any website with users of its own. Your server signs a customer up, mints that customer a serving key and reads back what it owes, without anyone opening the Modelvia portal in the middle of your signup flow, once Modelvia has provisioned your business and agreed commercial setup.
Start with an agreed business account
Client onboarding is operator-assisted. Before these calls, Modelvia must establish your billing identity and payer, active client and billing service, allowed models and caps, accepted rate card, and a dedicated client integration key. Customer commercial terms are recorded separately by Modelvia; creating an account does not accept or activate them.
Signing up customers and projects is closed by default, even once you hold a working integration key: only the Modelvia operator can open it for your client. Confirm with Modelvia that provisioning is open before you build a signup flow against it; while closed, the creates below are refused with onboarding_operator_only (403).
- Verify the client key and expected business ID with a read-only request. Stop if they differ.
- Create a customer and project within your agreed ceilings.
- Confirm the customer’s accepted pricing or client-funded treatment with Modelvia before serving it.
- Mint one project key for a permitted environment and store its secret securely.
- Send a bounded request and inspect its receipt.
- Display the authenticated customer’s usage, then configure invoice collection when the commercial setup is ready.
Client API reference and downloadable OpenAPI lists the supported backend operations. The amounts and model ids below are fictional examples, not approved prices or limits; replace them with your agreed values.
Check the connection before changing anything
Keep the dedicated mgt_ client integration key in protected server configuration. Save the expected clientId from your verified onboarding record separately; never accept it from a browser query or use a returned ID to silently switch businesses.
// Run on your server or trusted CLI before creating a customer.
const key = process.env.MODELVIA_CLIENT_KEY;
const expectedClientId = process.env.MODELVIA_EXPECTED_CLIENT_ID;
if (!key || !expectedClientId) throw new Error("Set the protected client key and expected client ID");
const response = await fetch("https://api.modelvia.dev/v1/client/payment-settings", {
method: "GET",
headers: { Authorization: "Bearer " + key },
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error("Connection check failed: " + response.status);
const settings = await response.json();
if (!settings || settings.clientId !== expectedClientId) throw new Error("Client binding mismatch: stop setup");
console.log("Connected to the expected Modelvia business account");This GET returns the authenticated business’s clientId and current payment settings. A response with squareConfigured: false or paymentMode: disabled can still mean the key is connected; it does not authorize collection. A 401, unexpected client ID or unavailable response stops setup. This read does not send inference, create a customer or prove that rates, customer terms and billing service are active.
Read GET /v1/client/customers on your server to reconcile any existing IDs before a retry. An empty array is a valid first-run state; do not substitute another client or create a duplicate to make the check pass.
What you get, and where the boundary is
You hold one client integration key, in the form mgt_<id>_<secret>, sent as Authorization: Bearer mgt_… on every /v1/client/… route. Everything you can reach is scoped by that key’s own stored binding: the client it belongs to is read from the key record, never from your request. There is no field in any body below that lets you act for another client, and no response that reveals another client’s account data. A valid create can report an id as unavailable; use your own prefix.
Your customers hold project keys, in the form rbk_<id>_<secret>, each scoped to one project and one environment. A project key is never accepted on a /v1/client/… route, and your integration key is never accepted as a serving credential. They are different credentials with different authority.
Your customers never see supplier or wholesale figures. The customer billing snapshot carries that customer’s own price — customerGrossNanoAud, customerCreditsNanoAud and customerNetNanoAud — and never a provider cost, a rate/fee/markup breakdown or your own aggregate invoice. What you pay Modelvia and what your customer pays you stay separate records.
What stays an operator decision, and cannot be changed from this key: billing ownership (billingCompanyId), commercial terms and the platform fee, rate cards and their acceptance, and the ceilings on your own client account. Raising a cap above your own is a change to your client account, not something a child can do.
Signing a customer up, server to server
Your integration key creates the customer, project and serving credential. Modelvia must also establish the active billing service, accepted rate card and effective accepted customer policy before the first request. The management calls use your integration key; inference uses the separate project key. Refusals are {"error": "<code>"} at the matching HTTP status.
1. Create the customer
curl https://api.modelvia.dev/v1/client/customers \
-H "Authorization: Bearer $MODELVIA_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "exampleco-acme-co",
"name": "Acme Pty Ltd",
"active": true,
"monthlyCapNanoAud": "50000000000",
"maxConcurrent": 4,
"allowedModels": ["example-vendor/example-model"]
}'
{
"id": "exampleco-acme-co",
"name": "Acme Pty Ltd",
"active": true,
"monthlyCapNanoAud": "50000000000",
"maxConcurrent": 4,
"allowedModels": ["example-vendor/example-model"],
"clientId": "your-client-id",
"version": 1
}clientId and version are filled in by the server. You may send clientId only if it is your own; anything else is account_not_found (404).
2. Create a project under that customer
curl https://api.modelvia.dev/v1/client/customers/exampleco-acme-co/projects \
-H "Authorization: Bearer $MODELVIA_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "exampleco-acme-co-app",
"name": "Acme production app",
"active": true,
"monthlyCapNanoAud": "20000000000",
"maxConcurrent": 2,
"allowedModels": ["example-vendor/example-model"],
"environments": ["development", "production"],
"requestCapNanoAud": "1000000000"
}'
{
"id": "exampleco-acme-co-app",
"name": "Acme production app",
"active": true,
"monthlyCapNanoAud": "20000000000",
"maxConcurrent": 2,
"allowedModels": ["example-vendor/example-model"],
"environments": ["development", "production"],
"requestCapNanoAud": "1000000000",
"clientId": "your-client-id",
"customerId": "exampleco-acme-co",
"version": 1
}A child narrows its parent and can never widen it. The customer is checked against your client account, the project against its customer: monthlyCapNanoAud must be ≤ the parent’s (invalid_caps), maxConcurrent must be greater than 0 and ≤ the parent’s (invalid_concurrency), and allowedModels must be a subset of the parent’s (invalid_model_policy). This is the same rule serving admission applies, stated one step earlier — so you find out at signup rather than on your customer’s first refused call.
3. Mint a serving key for one environment
curl https://api.modelvia.dev/v1/client/customers/exampleco-acme-co/projects/exampleco-acme-co-app/keys \
-H "Authorization: Bearer $MODELVIA_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{"environment": "production", "label": "Acme production"}'
{
"key": "rbk_<id>_<secret>",
"record": {
"id": "<id>",
"companyId": "...",
"project": "exampleco-acme-co-app",
"environment": "production",
"label": "Acme production",
"createdAt": 1758000000000
}
}The key is returned exactly once. Only its SHA-256 hash is stored. Modelvia cannot recover, re-display or email a lost secret — neither can an operator. If a mint or rotation response is lost, pause issuance and list/reconcile the key records. Revoke an unusable credential or deliberately rotate a known active key after reconciliation; do not repeat the signup flow or mint blindly.
4. Your customer’s first call
A minted key does not activate billing or accept terms. For a client-paid customer, missing, draft or future-only customer terms return customer_terms_required (409) before reservation or provider dispatch. Confirm the effective accepted resale or client-funded policy with Modelvia, then use the project key in a protected backend or trusted CLI for Your first call. Generate an idempotency key once and save it with the exact request body before dispatch, as the first-call guide describes. Load that saved value as MODELVIA_REQUEST_KEY for this call and every retry.
# Load MODELVIA_REQUEST_KEY from this logical request's saved record.
# On retry, reuse that value and the exact body. Do not generate another ID.
curl --fail-with-body https://api.modelvia.dev/v1/chat/completions \
-D modelvia-response.headers \
-H "Authorization: Bearer $MODELVIA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ${MODELVIA_REQUEST_KEY:?Load the saved ID for this logical request}" \
-d '{
"model": "auto",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Summarise this ticket in one line."}]
}'5. Managing keys afterwards
GET /v1/client/customers/{customerId}/projects/{projectId}/keyslists that project’s keys without secrets.POST …/keys/{keyId}/rotateissues a replacement and revokes the old id in one step, returning the new secret once.POST …/keys/{keyId}/revokerevokes. Repeating it is harmless: the first timestamp wins.
These are the same helpers the portal uses, so there is one secret format, one limit of 100 active keys per client, and one audit record however a key was created. Every provisioning action is recorded against your key’s subject, integration:<clientKeyId>.
The whole sequence in Node
// Sign one customer up, end to end. The client integration key never leaves
// this process. Store the project secret in a protected backend/CLI environment,
// never a consumer browser. Serialize this flow per project/environment.
import { writeFile } from "node:fs/promises";
const BASE = "https://api.modelvia.dev";
const secretPath = process.env.MODELVIA_KEY_FILE;
if (!secretPath) throw new Error("Set MODELVIA_KEY_FILE to a protected new file path");
const KEY = process.env.MODELVIA_CLIENT_KEY;
if (!KEY) throw new Error("Inject MODELVIA_CLIENT_KEY before provisioning");
async function call(method, path, body) {
const response = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const payload = await response.json();
// Client routes refuse as {"error": "<code>"} at the HTTP status.
if (!response.ok) throw new Error(`${path} -> ${response.status} ${payload.error}`);
return payload;
}
const customerId = "exampleco-acme-co"; // always prefix with your own name
const projectId = "exampleco-acme-co-app";
const models = ["example-vendor/example-model"];
// 1. The customer. An identical retry returns the same record, not a second one.
await call("POST", "/v1/client/customers", {
id: customerId,
name: "Acme Pty Ltd",
active: true,
monthlyCapNanoAud: "50000000000",
maxConcurrent: 4,
allowedModels: models,
});
// 2. A project under it. Its caps must sit inside the customer's.
await call("POST", `/v1/client/customers/${customerId}/projects`, {
id: projectId,
name: "Acme production app",
active: true,
monthlyCapNanoAud: "20000000000",
maxConcurrent: 2,
allowedModels: models,
environments: ["development", "production"],
requestCapNanoAud: "1000000000",
});
// 3. A serving key. Minting is NOT idempotent, so look before you mint: a
// retry after a lost reply would otherwise issue a second, equally valid key.
const path = `/v1/client/customers/${customerId}/projects/${projectId}/keys`;
const { keys } = await call("GET", path);
const live = keys.find((k) => k.environment === "production" && !k.revokedAt);
if (live) throw new Error(`already provisioned: ${live.id}`);
const minted = await call("POST", path, {
environment: "production",
label: "Acme production",
});
// minted.key is returned exactly once. Only its SHA-256 hash is stored, so
// neither Modelvia nor an operator can show it again.
try {
await writeFile(secretPath, minted.key + "\n", { mode: 0o600, flag: "wx" });
} catch {
throw new Error(`Key ${minted.record.id} was minted but not stored. Reconcile/revoke it before retrying; do not rerun signup.`);
}
console.log(JSON.stringify({ keyId: minted.record.id, stored: true }));Run this once inside your serialized signup flow, after agreeing customer terms. If secure storage fails after minting, the credential may already exist: reconcile or revoke its record before another issuance. Do not rerun the whole script to recover a lost secret.
What you may and may not set
| Field | Customer | Project | Notes |
|---|---|---|---|
id, name, active | yes | yes | id must be unique across the whole platform — see Choosing ids. |
monthlyCapNanoAud, maxConcurrent, allowedModels | yes | yes | Bounded by the parent. A child narrows, never widens. |
environments, requestCapNanoAud | — | yes | requestCapNanoAud must be ≤ the project's own monthlyCapNanoAud. |
payer | only when your billingMode is mixed | — | Otherwise billingMode has already decided who pays, and a supplied value is invalid_payer. |
clientId, customerId | only your own | only your own | Redundant: both are taken from your key and the path. Anything else is account_not_found. |
billingCompanyId | never | never | Billing ownership is an operator decision: billing_binding_operator_only (403). |
version | never | never | The server owns it. A create is always written as version 0 and returned as version 1. |
billingMode, customerCheckout | never | — | Client-level settings, not customer ones. customerCheckout has its own portal route below. |
If your customers pay Modelvia directly — billingMode: "customer", or "mixed" with payer: "customer" — that customer needs a billing account bound to it, which only an operator can create. The create refuses with invalid_billing_binding; ask us to provision that customer instead. A business that pays for its own customers (billingMode: "client") uses its existing billing binding; its active service, accepted rate card and effective accepted customer policy are still required.
Choosing ids
Customer and project ids are one namespace across every client on the platform, so an id you pick may already belong to somebody else. When it does, the create is refused with account_id_unavailable (409) rather than with your own account_version_conflict, because your action is to choose another id, not to reconcile a record you never wrote and cannot read. The refusal says only that the id is unavailable; it tells you nothing about who holds it.
Prefix your ids with something of your own — exampleco-acme-co rather than acme-co. That makes a collision practically impossible and keeps your ids readable in your own usage and invoice exports. The platform does not prefix them for you: the id you send is the id you get back, and we would rather not hand you one you did not choose.
What a retry does
| Step | Retry with an identical body | Retry with a different body |
|---|---|---|
POST /v1/client/customers | Returns the existing record, 200. No second row and no second audit event. | account_version_conflict (409) |
POST /v1/client/customers/{id}/projects | The same: the stored project is returned unchanged. | account_version_conflict (409) |
POST …/projects/{id}/keys | Mints a second, independently valid key. There is no idempotency guard on a mint. | Mints a second key. |
POST …/keys/{keyId}/rotate | After success, repeating the original key URL returns 409 key_revoked; it does not recover the replacement secret. Reconcile an unknown result before another issuance. | — |
POST …/keys/{keyId}/revoke | Returns the same revokedAt. The first timestamp wins. | — |
Mint idempotency, plainly. There is no Idempotency-Key guard on the mint path, and a key mint is a credential issue rather than a value update, so it is not collapsed by body equality either. A retried mint yields a second, independently valid key. If your signup flow can retry after a lost reply, call GET …/keys first and mint only when that project and environment has no active key — as the Node example above does. Revoke any surplus key you find; it is the only way to be sure which credential your customer actually holds.
Refusal codes
| Code | HTTP | Means |
|---|---|---|
invalid_client_key | 401 | The bearer token is not a live client integration key. |
onboarding_operator_only | 403 | Customer/project provisioning is closed for your client. Only the Modelvia operator can open it. |
account_inactive | 403 | Your client, or an ancestor of what you named, is deactivated. |
billing_binding_operator_only | 403 | You sent billingCompanyId. |
environment_denied | 403 | The environment is not one of the project's. |
service_inactive | 403 | The billing account behind this project is not serving. |
account_not_found | 404 | The customer, project or key is not in your subtree — or does not exist at all. The two are deliberately indistinguishable. |
unknown_key | 404 | The key id is not a key anywhere. |
not_found | 404 | No such route or method. |
account_version_conflict | 409 | That id is yours and already exists with different content. |
account_id_unavailable | 409 | That id already belongs to another client. Pick a different one. |
key_limit_reached | 409 | 100 active keys for this client. |
invalid_fields | 400 | A field you may not set, or one this route does not accept. |
invalid_caps, invalid_concurrency, invalid_model_policy | 400 | The child exceeds its parent's ceiling, concurrency or model policy. |
invalid_payer | 400 | payer sent when your billingMode is not mixed. |
invalid_billing_binding | 400 | The resulting customer would need an operator-provisioned billing account. |
invalid_id, invalid_account_name, invalid_account_state, invalid_money, invalid_integer, invalid_environments | 400 | Ordinary field validation. |
Where your customers pay
The issued invoice names the seller who is paid. The accepted billing arrangement decides who is charged; a Square binding must match that invoice seller and does not change the payee. Two separate client settings control the customer experience: customerPayment.methods selects the allowed methods, and customerCheckout selects the surface where eligible customer payment actions appear.
| Value | Who may see eligible payment actions |
|---|---|
"client_app" | Your own app, your integration key, and a client_admin in the Modelvia portal. This is the default, and what a client row written before the setting existed means. |
"portal" | A customer_admin in the Modelvia portal, for its own customer only. Your app is no longer offered customer payment actions. |
"both" | Either surface. |
"off" | Neither. Customer payment actions are disabled; checkout refuses with customer_checkout_off (409). An authorized provider can still record money already received. |
You read and change it yourself, on the portal surface rather than with the integration key: a client_admin signed into the Modelvia portal calls GET /v1/member/settings and POST /v1/member/settings.
curl https://api.modelvia.dev/v1/member/settings \
-H "Authorization: Bearer <portal session token>"
{"clientId": "your-client-id", "customerCheckout": "client_app"}
curl -X POST https://api.modelvia.dev/v1/member/settings \
-H "Authorization: Bearer <portal session token>" \
-H "Content-Type: application/json" \
-d '{"clientId": "your-client-id", "customerCheckout": "portal"}'
{"clientId": "your-client-id", "customerCheckout": "portal"}customerCheckout is the only setting that route changes. Send the loaded clientId as an expected-business assertion: a changed sign-in returns settings_scope_changed (409) before any write. This assertion is optional for legacy API callers; the portal always supplies it. Other fields, including an empty body, are invalid_fields, and an unrecognised value is invalid_customer_checkout. Billing mode, billing ownership, caps and model policy on the client row stay operator-only. A customer_admin or customer_reader gets forbidden (403), and the row written is always the one the caller’s stored membership names — another client’s settings are not addressable from this route at all.
This moves the offer, never the money. Enabling a surface does not enable a payment method. With no Square binding for your client, setting portal still reports collectionEnabled: false, paymentMode: "disabled" and checkoutAvailable: false on every invoice, and a checkout attempt still refuses with customer_square_unconfigured (503). Connecting a Square merchant remains an operator act. Direct instructions can still be available without Square when the method, invoice and surface allow them. Saved payment links, orders, invoices, receipts and refunds are untouched by a change of surface.
Allowed methods and direct-payment receipts
customerPayment.methods accepts square, direct, both or disabled. An older account without this setting defaults to Square.direct and both require directInstructions: plain text, up to 2,000 characters. These instructions tell customers how to pay the invoice seller; they do not change the invoice amount or mark it paid.
A client_admin reads and updates this separate setting with GET /v1/member/payment-settings and POST /v1/member/payment-settings. Saving requires MFA and the loaded clientId and current version. The submitted client id asserts which business the draft belongs to; authentication determines the account and this field never selects another account. A changed sign-in returns payment_settings_scope_changed (409) before any write: return to the original business and load its current settings. A stale version returns account_version_conflict (409): read current settings, retain the draft and review before saving again. A trusted provider server can use the corresponding GET /v1/client/payment-settings and POST /v1/client/payment-settings with its own client key.
GET /v1/member/payment-settings
{
"clientId": "your-client-id",
"version": 3,
"customerPayment": {"methods": "square"},
"squareConfigured": false,
"paymentMode": "disabled"
}
POST /v1/member/payment-settings
{
"clientId": "your-client-id",
"version": 3,
"customerPayment": {
"methods": "direct",
"directInstructions": "Pay ExampleCo using the details on your invoice. Include the invoice reference."
}
}
// Success returns the saved settings with version 4.
// Use the returned clientId and version for the next edit.To record a full payment already received, a trusted provider server calls POST /v1/client/customers/{customerId}/invoices/{invoiceId}/external-payment. A provider administrator can instead use POST /v1/member/customers/{customerId}/invoices/{invoiceId}/external-payment with MFA. These routes record a statement receipt; they do not charge the customer or move money. Customer administrators and readers cannot record a payment.
POST /v1/client/customers/exampleco-acme-co/invoices/CI-EXAMPLE-1/external-payment
Authorization: Bearer $MODELVIA_CLIENT_KEY
Content-Type: application/json
{
"reference": "EXAMPLE-STATEMENT-001",
"amountCents": "12040",
"currency": "AUD",
"receivedAt": "2026-09-22T01:00:00.000Z"
}
// The persisted payment summary includes:
{
"paid": true,
"paymentState": "paid",
"checkoutAvailable": false,
"directPaymentAvailable": false,
"receipt": {
"source": "external",
"invoice": "CI-EXAMPLE-1",
"amountCents": "12040",
"currency": "AUD",
"settledAt": "2026-09-22T01:00:00.000Z"
}
}Record the exact full invoice amount, currency AUD, statement reference and canonical UTC receipt time. An identical retry returns the existing receipt; changing its amount, reference or time is refused. Any existing Square link, adjusted invoice, partial payment or nonpositive amount requires operator reconciliation. Provider-only externalPaymentRecordable is the action flag for this bookkeeping; it is independent of the customer checkout surface. Keep it out of downstream customer controls. The shared receipt omits the private statement reference and staff identity.
What your own billing panel must do
- Show a Pay button only when
checkoutAvailableis true for that invoice. Offer direct instructions only whendirectPaymentAvailableis true. Both flags are surface-aware and already account for the allowed method, invoice eligibility and payment state. UsesellerNameto name the payee. Missing flags mean false. - Always say where the customer pays when there is no button. Read
customerCheckoutand the allowed methods from the same billing snapshot. If the surface isportaland a method is enabled, direct the customer to the Modelvia portal to check the invoice’s available actions. Checking onlycollectionEnabledcan leave a customer with no button and no explanation. - If
customerCheckoutisoffor methods aredisabled, say payment actions are disabled and the customer should contact the provider. Do not substitute direct-payment instructions. A checkout against the off surface iscustomer_checkout_off(409), checked before any payer branch. Authorized provider bookkeeping can still record money already received when its own action flag permits it. - For these provider-issued resale invoices,
collectionEnabledreports Square capability, including sandbox; it does not mean live collection or permission to pay. CheckpaymentModefor sandbox or live.collectionEnabled: falsecan coexist withdirectPaymentAvailable: true; show the authorized direct instructions in that case. If neither method is available, explain the state and ask the customer to contact the provider. - Before checkout starts, persist a hold for this client, customer and invoice, blocking Square, direct payment and manual receipt entry. Keep it across dialog close and reload after an unknown result. A confirmed paid receipt resolves the hold; a known
readycheckout may resume the same Square link only. A freshnot_started, missing payment state or true availability flags cannot release it: the original POST may still be pending. Otherwise ask Modelvia support to reconcile before offering another payment path. A payment attempt is not a paid receipt; never ask customers to self-report payment.
What you can read back
GET /v1/client/customers lists your own customers. GET /v1/client/customers/{customerId}/billing is the per-customer snapshot your panel renders, with an optional ?period=YYYY-MM that defaults to the current period.
curl "https://api.modelvia.dev/v1/client/customers/exampleco-acme-co/billing?period=2026-08" \
-H "Authorization: Bearer $MODELVIA_CLIENT_KEY"
{
"clientId": "your-client-id",
"customerId": "exampleco-acme-co",
"customerName": "Acme Pty Ltd",
"clientName": "ExampleCo",
"period": "2026-08",
"currency": "AUD",
"payer": "client",
"customerCheckout": "client_app",
"customerPayment": {
"methods": "direct",
"directInstructions": "Pay ExampleCo using the details on your invoice. Include the invoice reference."
},
"customerGrossNanoAud": "120400000000",
"customerCreditsNanoAud": "0",
"customerNetNanoAud": "120400000000",
"unknownCustomerPriceRequests": 0,
"pendingRequests": 0,
"unreconciledRequests": 0,
"statementKind": "customer_invoice",
"collectionEnabled": false,
"paymentMode": "disabled",
"invoices": [
{"id": "CI-EXAMPLE-1", "kind": "Tax Invoice", "period": "2026-08", "currency": "AUD",
"gstInclusive": true, "totalCents": "12040", "gstCents": "1095",
"paid": false, "documentAvailable": true, "sellerName": "ExampleCo",
"paymentState": "not_started", "checkoutAvailable": false,
"directPaymentAvailable": true, "externalPaymentRecordable": true}
],
"requests": [],
"requestCoverage": {"total": 412, "returned": 100, "limit": 100, "complete": false}
}Provider-issued invoices report paid, documentAvailable, sellerName, paymentState, checkoutAvailable and directPaymentAvailable. A paid invoice carries a durable receipt whether payment came through Square or was recorded externally, even when Square is no longer configured. Its source is square or external. Treat missing action flags as false.externalPaymentRecordable is returned only to the provider’s authorized context; do not render it as a customer action.
Invoices come back inside that snapshot and individually at GET /v1/client/customers/{customerId}/invoices/{invoiceId}. Add /document to the same path for the rendered HTML invoice, which is served as a document rather than as JSON — that is the one to show or attach. Starting Square checkout, when the invoice’s checkoutAvailable flag allows it, is POST …/invoices/{invoiceId}/checkout.
GET /v1/client/analytics is your own business-wide usage view. GET /v1/client/customers/{customerId}/analytics is the customer-safe projection of one customer — the same shape without provider or rate-card disclosure, so you can render it straight into that customer’s own dashboard. Both accept period, customerId, projectId, environment and model as query filters, each at most once.
curl "https://api.modelvia.dev/v1/client/analytics?period=2026-09" \
-H "Authorization: Bearer $MODELVIA_CLIENT_KEY"
# One customer's own view, safe to show that customer:
curl "https://api.modelvia.dev/v1/client/customers/exampleco-acme-co/analytics?period=2026-09" \
-H "Authorization: Bearer $MODELVIA_CLIENT_KEY"Totals sum the whole month at any volume — there is no cohort size that refuses the read, and the billing snapshot always reports usageAvailable: true. Only the recent-requests list is paged: pass requestsLimit (1–500, default 100) and, for the next page, the opaque requestsCursor from the previous response’s recentRequestsPage.nextCursor. Stop paging once nextCursor is null.
Keep the key on your server
A client integration key can create customers, create projects and mint serving credentials. It belongs in your server’s environment — MODELVIA_CLIENT_KEY in the examples above — and nowhere else: not in a browser bundle, not in a mobile app, not in a configuration file you commit, and never in a URL, where it would land in logs and referrers.
Give each customer only its own project key, and mint a separate key per environment so revoking one does not stop the others. If a key is exposed, rotate it; a rotation issues the replacement and revokes the old id in one step. Modelvia sets no Access-Control-Allow-Origin and answers no preflight, so these routes cannot be called from a browser at all — a server-side call is not a recommendation here, it is the only way it works.