Metrics query
Exported from metrics.ts. Backs POST /v1/metrics/query — an API-key-authenticated endpoint for ad-hoc analytics over your own tenant's payment data (no id in the path; the tenant is implied by the calling key, same as every other /v1/* route), in the same spirit as a log/observability platform's query API (Datadog, Honeycomb, Axiom): you describe what you want — resource, aggregation, groupBy, filters, date range — and get back rows shaped that way. Not raw SQL, not GraphQL — every queryable field is a typed literal enum, checked both by the TypeScript compiler and by the server, so a query can only ever touch a field this package explicitly declares queryable.
MetricsQuerySchema
A discriminated union on resource — the fields you can filter/group/ aggregate by depend on which resource you're querying, and TypeScript narrows accordingly the moment you set resource:
ts
import { MetricsQuerySchema, type MetricsQueryRequest } from '@klappay/types'
const request: MetricsQueryRequest = {
resource: 'charges',
environment: 'live',
dateRange: { field: 'createdAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z' },
groupBy: [{ type: 'date_bucket', field: 'createdAt', granularity: 'day' }],
metrics: [
{ aggregation: 'sum', field: 'amount', alias: 'volume' },
{ aggregation: 'count' },
],
filters: [{ field: 'status', operator: 'eq', value: 'confirmed' }],
}
MetricsQuerySchema.parse(request)MetricsQueryRequest (z.input<>) is the pre-parse shape — groupBy, filters, and limit are all optional there (the schema defaults them to []/[]/100); use MetricsQuery (z.infer<>) for an already-parsed value where those are always present, same *Request/*Input split as CreateChargeSchema (see charges.md).
MetricsResourceSchema — resources and their queryable fields
resource: 'charges' | 'transactions' | 'distributions'. One row per underlying record: charges is one row per charge, transactions is one row per detected on-chain transfer (a charge paid in installments has more than one), distributions is one row per payout attempt for one (token, network) pair a charge settled across.
Every field name below is also exported as its own literal-union schema/type — import these directly for full autocomplete instead of typing the string by hand:
| Resource | Dimension fields (filter/groupBy) | Numeric fields (sum/avg/min/max) | Date fields |
|---|---|---|---|
charges | status, source, apiKeyId, currency, isOverpaid, externalRef, escrowReleaserAddress — ChargesQueryFieldSchema/ChargesQueryField | amount, amountReceived, feePercent, escrowFeePercent — ChargesMetricFieldSchema/ChargesMetricField | createdAt, confirmedAt, lastActivityAt, expiresAt, escrowReleasedAt — ChargesDateFieldSchema/ChargesDateField |
transactions | network, token, source, causedTransition — TransactionsQueryFieldSchema/TransactionsQueryField | amount — TransactionsMetricFieldSchema/TransactionsMetricField | detectedAt — TransactionsDateFieldSchema/TransactionsDateField |
distributions | status, network, token, distributorAddress — DistributionsQueryFieldSchema/DistributionsQueryField | attempts — DistributionsMetricFieldSchema/DistributionsMetricField | createdAt, processingStartedAt, completedAt — DistributionsDateFieldSchema/DistributionsDateField |
ts
import { ChargesQueryFieldSchema, type ChargesQueryField } from '@klappay/types'
ChargesQueryFieldSchema.options // ['status', 'source', 'apiKeyId', 'currency', 'isOverpaid', 'externalRef', 'escrowReleaserAddress']dateRange is required, and capped
Every query needs a dateRange — { field, from, to }, field one of that resource's date fields above. This isn't optional-defaulting-to- unbounded on purpose: it's what keeps a query from scanning your entire history. The span can't exceed MAX_METRICS_QUERY_DATE_RANGE_DAYS (366 days) — a superRefine on MetricsQuerySchema rejects anything wider, or from >= to.
groupBy — MetricsDateGranularitySchema
Up to METRICS_QUERY_MAX_GROUP_BY (3) entries, each either:
{ type: 'field', field: <a dimension field> }{ type: 'date_bucket', field: <a date field>, granularity }— at most one of these per query.granularityisMetricsDateGranularitySchema/MetricsDateGranularity:'day' | 'week' | 'month'.
metrics — MetricsAggregationSchema
At least one, up to METRICS_QUERY_MAX_METRICS (10): { aggregation, field?, alias? }. aggregation is MetricsAggregationSchema/MetricsAggregation: 'count' | 'sum' | 'avg' | 'min' | 'max'. field is required unless aggregation is count (rejected otherwise), and must be one of the resource's numeric fields. alias names the output column — if you omit it, the column is named ${aggregation} for count, or ${aggregation}_${field} otherwise (e.g. sum_amount). Every alias in one query must be unique, must not collide with a groupBy field name (or the reserved word bucket) in the same query, and (since it becomes a SQL column alias for date-bucketed queries) must match ^[a-zA-Z_][a-zA-Z0-9_]*$ — letters, digits, and underscores, not starting with a digit.
filters — MetricsFilterOperatorSchema
Up to METRICS_QUERY_MAX_FILTERS (20): { field, operator, value }. field is one of that resource's dimension fields (not its numeric fields — filtering amount > 100 isn't supported yet, only grouping/aggregating on it). operator is MetricsFilterOperatorSchema/ MetricsFilterOperator: 'eq' | 'neq' | 'in' | 'gt' | 'gte' | 'lt' | 'lte' — in expects an array value (max 50 entries), every other operator a single scalar.
orderBy and limit
orderBy (optional): { key, direction }, where key is an output column name from this same query (a groupBy field name, or a metric's alias/default name) — must match ^[a-zA-Z_][a-zA-Z0-9_]*$, same pattern as alias above, though every real output column name already satisfies it. limit (default 100, max METRICS_QUERY_MAX_ROW_LIMIT = 1000) caps rows returned — if more rows matched, the response's meta.truncated is true.
MetricsQueryResultSchema
ts
import { type MetricsQueryResult } from '@klappay/types'
const result: MetricsQueryResult = {
data: [
{ createdAt: '2026-07-01', volume: 4820.5, count: 12 },
{ createdAt: '2026-07-02', volume: 3110.0, count: 9 },
],
meta: { resource: 'charges', environment: 'live', rowCount: 2, truncated: false },
}data rows are shaped by your query — keys are whatever your groupBy fields and metric aliases resolved to, values are string | number | boolean | null.
That row type is exported on its own as MetricsQueryResultRowSchema / MetricsQueryResultRow (Record<string, string | number | boolean | null>). Reach for it when you're typing a function that formats or renders a single row without carrying the surrounding meta — the result schema is defined in terms of it, so the two can't drift. It is deliberately an open record rather than a generated per-resource type: the key set depends on the query you sent, not on the resource, so there's nothing narrower to promise at compile time.
Access control
Every valid API key gets unrestricted access to its own tenant's data — there's no member/role-based sub-scoping within a tenant, since Core has no membership or role concept at all (account/organization management lives in a separate Dashboard service, see docs/auth.md in the main repo). A key can only ever see data attributed to its own tenantId, never another tenant's.
See also
charges.md— the*Request/*Inputsplit this schema also follows, and the underlyingChargefields this query engine reads.
