Register a new organization and its owner.
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| organizationName | string | Yes | 2-120 chars | Name of the organization |
| name | string | Yes | 1-120 chars | Full name of the owner |
| string | Yes | Valid email | Owner's email address | |
| password | string | Yes | 8-200 chars | Secure password |
Response: TokenPairDto
| Field | Type | Description |
|---|---|---|
| accessToken | string | JWT access token |
| refreshToken | string | JWT refresh token |
| expiresIn | number | Access token lifetime in seconds (default: 900) |
| tokenType | string | Token type (optional) |
Authenticate with email and password.
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| string | Yes | Valid email | User's email address | |
| password | string | Yes | Non-empty | User's password |
Response: TokenPairDto
Rotate an access/refresh token pair.
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| refreshToken | string | Yes | Non-empty | Valid refresh token |
Response: TokenPairDto
Revoke the current session.
Authentication: Bearer token required
Response: Success message
Get the current authenticated user.
Authentication: Bearer token required
Response: User profile information
Get the current session principal (alias for /auth/me).
Authentication: Bearer token required
Response: User profile information
Begin WebAuthn passkey registration.
Status: Not implemented - requires @simplewebauthn/server package
Verify a WebAuthn passkey assertion.
Status: Not implemented - requires @simplewebauthn/server package
List wallets for the organization.
Query Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
| offset | number | No | Rows to skip (default: 0); mutually exclusive with page |
| page | number | No | Page number, alternative to offset (default: 1) |
| limit | number | No | Items per page (default: 50, max: 200) |
Authentication: Bearer token required
Response: Paginated list of wallets
Create a wallet (generate a keypair or import an address).
Authentication: Requires roles: OWNER, ADMIN, FINANCE, DEVELOPER
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| label | string | No | 1-120 chars, whitespace normalized | Wallet label/name |
| walletType | enum | No | AGENT, OPERATIONAL, TREASURY | Type of wallet (default: AGENT) |
| network | enum | No | TESTNET, PUBLIC | Stellar network (default: TESTNET) |
| agentId | string | No | Valid UUID | Owning agent ID |
| stellarAddress | string | No | Stellar account address (G…), checksum validated |
Import existing address (if provided, no keypair is generated) |
Only Stellar accounts can be imported: a contract ID (C…) or a
multiplexed address (M…) cannot hold a balance or sign for the wallet and is
rejected. Unknown properties are rejected as well.
Response: WalletSecretDto (on generation) or wallet object (on import)
| Field | Type | Description |
|---|---|---|
| stellarAddress | string | Public Stellar address (G...) |
| secretKey | string | Generated secret key (S...) - shown ONCE, never stored |
Get a specific wallet.
Authentication: Bearer token required
Response: Wallet details
Fetch live on-chain balances for a wallet.
Authentication: Bearer token required
Response: Balance information for all assets
Update a wallet label or owning agent.
Authentication: Requires roles: OWNER, ADMIN, FINANCE, DEVELOPER
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| label | string | No | 1-120 chars, whitespace normalized | New wallet label |
| agentId | string | No | Valid UUID or null | Reassign or clear owning agent |
At least one field must be supplied: an empty body would be a no-op that still writes an audit entry and is rejected with a validation error.
Response: Updated wallet object
Freeze a wallet (block outgoing transactions).
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Response: Updated wallet with FROZEN status
Unfreeze a wallet.
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Response: Updated wallet with ACTIVE status
Archive (soft-delete) a wallet.
Authentication: Requires roles: OWNER, ADMIN
Response: Success message
List transactions for the organization.
Query Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
| offset | number | No | Rows to skip (default: 0); mutually exclusive with page |
| page | number | No | Page number, alternative to offset (default: 1) |
| limit | number | No | Items per page (default: 50, max: 200) |
Authentication: Bearer token required
Response: Paginated list of transactions
Create a transaction (runs the full governance pipeline).
Authentication: Requires roles: OWNER, ADMIN, FINANCE, DEVELOPER
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| walletId | string | Yes | Valid UUID | Sender wallet ID |
| agentId | string | No | Valid UUID | Initiating agent ID |
| budgetId | string | No | Valid UUID | Budget to charge against |
| asset | string | No | CODE or CODE:ISSUER, max 80 chars |
Asset code (default: XLM). A Stellar Anchor ticker (e.g. USDC) is resolved to its issuing account when it is configured in STELLAR_ANCHOR_ASSETS; an issued asset can always be requested explicitly as CODE:ISSUER |
| amount | string | Yes | Positive decimal, max 7 decimal places | Transaction amount |
| recipientAddress | string | Yes | Non-empty | Stellar destination address |
| memo | string | No | Max 28 chars | Transaction memo |
| purpose | string | No | Max 280 chars | Transaction purpose |
| metadata | object | No | Arbitrary JSON | Additional metadata |
Response: Transaction object with governance evaluation result
A transaction moves a single asset. A payload that carries a transfers array
is rejected with HTTP 422 and a message pointing at
POST /transactions/simulate, which evaluates a multi-asset transfer against
the spending policies without moving funds.
Policy violations: a transaction blocked by a policy is rejected with HTTP
422 and a POLICY_VIOLATION problem-details body that lists every violated
rule, the asset it applies to and the limit that was breached. A blocked
transaction is never persisted and never reaches the blockchain execution
queue.
Dry-run the governance pipeline without moving funds.
Authentication: Bearer token required
Request Body: Either the single-asset body of POST /transactions (without
metadata being required) or a multi-asset payload:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| walletId | string | Yes | Valid UUID | Sender wallet ID |
| agentId | string | No | Valid UUID | Initiating agent ID |
| budgetId | string | No | Valid UUID | Budget to charge against |
| asset | string | No | CODE or CODE:ISSUER |
Asset of a single-asset payload |
| amount | string | No | Positive decimal, max 7 decimal places | Amount of a single-asset payload |
| transfers | array | No | 1-10 legs | Multi-asset payload: one { asset, amount } leg per asset. Mutually exclusive with asset/amount |
| recipientAddress | string | Yes | Non-empty | Stellar destination address |
| memo | string | No | Max 28 chars | Transaction memo |
| purpose | string | No | Max 280 chars | Transaction purpose |
| metadata | object | No | Arbitrary JSON | Additional metadata |
Response: Simulation result with policy evaluation, the evaluated
transfers and the risk assessment of the riskiest leg
Get a specific transaction.
Authentication: Bearer token required
Response: Transaction details
Cancel a draft or pending transaction.
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Response: Updated transaction with CANCELLED status
List policies for the organization.
Query Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
| offset | number | No | Rows to skip (default: 0); mutually exclusive with page |
| page | number | No | Page number, alternative to offset (default: 1) |
| limit | number | No | Items per page (default: 50, max: 200) |
Authentication: Bearer token required
Response: Paginated list of policies
Create a policy.
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| name | string | Yes | 1-120 chars | Policy name |
| description | string | No | Max 500 chars | Policy description |
| type | enum | Yes | SPENDING_LIMIT, ASSET_RESTRICTION, APPROVAL_WORKFLOW, TIME_WINDOW, EMERGENCY_LOCK | Policy type |
| agentId | string | No | Valid UUID | Scope policy to specific agent |
| configuration | object | No | Valid policy configuration | Policy-specific settings (see below) |
| priority | number | No | 0-1000 | Evaluation priority (default: 100) |
| enabled | boolean | No | - | Policy active state (default: true) |
Configuration Schema:
| Field | Type | Description |
|---|---|---|
| maxAmount | number | Maximum single transaction amount |
| minAmount | number | Minimum single transaction amount |
| allowedAssets | string[] | List of allowed asset codes |
| blockedAssets | string[] | List of blocked asset codes |
| allowedRecipients | string[] | List of allowed recipient addresses |
| blockedRecipients | string[] | List of blocked recipient addresses |
| dailyLimit | number | Daily spending limit |
| weeklyLimit | number | Weekly spending limit |
| monthlyLimit | number | Monthly spending limit |
| timeWindow | object | Allowed time window (startHour, endHour, days) |
| requiresApproval | boolean | Whether approval is required |
| approvalThreshold | number | Amount threshold for approval |
| emergencyLock | boolean | Emergency lock flag (blocks all spending) |
| assetLimits | object | Per-asset limit overrides keyed by the upper-case asset code (e.g. { "USDC": { "maxAmount": 1000, "dailyLimit": 5000 } }). An issued asset (USDC:GA5Z…) matches the USDC entry |
| baseCurrency | string | Currency the amount limits are denominated in (e.g. USD). Transfer amounts — and the spend already accumulated for the asset — are converted into that currency before the limits are applied. A missing or stale price rejects the transaction with PRICE_UNAVAILABLE (HTTP 503) instead of skipping the limit |
Response: Created policy object
Simulate a transaction intent against active policies.
Authentication: Bearer token required
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| agentId | string | No | Valid UUID | Agent ID |
| walletId | string | No | Valid UUID | Wallet ID |
| asset | string | No | CODE or CODE:ISSUER |
Asset of a single-asset payload |
| amount | number | No | Positive | Amount of a single-asset payload |
| transfers | array | No | 1-10 legs | Multi-asset payload: one { asset, amount } leg per asset. Mutually exclusive with asset/amount |
| recipientAddress | string | Yes | Non-empty | Destination address |
| spentToday | number | No | Non-negative | Amount spent today |
| spentThisWeek | number | No | Non-negative | Amount spent this week |
| spentThisMonth | number | No | Non-negative | Amount spent this month |
Response: Policy evaluation result
Get a specific policy.
Authentication: Bearer token required
Response: Policy details
Update a policy.
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Request Body: Partial update of POST /policies body
Response: Updated policy object
Delete (soft-delete) a policy.
Authentication: Requires roles: OWNER, ADMIN
Response: Success message
List budgets for the organization.
Query Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
| offset | number | No | Rows to skip (default: 0); mutually exclusive with page |
| page | number | No | Page number, alternative to offset (default: 1) |
| limit | number | No | Items per page (default: 50, max: 200) |
Authentication: Bearer token required
Response: Paginated list of budgets
Create a budget.
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Request Body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| name | string | Yes | 1-120 chars | Budget name |
| period | enum | Yes | DAILY, WEEKLY, MONTHLY | Budget period |
| limit | number | Yes | Positive | Budget limit amount |
| asset | string | Yes | 1-24 chars | Asset code |
| walletId | string | No | Valid UUID | Associated wallet |
| agentId | string | No | Valid UUID | Associated agent |
| resetDate | date | No | Valid date | Custom reset date |
Response: Created budget object
Get a specific budget.
Authentication: Bearer token required
Response: Budget details with current usage
Update a budget.
Authentication: Requires roles: OWNER, ADMIN, FINANCE
Request Body: Partial update of POST /budgets body
Response: Updated budget object
Delete a budget.
Authentication: Requires roles: OWNER, ADMIN
Response: Success message
Every response includes the selected request ID in the x-request-id header and the standard response envelope. A caller-supplied ID is accepted only when it is 1-128 ASCII characters, starts with a letter or digit, and otherwise contains only letters, digits, ., _, :, or -. Invalid values are replaced with a server-generated UUID. The selected ID is propagated as typed event/job metadata and is isolated per concurrent request.
List audit records in reverse chronological order using a stable (createdAt, id) keyset.
Query Parameters: limit defaults to 20 and is bounded to 1-100; cursor is the opaque nextCursor from the previous response; optional actorId, action, resourceId, from, and to filters apply within the authenticated organization. from and to are inclusive ISO 8601 timestamps and from must not be later than to.
The response meta includes limit, hasNext, and nextCursor (null on the final page). New records inserted after a page is read do not shift subsequent pages.
Example: GET /audit?limit=20&actorId=user-123&action=wallet.created
Audit records older than AUDIT_ARCHIVE_RETENTION_DAYS (default 30) are moved
out of the actively queried audit_logs table into a compressed archive
(audit_log_archives) by a scheduled background job. The archive keeps the
full row content, a SHA-256 checksum and the hash-chain anchor of the batch,
so the audit history is preserved while the hot table — and every query
against it — stays small.
- The job runs hourly, processes at most
AUDIT_ARCHIVE_BATCH_SIZErecords (default 500) per organization and batch and at mostAUDIT_ARCHIVE_MAX_BATCHES(default 20) batches per run. - A batch is written and read back (checksum and record count verified) before the source rows are removed. A failure in between leaves the records in place and the job is retried with the shared queue backoff.
- The batch id is derived from the archived row ids, so a retried run reuses the existing archive instead of writing a duplicate.
- Because the oldest rows are archived first, the archived rows of an
organization are always a prefix of its hash chain.
GET /audit/integrity/verifyanchors the live rows on the archived prefix and keeps reporting a valid chain across the archive boundary. - The hard-delete retention of the cleanup job (
AUDIT_RETENTION_DAYS, default 90) stays above the archive cutoff, so records are always archived before they are deleted. The configuration is validated at startup.
Lists the archive batches of the organization, newest first. Only the batch metadata is returned — the compressed payload is fetched one batch at a time.
Query Parameters: limit defaults to 50 and is bounded to 1-100.
Example: GET /audit/archives?limit=10
Returns the audit records of one archived batch. The compressed payload is
decompressed and its checksum verified before it is returned, so a corrupted
archive answers with an error instead of silently mangled records. A batch of
another organization is reported as 404.
Example: GET /audit/archives/8f1c3d…
List the archive batches of the organization, newest first. Only the batch metadata is returned (batch id, cutoff, record count, checksum, chain anchor); the compressed payloads are not loaded.
Authentication: Requires roles: OWNER, ADMIN, AUDITOR
Query Parameters: limit defaults to 50 and is bounded to 1-100.
Read the audit records of one archived batch. The payload is decompressed and
its checksum verified before it is returned, so a corrupted archive surfaces as
an error instead of as silently mangled audit records. A batch of another
organization is reported as 404.
Authentication: Requires roles: OWNER, ADMIN, AUDITOR
Webhook creation and secret rotation responses disclose the signing secret once. Later list, get, update, delivery, and audit responses never include it. Store the secret securely and rotate it when compromised.
Every delivery includes x-astroid-signature, x-astroid-signature-version, x-astroid-timestamp, and x-astroid-event-id. x-astroid-delivery remains an alias for the event ID. The signature header is v1=<lowercase hex HMAC-SHA256> and the version header is v1.
The canonical signed bytes are UTF-8 v1.<timestamp>.<event-id>. followed by the exact raw HTTP body bytes. Each retry uses the same event ID and body, with a fresh timestamp and signature. Consumers should also reject timestamps outside their chosen replay window.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyAstroidWebhook({ secret, headers, rawBody }) {
const version = headers['x-astroid-signature-version'];
const timestamp = headers['x-astroid-timestamp'];
const eventId = headers['x-astroid-event-id'];
const received = headers['x-astroid-signature'];
if (version !== 'v1' || !/^\d{1,12}$/.test(timestamp) || !eventId) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const prefix = Buffer.from(`v1.${timestamp}.${eventId}.`, 'utf8');
const expected = createHmac('sha256', secret)
.update(Buffer.concat([prefix, rawBody]))
.digest();
const match = /^v1=([0-9a-f]{64})$/.exec(received);
if (!match) return false;
const actual = Buffer.from(match[1], 'hex');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}Outgoing deliveries are asynchronous and provide at-least-once, not
exactly-once, semantics. The event ID remains stable across retries; consumers
should use it as an idempotency key. By default, BullMQ makes five total
attempts with exponential backoff starting at 2 seconds and 20% jitter. Operators
can configure the attempt limit, base delay, HTTP timeout, and failed-job
retention with WEBHOOK_DELIVERY_MAX_ATTEMPTS, WEBHOOK_RETRY_DELAY_MS,
WEBHOOK_HTTP_TIMEOUT_MS, and WEBHOOK_FAILED_JOB_RETENTION_SECONDS.
Network errors, timeouts, HTTP 408, HTTP 429, and 5xx responses are retried.
Other 3xx/4xx responses are treated as permanent and stop immediately. Redirects
are not followed. Exhausted jobs are routed to the shared dead-letter queue and
recorded in the webhook delivery audit trail. Failed BullMQ jobs are retained
for 24 hours by default; the dead-letter queue retains failed records for seven
days by default. WEBHOOK_DEAD_LETTER_RETENTION_SECONDS configures that
retention. Operators can inspect and re-drive dead-letter jobs through the
existing queue administration tools.
POST /{API_PREFIX}/webhooks/receive requires both
x-astroid-timestamp and x-astroid-signature. The inbound signature is the
lowercase 64-character hex HMAC-SHA256 digest of UTF-8
<timestamp>. concatenated with the exact raw request body bytes. The request
timestamp must be within five minutes of server time. Verification uses a
constant-time comparison; missing, malformed, stale, or invalid signatures are
rejected before the ingress handler runs.
The route uses the dedicated WEBHOOK_INGRESS_SECRET, which must contain at
least 32 characters. It is separate from each outbound subscription's
whsec_... secret; when unset, inbound requests are rejected. Deployments
should provide it through a secret manager. Nest captures raw request bytes
before JSON parsing so whitespace and key order are preserved for verification.
The liveness and readiness probes are served outside the API prefix, so orchestrator and load-balancer probe paths do not change with the API version. Both are public, exempt from rate limiting, excluded from the audit trail, and return raw JSON (no success envelope).
Liveness probe. Returns 200 whenever the process is running. It performs no
dependency checks, so a database or cache outage never causes an otherwise
healthy process to be restarted.
Authentication: Public
Response (200):
{ "status": "up", "timestamp": "2026-09-28T10:00:00.000Z", "uptimeSeconds": 42 }Readiness probe. Probes the database (SELECT 1) and cache (Redis PING) in
parallel, each bounded by a 2 second timeout. Returns 200 when every
dependency is up and 503 when any is down.
Authentication: Public
Response (503 example):
{
"status": "down",
"timestamp": "2026-09-28T10:00:00.000Z",
"services": {
"database": {
"status": "down",
"latencyMs": 2001,
"timestamp": "2026-09-28T10:00:00.000Z",
"error": "Database health check timed out after 2000ms"
},
"cache": { "status": "up", "latencyMs": 1, "timestamp": "2026-09-28T10:00:00.000Z" }
}
}Richer diagnostics (including Stellar and migration status) remain available
under the API prefix at GET /{API_PREFIX}/health/readiness,
GET /{API_PREFIX}/health/liveness and GET /{API_PREFIX}/health/database.
Stellar network connectivity check. Probes the configured Horizon server and
the Soroban RPC node in parallel, each bounded by a 2 second timeout. Returns
200 when both endpoints are healthy and 503 when one of them is down or
degraded — an endpoint that answers but needs more than half of the budget is
reported as degraded.
Authentication: Public
Response (503 example):
{
"status": "down",
"timestamp": "2026-09-28T10:00:00.000Z",
"network": "testnet",
"horizon": { "status": "up", "latencyMs": 42, "url": "https://horizon-testnet.stellar.org" },
"sorobanRpc": {
"status": "down",
"latencyMs": 2001,
"url": "https://soroban-testnet.stellar.org",
"error": "The operation was aborted"
}
}Redis connectivity check. Issues a PING on the application's shared Redis
client, bounded by a 2 second timeout, so the check exercises the exact
connection the API depends on. Returns 200 when the ping is answered and
503 when Redis is unreachable or the probe times out.
Authentication: Public
Every list endpoint accepts the same query parameters.
| Field | Type | Default | Description |
|---|---|---|---|
| offset | number | 0 | Zero-based number of rows to skip. Mutually exclusive with page |
| page | number | 1 | 1-based page number, an alternative to offset |
| limit | number | 50 | Items per page, capped at 200 |
| sort | string | createdAt | Sort field (restricted to an allow-list per endpoint) |
| order | asc | desc |
desc | Sort direction |
Negative, non-integer or non-numeric values, a limit above 200, or supplying both offset and page return 400 Bad Request.
Paginated responses carry the total row count in the X-Total-Count header and in meta:
{
"success": true,
"data": [],
"meta": { "offset": 50, "page": 2, "limit": 50, "total": 120, "totalPages": 3, "hasNext": true, "hasPrev": true },
"requestId": "req_..."
}All endpoints return errors as RFC 9457 problem details with Content-Type: application/problem+json:
{
"type": "urn:astroid:problem:validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "Request validation failed",
"instance": "/api/v1/agents",
"code": "VALIDATION_ERROR",
"requestId": "req_018f...",
"details": [{ "path": "limit", "message": "Number must be less than or equal to 200" }]
}| Member | Description |
|---|---|
type |
URI identifying the problem type (urn:astroid:problem:<code>), or about:blank for plain HTTP errors without a dedicated code (e.g. 405) |
title |
Short summary of the problem type; the same for every occurrence |
status |
HTTP status code |
detail |
Explanation specific to this occurrence |
instance |
Request path that produced the error (query string omitted) |
code |
Machine-readable error code; clients should switch on this rather than on title or detail |
requestId |
Correlation id, matching the x-request-id header |
details |
Optional structured context, e.g. field-level validation errors |
Unhandled server errors always return 500 with code: "INTERNAL_ERROR" and a generic detail; internal information is only written to the server logs under the requestId. Stack traces are never included in a response.
The same envelope is used for requests rejected before they reach a controller — a malformed JSON body returns 400, and a body above the parser limit returns 413 — so clients only ever parse one error shape.
A transaction that is blocked by a spending policy returns 422 with
code: "POLICY_VIOLATION". The details.violations array lists every
violated rule with its policyId, policyName, code, message and the
asset the rule applies to:
{
"type": "urn:astroid:problem:policy-violation",
"title": "Policy Violation",
"status": 422,
"code": "POLICY_VIOLATION",
"detail": "Transaction blocked by spending limit policy: USDC cap: Projected daily spend 600 USDC exceeds limit 500 USDC",
"details": {
"violations": [
{
"policyId": "018f...",
"policyName": "USDC cap",
"code": "DAILY_LIMIT_EXCEEDED",
"message": "Projected daily spend 600 USDC exceeds limit 500 USDC",
"asset": "USDC"
}
]
}
}When a policy denominates its limits in a base currency and no usable price is
available, the transaction is rejected with 503 and
code: "PRICE_UNAVAILABLE" instead of being let through with an unknown
price.
Most endpoints require Bearer token authentication in the format:
Authorization: Bearer <access_token>
Tokens are obtained via /auth/login or /auth/register endpoints.
Unauthenticated endpoints (routes marked @Public(), such as /auth/login, /auth/register and /auth/refresh, and every route under /public/) share a per-IP sliding-window budget: 60 requests per 60 seconds by default, configurable with PUBLIC_RATE_LIMIT_MAX_REQUESTS and PUBLIC_RATE_LIMIT_WINDOW_SECONDS. Counters are stored in Redis, so the budget applies across all API instances.
Every rate-limited response includes:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Requests allowed per window |
X-RateLimit-Remaining |
Requests left in the current window |
X-RateLimit-Reset |
Unix time (seconds) at which the next request slot frees up |
When the budget is exhausted the API responds with 429 Too Many Requests, a Retry-After header (seconds) and error code RATE_LIMITED. These limits are in addition to the per-route auth throttling on the /auth endpoints.