curl --request GET \
--url https://api.nexspace365.com/api/api-usage/summary \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nexspace365.com/api/api-usage/summary"
headers = {"Authorization": "Bearer <token>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nexspace365.com/api/api-usage/summary', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nexspace365.com/api/api-usage/summary",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.nexspace365.com/api/api-usage/summary"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.nexspace365.com/api/api-usage/summary")
.header("Authorization", "Bearer <token>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.nexspace365.com/api/api-usage/summary")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_body{
"totalCalls": 123,
"uniqueKeys": 123,
"avgLatencyMs": 123,
"p95LatencyMs": 123,
"errorCount": 123,
"outcomeCount": 123,
"periodDays": 123,
"from": "2023-11-07T05:31:56Z",
"to": "2023-11-07T05:31:56Z",
"scope": {
"facilityId": 123,
"global": true
}
}{
"error": {
"message": "Invalid or revoked API key",
"code": "UNAUTHENTICATED",
"suggestion": "Send a valid `Authorization: Bearer <token>` (nex_live_/nex_pat_ key, JWT, or OAuth access token).",
"retryable": false
}
}{
"error": {
"message": "Insufficient API key scope",
"code": "INSUFFICIENT_SCOPE",
"suggestion": "Mint or rotate a key that includes the required scope, or grant a wildcard like `resource:*`.",
"retryable": false
}
}{
"error": {
"message": "Rate limit exceeded for this API key",
"code": "API_KEY_RATE_LIMITED",
"suggestion": "Wait until X-RateLimit-Reset before retrying, or batch operations.",
"retryable": true
}
}Usage Analytics
Monitor API key usage and request metrics (production traffic only — sandbox nex_test_ rows excluded)
curl --request GET \
--url https://api.nexspace365.com/api/api-usage/summary \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nexspace365.com/api/api-usage/summary"
headers = {"Authorization": "Bearer <token>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nexspace365.com/api/api-usage/summary', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nexspace365.com/api/api-usage/summary",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.nexspace365.com/api/api-usage/summary"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.nexspace365.com/api/api-usage/summary")
.header("Authorization", "Bearer <token>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.nexspace365.com/api/api-usage/summary")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_body{
"totalCalls": 123,
"uniqueKeys": 123,
"avgLatencyMs": 123,
"p95LatencyMs": 123,
"errorCount": 123,
"outcomeCount": 123,
"periodDays": 123,
"from": "2023-11-07T05:31:56Z",
"to": "2023-11-07T05:31:56Z",
"scope": {
"facilityId": 123,
"global": true
}
}{
"error": {
"message": "Invalid or revoked API key",
"code": "UNAUTHENTICATED",
"suggestion": "Send a valid `Authorization: Bearer <token>` (nex_live_/nex_pat_ key, JWT, or OAuth access token).",
"retryable": false
}
}{
"error": {
"message": "Insufficient API key scope",
"code": "INSUFFICIENT_SCOPE",
"suggestion": "Mint or rotate a key that includes the required scope, or grant a wildcard like `resource:*`.",
"retryable": false
}
}{
"error": {
"message": "Rate limit exceeded for this API key",
"code": "API_KEY_RATE_LIMITED",
"suggestion": "Wait until X-RateLimit-Reset before retrying, or batch operations.",
"retryable": true
}
}/api/api-usage/* endpoints aggregate non-sandbox usage only. Requests made with nex_test_ keys still log rows with is_sandbox = true, but those rows are not included in summary, timeseries, by-agent, by-tool, by-key, or outcome totals.
See Sandbox (test API keys) for how test keys isolate data elsewhere.
Operator UI
- Facility dashboard:
/admin/api-usage— requiresanalytics.view; passfacilityIdfor keys scoped to that facility. - Internal platform dashboard:
/admin/platform-usage— cross-facility totals (facilityIdomitted) for internal operators only; same JSON endpoints and CSV export (GET /api/api-usage/export).
Outcomes
An outcome is a marker the server stamps on a usage row when a call did something billable — it is the unit outcome-based pricing counts, so you need to know which of your calls produce one to reconcile a usage report against your own call log.GET /api/api-usage/outcomes groups usage rows by that marker and returns
{ data: [{ outcomeType, totalCount }], scope }, largest count first. The
outcomeCount field on /summary (and in the CSV export’s summary block) is
the number of rows in the same window carrying any marker — because a row
carries at most one marker, it equals the sum of totalCount across
/outcomes. The other breakdowns (/by-agent, /by-tool, /by-key) do not
report an outcome count.
The markers
Four are billing outcome types:| Marker | Emitted by | Verb scope |
|---|---|---|
shift_filled | fillShift — a staff member is assigned to an open shift | shifts:assign |
shift_filled | swapShifts — two staff are reassigned across two shifts | shifts:assign |
credential_verified | verifyCredential — a credential verification completes | credentials:verify |
payroll_run | runPayroll — a payroll run is committed | payroll:run |
lead_qualified | qualifyLead — lead scoring and enrichment complete | crm:* |
| Marker | Emitted by |
|---|---|
composio_execution | Every successful connected-app tool execution brokered through the org’s connected accounts |
composio_execution appears in the /outcomes breakdown and counts toward
outcomeCount, but it is not one of the four billing outcome types, so it is not
reported to the billing meter. Do not read it as a billable unit.
REST calls emit outcomes too
The verbs above are the MCP path. Four REST endpoints mark the same outcomes, so a usage report mixes both surfaces:| Endpoint | Marker | When |
|---|---|---|
POST /api/shifts/{id}/assign | shift_filled | Only when the assignment moves the shift to filled |
POST /api/credentials/{id}/verify-external | credential_verified | On a completed verification |
PUT /api/crm/leads/{id} | lead_qualified | Only on the transition into qualified — re-saving an already-qualified lead marks nothing |
POST /api/payroll/runs/{id}/approve | payroll_run | When the run is approved and submitted for processing — the approve/submit is the billable moment, not the earlier draft |
What does and does not produce one
- Only a successful mutating call. The marker is set after the write
commits — a call that is denied, errors, returns a rejection, or pauses at
pending_approvalproduces a usage row with no outcome. - Reads never produce one. No read-only verb or registry tool marks an outcome.
- At most one marker per request. The marker is a single value on the
request, so one HTTP request or one
tools/callcontributes at most one outcome row — later marks overwrite earlier ones rather than accumulating.
swapShifts touches two shifts, and its verb definition is annotated as counting
twice, but the runtime records a single shift_filled marker for the call
(one marker per request, quantity 1). Reconcile a swap as one shift_filled,
not two.Deduplication over MCP
For MCP tool calls the server derives a dedupe key ofmcp:<jsonrpcId>:<toolName> from the JSON-RPC request id and the tool name, and
uses it as the billing meter event’s identifier. Retrying the same logical
invocation — same JSON-RPC id, same tool — is not metered twice. Use a fresh
JSON-RPC id per genuinely new operation and reuse it when retrying, so this
works in your favor.
There is no equivalent for REST: a REST call with no MCP dedupe key falls
back to an identifier derived from its own usage-event row, so two REST calls
that both fill a shift are two metered outcomes even if you intended them as one
retry. Use an Idempotency-Key on REST mutations —
a replayed response never re-executes the write, so it never produces a second
outcome.
Note that the /outcomes and outcomeCount aggregates count usage rows, not
meter events. Deduplication happens on the billing-meter side, so a
double-counted retry can appear twice in the analytics breakdown while being
billed once.
Sandbox
nex_test_ sandbox traffic is excluded from outcome totals, exactly as it is
from every other aggregate on these endpoints: sandbox rows are written with
is_sandbox = true and every aggregate filters them out. They are also skipped
when reporting to the billing meter, so a sandbox fillShift is never billed.
See Sandbox (test API keys).
Response shapes
| Endpoint | Shape |
|---|---|
GET /api/api-usage/summary | totalCalls, uniqueKeys, avgLatencyMs, p95LatencyMs, errorCount, outcomeCount, plus periodDays, from, to, scope |
GET /api/api-usage/timeseries | { granularity, data, scope } |
GET /api/api-usage/by-agent | { data, scope } |
GET /api/api-usage/by-tool | { data, scope } |
GET /api/api-usage/by-key | { data, scope } |
GET /api/api-usage/outcomes | { data, scope } |
scope is { "facilityId": 3 } when you pass facilityId, and
{ "global": true } for a cross-facility internal query. granularity is
"day" (the default) or "hour". All endpoints accept days (default 30)
to set the window.Authorizations
JWT token authentication
Headers
Pin the request to a dated API version (e.g. 2026-05-10). Applied by app-level middleware to every /api route (server/routes/index.ts → apiVersionMiddleware), which echoes the resolved value back in the NexSpace-Version response header. Omit to get the latest version. Discover the catalog at GET /.well-known/api-versions.
^\d{4}-\d{2}-\d{2}$Query Parameters
When set, restrict aggregates to API keys with this api_keys.facility_id. Required for facility-scoped sessions that are not internal cross-facility operators. Omit for platform-wide totals (internal operators only).
Lookback period in days
Response
Usage summary
Echo of the days query parameter (default 30).
Start of the aggregation window (ISO 8601).
End of the aggregation window (ISO 8601).
Which slice of usage the aggregate covers. Facility-scoped sessions get { facilityId }; internal cross-facility operators querying without a facilityId get { global: true }.
Show child attributes
Show child attributes

