Issue: #752
Version: v1.0.1
Last Updated: 2026-07-24
- Overview
- OpenAPI Specification
- Authentication
- Rate Limiting
- Backend REST & WebSocket API
- Indexer REST API
- Soroban Contract API
- TypeScript SDK
- Error Handling
- API Versioning
- Changelog
Scavngr exposes two API surfaces:
| Surface | Base URL | Purpose |
|---|---|---|
| Indexer REST API | http://localhost:3001 |
Query indexed chain events, replay, metrics |
| Soroban Contract | Stellar RPC | On-chain state mutations and reads |
| TypeScript SDK | @scavngr/sdk |
Typed wrapper for both surfaces |
All REST responses are JSON. All times are ISO 8601 strings unless stated otherwise.
This file is the single source of truth for the Indexer REST API. External consumers should integrate against the Indexer REST API section and the OpenAPI Specification above it; both are derived from the route handlers in
indexer/src/api/server.tsand the migrations inindexer/src/db/migrations/. Other documents indocs/may reference the indexer, but where they disagree with this file, this file is correct. Changes to indexer routes must update this file in the same PR.
The Indexer REST API follows the OpenAPI 3.1 schema below.
openapi: "3.1.0"
info:
title: Scavngr Indexer API
version: "1.0.0"
description: REST API for querying indexed Scavngr blockchain events
contact:
name: Scavngr Developers
url: https://github.com/Xoulomon/Scavenger
servers:
- url: http://localhost:3001
description: Local development
- url: https://indexer.scavngr.io
description: Production
tags:
- name: health
description: Service health and readiness
- name: events
description: Blockchain event queries
- name: metrics
description: Indexer operational metrics
- name: replay
description: Historical event replay
paths:
/health:
get:
tags: [health]
summary: Health check
responses:
"200":
description: Service is healthy
content:
application/json:
schema:
$ref: "#/components/schemas/HealthResponse"
/metrics:
get:
tags: [metrics]
summary: Indexer operational metrics
responses:
"200":
description: Current metrics snapshot
content:
application/json:
schema:
$ref: "#/components/schemas/MetricsResponse"
/events:
get:
tags: [events]
summary: Query indexed events
parameters:
- name: type
in: query
schema:
type: string
example: recycled
- name: from
in: query
schema:
type: integer
description: Start ledger sequence
- name: to
in: query
schema:
type: integer
description: End ledger sequence
- name: contractId
in: query
schema:
type: string
description: Filter by emitting contract ID
- name: txHash
in: query
schema:
type: string
description: Filter by transaction hash
- name: limit
in: query
schema:
type: integer
default: 100
maximum: 1000
description: Values above the maximum are clamped, not rejected
- name: offset
in: query
schema:
type: integer
default: 0
responses:
"200":
description: Event list
content:
application/json:
schema:
$ref: "#/components/schemas/EventListResponse"
"500":
$ref: "#/components/responses/InternalError"
/events/stream:
get:
tags: [events]
summary: Server-Sent Events stream of new contract events
responses:
"200":
description: SSE stream
content:
text/event-stream:
schema:
type: string
/replay:
post:
tags: [replay]
summary: Trigger historical event replay
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ReplayRequest"
responses:
"202":
description: Replay accepted and started
content:
application/json:
schema:
$ref: "#/components/schemas/ReplayAcceptedResponse"
"400":
$ref: "#/components/responses/BadRequest"
"405":
$ref: "#/components/responses/MethodNotAllowed"
/replay/status/{id}:
get:
tags: [replay]
summary: Get replay subsystem status
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Replay subsystem status
content:
application/json:
schema:
$ref: "#/components/schemas/ReplayStatusResponse"
/alerts:
get:
tags: [metrics]
summary: List recent alert history
parameters:
- name: limit
in: query
schema:
type: integer
default: 50
maximum: 200
description: Values above the maximum are clamped, not rejected
responses:
"200":
description: Alert list
content:
application/json:
schema:
$ref: "#/components/schemas/AlertListResponse"
"500":
$ref: "#/components/responses/InternalError"
components:
schemas:
HealthResponse:
type: object
required: [status, timestamp]
properties:
status:
type: string
enum: [ok, degraded]
timestamp:
type: string
format: date-time
MetricsResponse:
type: object
properties:
eventsProcessed:
type: integer
eventsFailed:
type: integer
lastEventTimestamp:
type: string
format: date-time
nullable: true
syncLag:
type: integer
description: Ledger lag behind chain tip
reorgsDetected:
type: integer
alertsFired:
type: integer
startTime:
type: string
format: date-time
eventsByType:
type: object
additionalProperties:
type: integer
IndexerEvent:
type: object
description: >-
A row from the raw_events table. Field names are the database column
names and are returned verbatim in snake_case.
properties:
id:
type: string
description: BIGSERIAL primary key, serialised as a string
ledger_sequence:
type: string
description: Ledger the event was emitted in
ledger_close_time:
type: string
format: date-time
transaction_hash:
type: string
contract_id:
type: string
event_type:
type: string
example: recycled
topic:
type: array
description: Raw event topics
items:
type: string
value:
type: object
description: Decoded event payload (JSONB)
additionalProperties: true
created_at:
type: string
format: date-time
description: When the indexer persisted the row
EventListResponse:
type: object
properties:
events:
type: array
items:
$ref: "#/components/schemas/IndexerEvent"
total:
type: integer
description: Total rows matching the filters, ignoring limit/offset
offset:
type: integer
limit:
type: integer
ReplayRequest:
type: object
required: [fromLedger]
properties:
fromLedger:
type: integer
description: Start ledger, inclusive. Required.
toLedger:
type: integer
description: End ledger, inclusive. Omit to replay to the newest indexed ledger.
eventTypes:
type: array
description: Optional event-type allow-list. Omit to replay all types.
items:
type: string
ReplayAcceptedResponse:
type: object
properties:
replayId:
type: string
example: replay_1750970400000_a1b2c3
eventCount:
type: integer
description: Number of stored events selected for replay
status:
type: string
enum: [started]
ReplayStatusResponse:
type: object
description: >-
Reports whether the replay subsystem is available. Per-job progress
tracking is not implemented; the path id is accepted but ignored.
properties:
status:
type: string
enum: [available]
message:
type: string
Alert:
type: object
description: A row from the alert_history table.
properties:
id:
type: string
alert_name:
type: string
severity:
type: string
enum: [info, warning, critical]
message:
type: string
metadata:
type: object
nullable: true
additionalProperties: true
created_at:
type: string
format: date-time
AlertListResponse:
type: object
properties:
alerts:
type: array
items:
$ref: "#/components/schemas/Alert"
ErrorResponse:
type: object
required: [error]
properties:
error:
type: string
responses:
BadRequest:
description: Invalid request body or parameters
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
NotFound:
description: Unknown path
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
MethodNotAllowed:
description: HTTP method not supported for this path
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
InternalError:
description: Unhandled server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"All state-changing contract calls require a signed Stellar transaction from the caller's account.
Freighter (browser):
import { signWithFreighter } from '@scavngr/sdk'
const client = new ScavengerClient({ ...options })
client.setSigningStrategy(signWithFreighter())
await client.registerParticipant({ ... })Secret Key (server-side / testing):
import { signWithSecretKey } from '@scavngr/sdk'
client.setSigningStrategy(signWithSecretKey(process.env.SECRET_KEY!))The Indexer API is unauthenticated by default. For production deployments behind a reverse proxy, apply API-key middleware at the gateway level (e.g., nginx auth_request).
Deployment note for external consumers. The indexer ships a Redis-backed
RateLimiter(indexer/src/rate-limit/rate-limiter.ts) with per-tier, per-endpoint windows plus whitelist/blacklist support, but it is not currently attached to the HTTP server — a stocknpm startserves requests unthrottled. Public deployments are expected to enforce limits at the ingress/gateway layer, or to wire the limiter in before exposing the service. Do not assume the API will throttle you; conversely, do not assume it will not, since the operator may have added limits upstream.
The limiter's own contract, for operators wiring it in and for clients that need to handle throttling:
| Concept | Behaviour |
|---|---|
| Identifier | Caller-supplied string (typically API key or client IP) |
| Tier | Named group of per-endpoint { windowMs, maxRequests } configs |
| Unknown tier or endpoint | Request allowed, remaining reported as -1 |
| Whitelisted identifier | Always allowed, remaining reported as -1 |
| Blacklisted identifier | Always denied |
| Algorithm | Sliding window over a Redis sorted set, keyed ratelimit:{id}:{endpoint} |
Recommended starting limits, matching the values in .env.example
(RATE_LIMIT_REQUESTS_PER_MINUTE, RATE_LIMIT_BURST_SIZE):
| Tier | Limit | Window |
|---|---|---|
| Default | 60 req | 1 minute |
| Burst | 10 req | 1 second |
When the limiter is attached, throttled requests return 429 Too Many Requests:
{ "error": "Rate limit exceeded. Retry after 42 seconds." }and getRateLimitHeaders() supplies:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1750970400
X-RateLimit-Reset is a Unix timestamp in seconds. A -1 in
X-RateLimit-Remaining means "not counted" (whitelisted, or no config for this
tier/endpoint), not "exhausted".
Client guidance: treat 429 as retryable with backoff, and page through /events
using limit/offset rather than issuing many small concurrent requests.
Stellar's public RPC endpoints enforce their own rate limits. For production, run a dedicated RPC node or use a paid provider.
| Network | Public RPC | Rate Limit |
|---|---|---|
| Testnet | https://soroban-testnet.stellar.org |
100 req/s |
| Mainnet | https://mainnet.sorobanrpc.com |
Contact provider |
Endpoints served by the backend (backend/src/api). Route registrations in backend/src/main.rs are checked against this section by scripts/check-api-reference.sh.
- Local Development:
http://localhost:8080 - Production:
https://api.scavenger.io
| Header | Type | Description | Required For |
|---|---|---|---|
Authorization |
Bearer <JWT> |
Bearer token verifying caller identity and role | Protected endpoints (Free / Premium / Admin tiers) |
X-CSRF-Token |
string |
CSRF protection token | Mutating HTTP methods (POST, PUT, DELETE) from web browsers |
Idempotency-Key |
UUIDv4 |
Deduplication key ensuring safe retries on mutating endpoints | Mutating operations (POST, PUT) |
X-Session-ID |
string |
Client session identifier for audit tracing | Optional |
X-Request-ID |
UUIDv4 |
Unique request tracking ID (auto-generated if omitted) | Injected in all responses |
IdempotencyMiddleware is mounted once, app-wide, in main.rs — it inspects every POST / PUT / PATCH / DELETE request across every route (including signing_api.rs and contracts.rs), so SDK consumers do not need to check per-endpoint whether a given write is covered.
- Send an
Idempotency-Keyheader (max 128 bytes, typically a UUIDv4) on any mutating request you may need to safely retry — e.g. after a timeout or connection drop. - The key is scoped to the exact request; reusing it replays the original response rather than re-running the handler.
- Response header
X-Idempotency-Status: createdmarks the first (handler-executing) call for a key;X-Idempotency-Status: replayedmarks a duplicate that returned the cached response. - Only 2xx/4xx responses are cached — a 5xx is never cached, so retrying after a server error re-runs the handler.
- Cached entries expire after 24 hours; after that, the same key is treated as new.
- Omitting the header is allowed (the request just isn't deduplicated) except where a specific endpoint's docs say otherwise — see the per-file notes in
signing_api.rsfor operations (signing, multisig, revocation) where a retried request without this header can double-execute a sensitive action.
Rate limiting is enforced at the middleware layer (backend/src/middleware/rate_limit.rs) with tier-based quotas and window headers:
| Tier | Rate Limits | Applied Route Categories |
|---|---|---|
| Anonymous | 30 requests / min · 200 requests / hour | Public read endpoints (/api/v1/contracts/*, /api/v1/search/*) |
| Free | 60 requests / min · 1,000 requests / hour | Standard authenticated users (/api/v1/audit/*, /ws, general API) |
| Premium | 300 requests / min · 10,000 requests / hour | High-throughput integrated partners |
| Admin | 1,200 requests / min · 50,000 requests / hour | Internal administrative operations |
X-RateLimit-Limit-Minute: Quota per minuteX-RateLimit-Limit-Hour: Quota per hourX-RateLimit-Remaining-Minute: Remaining tokens in current minute windowX-RateLimit-Remaining-Hour: Remaining tokens in current hour windowRetry-After: (On HTTP 429) Seconds to wait before retrying
Returns the operational status of the backend and its connected core subsystems.
- Auth Required: None
- Rate Limit Tier: Anonymous (30 RPM)
- Response
200 OK:
{
"status": "healthy",
"timestamp": "2026-08-25T14:30:00Z",
"version": "1.0.0",
"services": ["contracts", "websocket", "export", "audit", "cache"]
}List waste records with filtering and pagination.
- Auth Required: None (Public)
- Rate Limit Tier: Anonymous (30 RPM)
- Query Parameters:
page(integer, optional, default1): Page numberlimit(integer, optional, default20, max100): Items per pagestatus(string, optional): Filter by status (active,confirmed,recycled,frozen)waste_type(string, optional): Filter by waste material (Plastic,Metal,Glass,Organic,Paper,Electronic)participant_id(string, optional): Filter by submitter or holder addresssort_by(string, optional): Sort field (created_at,weight)sort_order(string, optional): Sort order (asc,desc)
- Response
200 OK:
{
"data": [
{
"id": "1001",
"waste_type": "Plastic",
"weight": 50000,
"status": "active",
"location": "40.7128,-74.0060",
"participant_id": "GBZX...3X4",
"created_at": "2026-08-25T10:00:00Z",
"updated_at": "2026-08-25T10:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"total_pages": 1
}
}Retrieve details for a single waste submission by its identifier.
- Auth Required: None
- Rate Limit Tier: Anonymous (30 RPM)
- Path Parameters:
id(string or u128): Unique waste ID - Response
200 OK: SingleWasteResponseobject - Response
404 Not Found:{"error": "Waste item not found"}
List registered network participants.
- Auth Required: None
- Rate Limit Tier: Anonymous (30 RPM)
- Query Parameters:
page(integer, default1)limit(integer, default20)role(string, optional): Filter by role (Recycler,Collector,Manufacturer)search(string, optional): Substring search on name or address
- Response
200 OK: Paginated list ofParticipantResponse
Retrieve a specific participant's profile and reputation stats.
- Auth Required: None
- Rate Limit Tier: Anonymous (30 RPM)
- Path Parameters:
id(string): Participant Stellar address or ID - Response
200 OK: SingleParticipantResponse
Get aggregated smart contract recycling statistics and carbon impact metrics.
- Auth Required: None
- Rate Limit Tier: Anonymous (30 RPM)
- Response
200 OK:
{
"total_wastes": 1420,
"total_participants": 285,
"total_weight": 854000000,
"recycled_weight": 620000000,
"pending_approvals": 12,
"active_participants": 194
}Get on-chain contract address, network configuration, and version metadata.
- Auth Required: None
- Rate Limit Tier: Anonymous (30 RPM)
- Response
200 OK:
{
"contract_id": "CA3D...992",
"network": "STANDALONE",
"version": "2.0.0",
"last_updated": "2026-08-25T12:00:00Z",
"total_transactions": 5420
}Evict cached contract data for a specific waste item.
- Auth Required: Bearer Token (Admin / Verifier)
- Rate Limit Tier: Free (60 RPM)
- Response
200 OK:{"success": true, "message": "Waste cache invalidated"}
Flush all on-chain cached queries.
- Auth Required: Bearer Token (Admin)
- Rate Limit Tier: Admin (1200 RPM)
- Response
200 OK:{"success": true, "message": "All contract caches invalidated"}
Retrieve cache performance metrics (hits, misses, evictions, latency).
- Auth Required: Bearer Token (Admin)
- Rate Limit Tier: Free (60 RPM)
- Response
200 OK: Metrics JSON object
Establishes a bi-directional WebSocket connection for streaming contract events, status transitions, and notifications.
- Auth Required: Initiated via WebSocket frame (
Authenticate { token }) - Rate Limit Tier: Free (60 RPM connection attempts)
- Protocol Flow:
- Client connects via
ws://localhost:8080/ws - Client sends auth message:
{"type": "Authenticate", "payload": {"token": "JWT_TOKEN"}} - Server acknowledges:
{"type": "AuthSuccess"} - Client subscribes to topics:
{"type": "Subscribe", "payload": {"channel": "wastes:GBZX...3X4"}} - Server streams events:
{"type": "Event", "payload": {"channel": "wastes:GBZX...3X4", "data": {...}}}
- Client connects via
Check WebSocket manager health and active connection count.
- Auth Required: None
- Rate Limit Tier: Free (60 RPM)
- Response
200 OK:
{
"status": "healthy",
"active_connections": 42,
"shutting_down": false
}Request an asynchronous data export job in CSV, PDF, or JSON format.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
- Request Body:
{
"format": "csv",
"data_type": "waste_records",
"start_date": "2026-01-01T00:00:00Z",
"end_date": "2026-08-01T00:00:00Z",
"anonymize": false,
"filters": { "status": "recycled" }
}- Response
200 OK:
{
"id": "exp-9921",
"format": "csv",
"data_type": "waste_records",
"status": "processing",
"file_size": null,
"created_at": "2026-08-25T14:00:00Z",
"expires_at": "2026-08-26T14:00:00Z"
}List export history for the authenticated user.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
- Query Parameters:
page(integer),limit(integer),status(string)
Download the generated export file.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
- Response
200 OK: Binary file stream (text/csv,application/pdf, orapplication/json)
Trigger sending the completed export file to the user's verified email.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
- Response
200 OK:{"success": true, "message": "Export sent via email"}
Schedule a recurring export report.
- Auth Required: Bearer Token (Admin / Manufacturer)
- Rate Limit Tier: Free (60 RPM)
- Request Body:
{
"format": "pdf",
"data_type": "compliance_summary",
"schedule": "0 0 1 * *",
"recipients": ["compliance@org.com"],
"anonymize": true,
"enabled": true
}List all scheduled recurring export jobs.
Cancel and delete a scheduled export job.
Query security and compliance audit logs.
- Auth Required: Bearer Token (Admin / Auditor)
- Rate Limit Tier: Free (60 RPM)
- Query Parameters:
page,limit(integers)event_type(string):contract,admin,auth,data_accessuser_id(string): Filter by actoraction(string): Filter by action nameresource_type(string): Filter by affected resourcestart_date,end_date(ISO 8601 strings)severity(string):info,warning,critical
- Response
200 OK: Paginated list ofAuditEntry
Retrieve a specific audit entry by ID.
Get aggregated audit statistics (actions per category, anomaly counts).
Generate a formal audit report over a given time window.
Stream raw audit records as CSV or JSON for external SIEM ingestion.
Create and list automated alert trigger rules for suspicious audit event patterns.
View and configure audit data retention policies (max_age_days, max_entries, archive_enabled).
Manually execute log pruning according to configured retention rules.
Start the verification onboarding process for a participant.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
- Request Body:
{"participant_id": "GBZX...3X4"} - Response
200 OK:{"success": true, "data": {"verification_id": "v-1001", "status": "pending"}}
Query the verification status, submitted documents, and checklist progression.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
Submit a KYC, recycling license, or facility certificate document for review.
- Request Body:
{
"participant_id": "GBZX...3X4",
"doc_type": "recycling_license",
"url": "https://storage.scavenger.io/docs/lic-99.pdf"
}Verifier marks a submitted document as verified or rejected.
Submit answers for compliance inspection checklist questions.
List all participant verifications pending human reviewer action.
- Auth Required: Bearer Token (Verifier role)
Grant or deny final verified status to a participant.
Reset a failed verification to allow resubmission of required documents.
List standard regulatory compliance checklists or register new checklists.
Run an automated compliance evaluation against active transactions and participant certifications.
- Response
200 OK:
{
"success": true,
"data": {
"compliance_score": 98.5,
"total_checks": 40,
"passed": 39,
"failed": 1
}
}List active compliance breach alerts and configure compliance alert threshold rules.
Retrieve the non-repudiable audit trail for compliance verification milestones.
Generate a structured compliance certification report for regulatory bodies.
Cryptographically sign a transaction payload using an authenticated hardware or custodial key.
- Auth Required: Bearer Token
- Rate Limit Tier: Free (60 RPM)
- Request Body:
{
"transaction_id": "tx-8819",
"signer_id": "GADM...112",
"data": "BASE64_PAYLOAD"
}Verify an ED25519 or Secp256k1 signature over a provided transaction payload.
Create a multi-signature transaction proposal requiring
Submit an additional signature to an open multi-signature proposal.
Revoke an active signature or compromised key.
List cryptographic signing audit events and revocation logs.
Returns cryptographic schemas and signing protocol documentation.
Unified search across wastes, participants, batches, and transactions.
- Auth Required: None (Public)
- Rate Limit Tier: Anonymous (30 RPM)
- Query Parameters:
q(string, required): Search query termfrom(integer, default0): Pagination offsetsize(integer, default20): Page sizefilters(array of strings, optional): Facet filters (type:Plastic,status:recycled)
- Response
200 OK:
{
"total": 45,
"hits": [...],
"took_ms": 12,
"facets": {
"waste_type": { "Plastic": 20, "Metal": 15, "Glass": 10 }
}
}Autocomplete suggestions for search queries.
Returns search engine configuration (indexed fields, boost weights, supported facets).
Create and list data archival policies defining storage tier transitions (Hot -> Warm -> Cold / S3 Glacier) and TTL intervals.
Retrieve, modify, or delete a specific archival policy.
Query archived data bundles or manually trigger immediate archival for a block of historical records.
Initiate unarchival to restore cold records back into primary queryable storage.
Returns storage volume metrics across tiers (hot database size, compressed archive volume, cost savings).
List and inspect the execution status of background compression and migration jobs.
Note
As part of cleanup issue #906, legacy analytics routes (/analytics/participant/{id}, /analytics/global, /analytics/metrics/{name}) were retired. All aggregated analytics and performance statistics are now served via the canonical GET /api/v1/contracts/stats endpoint.
This section is the canonical reference for the indexer's public HTTP surface.
It is generated by reading the route definitions in indexer/src/api/server.ts and
the schema in indexer/src/db/migrations/; where other documents describe these
endpoints, this file wins.
- Base URL —
http://{API_HOST}:{API_PORT}, defaulting to0.0.0.0:3001(indexer/src/index.ts). Production deployments front this with a gateway. - Content type — every response is
application/json, exceptGET /events/stream, which istext/event-stream. - No authentication — the indexer serves public, already-public-on-chain data and
performs no authentication or authorisation of its own.
POST /replayis a privileged operation with no built-in guard; do not expose it to the internet. See Authentication. - Numeric JSON types —
BIGINT/BIGSERIALcolumns are returned by the Postgres driver as strings, not numbers, to avoid precision loss. Parseid,ledger_sequence, andwaste_idaccordingly. - Snake_case payloads — event and alert objects are database rows returned
verbatim, so their fields are snake_case. Only the envelope fields
(
total,limit,offset,replayId,eventCount) are camelCase. - Out-of-range
limit— clamped to the maximum silently; it is never an error.
| Method | Path | Purpose | Success |
|---|---|---|---|
GET |
/health |
Liveness probe | 200 |
GET |
/metrics |
Process metrics snapshot | 200 |
GET |
/events |
Query indexed events | 200 |
GET |
/events/stream |
SSE stream of new events | 200 |
POST |
/replay |
Replay stored events | 202 |
GET |
/replay/status/:id |
Replay subsystem status | 200 |
GET |
/alerts |
Recent alert history | 200 |
Liveness probe. Returns unconditionally as long as the process is accepting
connections — it does not check database connectivity or sync lag. Use
GET /metrics and its syncLag field for readiness decisions.
Request:
curl -s http://localhost:3001/healthResponse — 200 OK:
{
"status": "ok",
"timestamp": "2026-06-26T17:23:41.008Z"
}| Field | Type | Description |
|---|---|---|
status |
string | Always ok while the process is serving |
timestamp |
string | ISO 8601 timestamp of the response |
In-memory metrics for the running indexer process. Counters reset on restart —
startTime tells you the epoch they are relative to.
Request:
curl -s http://localhost:3001/metricsResponse — 200 OK:
{
"eventsProcessed": 14823,
"eventsFailed": 2,
"lastEventTimestamp": "2026-06-26T17:23:00.000Z",
"syncLag": 1,
"reorgsDetected": 0,
"alertsFired": 1,
"startTime": "2026-06-25T08:00:00.000Z",
"eventsByType": {
"recycled": 3201,
"transfer": 1840,
"reg": 500
}
}| Field | Type | Description |
|---|---|---|
eventsProcessed |
integer | Events successfully handled since start |
eventsFailed |
integer | Events that threw during handling |
lastEventTimestamp |
string | null | Close time of the most recent event; null before the first event |
syncLag |
integer | Ledgers behind chain tip |
reorgsDetected |
integer | Reorganisations detected since start |
alertsFired |
integer | Alerts raised since start |
startTime |
string | Process start time |
eventsByType |
object | Per-event-type counts, keyed by contract event symbol |
Query indexed contract events. Results are ordered newest-first by
ledger_sequence DESC, id DESC.
Query parameters:
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
type |
string | — | — | Exact match on event_type (recycled, transfer, confirmed, reg, rewarded, donated, …) |
from |
integer | — | — | Start ledger sequence, inclusive |
to |
integer | — | — | End ledger sequence, inclusive |
contractId |
string | — | — | Exact match on emitting contract ID |
txHash |
string | — | — | Exact match on transaction hash |
limit |
integer | 100 |
1000 |
Page size; higher values are clamped |
offset |
integer | 0 |
— | Pagination offset |
All filters are ANDed. Unknown query parameters are ignored.
Request:
curl -s "http://localhost:3001/events?type=recycled&from=1000000&limit=2"Response — 200 OK:
{
"events": [
{
"id": "48211",
"ledger_sequence": "1000123",
"ledger_close_time": "2026-06-26T10:00:00.000Z",
"transaction_hash": "a3f1c0b9e2d84f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a",
"contract_id": "CCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"event_type": "recycled",
"topic": ["recycled", "42"],
"value": {
"waste_type": 2,
"weight": 1000,
"recycler": "GABCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"latitude": 40714000,
"longitude": -74006000
},
"created_at": "2026-06-26T10:00:02.417Z"
}
],
"total": 3201,
"limit": 2,
"offset": 0
}| Field | Type | Description |
|---|---|---|
events |
array | Rows from raw_events (see column reference below) |
total |
integer | Total rows matching the filters, ignoring limit/offset |
limit |
integer | Effective page size after clamping |
offset |
integer | Echo of the requested offset |
raw_events columns:
| Column | SQL type | JSON type | Description |
|---|---|---|---|
id |
BIGSERIAL |
string | Monotonic insertion ID |
ledger_sequence |
BIGINT |
string | Ledger the event was emitted in |
ledger_close_time |
TIMESTAMPTZ |
string | Ledger close time |
transaction_hash |
TEXT |
string | Emitting transaction |
contract_id |
TEXT |
string | Emitting contract |
event_type |
TEXT |
string | Contract event symbol |
topic |
TEXT[] |
string[] | Raw event topics |
value |
JSONB |
object | Decoded event payload |
created_at |
TIMESTAMPTZ |
string | When the indexer persisted the row |
Pagination: page with limit/offset and stop when
offset + events.length >= total. Because ordering is newest-first, a deep paginated
scan taken while the indexer is live can skip or repeat rows as new events land at the
head. For a stable scan, pin the range with to={current_tip} and page within it.
Errors: 500 if the database query fails.
Server-Sent Events stream of events as the indexer processes them. On connect the
server sends a {"type":"connected"} frame, then one frame per event, plus a
:keepalive comment every 15 seconds to hold the connection open through proxies.
Request:
curl -N -H "Accept: text/event-stream" http://localhost:3001/events/streamResponse — 200 OK, Content-Type: text/event-stream:
data: {"type":"connected"}
data: {"event_type":"recycled","ledger_sequence":"1000456", ...}
:keepalive
data: {"event_type":"transfer","ledger_sequence":"1000457", ...}
Response headers: Cache-Control: no-cache, Connection: keep-alive,
X-Accel-Buffering: no (the last disables nginx response buffering, which would
otherwise defeat streaming).
The stream carries live events only — it does not replay history on connect.
Bridge the gap after a reconnect by noting the last ledger_sequence you saw and
backfilling with GET /events?from={last_seen} before trusting the stream again.
Known issue — this route is currently unreachable. The dispatcher tests
path.startsWith('/events')before it testspath === '/events/stream', so a request for/events/streamis matched by the/eventsbranch and answered with a JSON event page instead of an SSE stream. The behaviour documented above is what the handler implements; reaching it requires reordering those two branches inindexer/src/api/server.ts. Until that is fixed, external consumers should pollGET /eventsrather than depend on the stream.
Re-dispatch stored events from raw_events through the event handlers. Use this to
rebuild derived tables after a handler bug fix, without re-fetching from Stellar.
The request returns as soon as the matching rows are selected; processing continues
asynchronously in the background. A 202 means "accepted and started", not
"finished".
Privileged operation. There is no authentication on this endpoint. It replays events through handlers that mutate indexer tables. Keep it behind your gateway, bound to a private interface, or firewalled off.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
fromLedger |
integer | yes | Start ledger, inclusive. Must be a JSON number. |
toLedger |
integer | no | End ledger, inclusive. Omit to replay through the newest stored event. |
eventTypes |
string[] | no | Restrict to these event types. Omit or pass an empty array for all types. |
Request:
curl -s -X POST http://localhost:3001/replay \
-H 'Content-Type: application/json' \
-d '{"fromLedger": 900000, "toLedger": 1000000, "eventTypes": ["recycled", "transfer"]}'Response — 202 Accepted:
{
"replayId": "replay_1750970400000_a1b2c3",
"eventCount": 1284,
"status": "started"
}| Field | Type | Description |
|---|---|---|
replayId |
string | Correlation ID, emitted in indexer logs for this replay |
eventCount |
integer | Rows selected for replay |
status |
string | Always started |
Errors:
| Status | Body | Cause |
|---|---|---|
400 |
{"error":"fromLedger is required"} |
fromLedger missing or not a number |
400 |
{"error":"SyntaxError: ..."} |
Body is not valid JSON |
405 |
{"error":"Method not allowed"} |
Any method other than POST |
replayId is a log correlation handle only — it cannot be used to poll progress (see
the next endpoint). Track a replay by grepping the indexer logs for it; failures are
logged as Replay processing failed with the same ID.
Reports whether the replay subsystem is available.
Per-job progress is not implemented. The
:idpath segment is accepted but ignored, and the response is a fixed availability probe — it does not reflect the state of any particular replay. Treat this as a capability check, not a job poller.
Request:
curl -s http://localhost:3001/replay/status/replay_1750970400000_a1b2c3Response — 200 OK:
{
"status": "available",
"message": "Replay status tracking is active"
}Recent monitoring alerts from the alert_history table, newest first. This is
history, not currently-firing state — there is no resolved/cleared flag, so a row
here means "this alert fired at this time", not "this alert is active now".
Query parameters:
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
limit |
integer | 50 |
200 |
Page size; higher values are clamped |
There is no offset — this endpoint returns only the most recent window.
Request:
curl -s "http://localhost:3001/alerts?limit=10"Response — 200 OK:
{
"alerts": [
{
"id": "912",
"alert_name": "HighSyncLag",
"severity": "warning",
"message": "Indexer is 5 ledgers behind chain tip",
"metadata": { "syncLag": 5, "threshold": 3 },
"created_at": "2026-06-26T17:20:00.000Z"
}
]
}| Field | Type | Description |
|---|---|---|
id |
string | BIGSERIAL primary key |
alert_name |
string | Alert rule name |
severity |
string | One of info, warning, critical |
message |
string | Human-readable description |
metadata |
object | null | Rule-specific context |
created_at |
string | When the alert fired |
Errors: 500 if the database query fails.
Every error body is {"error": "<message>"}.
| Status | Body | When |
|---|---|---|
400 Bad Request |
{"error":"fromLedger is required"} |
Invalid POST /replay body |
404 Not Found |
{"error":"Not found"} |
Path does not match any route |
405 Method Not Allowed |
{"error":"Method not allowed"} |
Wrong method on a matched path |
500 Internal Server Error |
{"error":"Internal server error"} |
Unhandled exception; details are logged server-side, not returned |
Note that 500 bodies are deliberately generic. When debugging an integration,
correlate by timestamp against the indexer logs rather than expecting detail in the
response.
The examples above were written against the route handlers in
indexer/src/api/server.ts. To re-verify after a change:
# 1. Start Postgres and the indexer (see docs/DEVELOPER_ONBOARDING.md)
cd indexer && npm install && npm run dev
# 2. Walk the endpoints
curl -s localhost:3001/health
curl -s localhost:3001/metrics
curl -s 'localhost:3001/events?limit=1'
curl -s 'localhost:3001/alerts?limit=1'
curl -s localhost:3001/replay/status/x
# 3. Confirm error shapes
curl -s localhost:3001/nope # 404
curl -s -X GET localhost:3001/replay # 405
curl -s -X POST localhost:3001/replay -d '{}' # 400For the full contract function reference see docs/CONTRACT_DOCUMENTATION.md and docs/API_REFERENCE.md.
Register a participant:
soroban contract invoke \
--id $CONTRACT_ID \
--source $SECRET_KEY \
--network testnet \
-- register_participant \
--address $ADDR --role 0 --name recycler1 \
--lat 40714000 --lon -74006000Submit waste material:
soroban contract invoke \
--id $CONTRACT_ID --source $SECRET_KEY --network testnet \
-- submit_material \
--submitter $ADDR --waste_type 2 --weight 1000 \
--lat 40714000 --lon -74006000Get global metrics:
soroban contract invoke \
--id $CONTRACT_ID --network testnet \
-- get_metricsnpm install @scavngr/sdk
# or
yarn add @scavngr/sdkimport { ScavengerClient, Network, signWithFreighter } from '@scavngr/sdk'
const client = new ScavengerClient({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2025',
contractId: process.env.VITE_CONTRACT_ID!,
})
// For browser (Freighter wallet)
client.setSigningStrategy(signWithFreighter())
// For server / CI (secret key)
// client.setSigningStrategy(signWithSecretKey(process.env.SECRET_KEY!))// Register a new participant
await client.registerParticipant({
address: 'GABC...',
role: Role.Recycler,
name: 'Alice',
latitude: 40_714_000,
longitude: -74_006_000,
})
// Get participant info
const participant: Participant | null = await client.getParticipant('GABC...')
// Check registration
const registered: boolean = await client.isParticipantRegistered('GABC...')// Submit a material
const wasteId: bigint = await client.submitMaterial({
submitter: 'GABC...',
wasteType: WasteType.Plastic,
weight: 1000n,
latitude: 40_714_000n,
longitude: -74_006_000n,
})
// Transfer waste
await client.transferWaste({
wasteId,
from: 'GABC...',
to: 'GDEF...',
latitude: 40_714_000n,
longitude: -74_006_000n,
note: 'Delivered to collector',
})
// Get waste details
const waste: Waste | null = await client.getWaste(wasteId)
// Get transfer history
const history: WasteTransfer[] = await client.getWasteTransferHistory(wasteId)// Create incentive (Manufacturer only)
const incentiveId: bigint = await client.createIncentive({
rewarder: 'GMFR...',
wasteType: WasteType.Plastic,
rewardPoints: 100n,
budget: 50_000n,
})
// Get active incentives for a waste type
const incentives: Incentive[] = await client.getIncentives(WasteType.Plastic)
// Distribute rewards
await client.distributeRewards({
wasteId,
incentiveId,
manufacturer: 'GMFR...',
})const metrics: GlobalMetrics = await client.getMetrics()
console.log(metrics.total_wastes_count) // number
console.log(metrics.total_tokens_earned) // bigint
const stats: ParticipantStats | null = await client.getStats('GABC...')
const supplyChain: SupplyChainStats = await client.getSupplyChainStats()interface ClientOptions {
rpcUrl: string
networkPassphrase: string
contractId: string
pollTimeoutMs?: number // default: 30000
pollIntervalMs?: number // default: 1500
}
enum Role { Recycler, Collector, Manufacturer }
enum WasteType { Paper=0, PetPlastic=1, Plastic=2, Metal=3, Glass=4, Organic=5, Electronic=6 }
enum Network { Standalone, Testnet, Futurenet, Mainnet }
interface Participant {
address: string; role: Role; name: string
latitude: number; longitude: number; registered_at: number
}
interface Waste {
waste_id: bigint; waste_type: WasteType; weight: bigint
current_owner: string; latitude: bigint; longitude: bigint
recycled_timestamp: number; is_active: boolean
is_confirmed: boolean; confirmer: string
}
interface Incentive {
id: number; rewarder: string; waste_type: WasteType
reward_points: number; total_budget: number
remaining_budget: number; active: boolean; created_at: number
}
interface GlobalMetrics {
total_wastes_count: number
total_tokens_earned: bigint
}import { ContractError, TransactionError, NetworkError } from '@scavngr/sdk'
try {
await client.transferWaste({ ... })
} catch (err) {
if (err instanceof ContractError) {
// err.code maps to contract Error enum (see error codes table)
console.error('Contract error:', err.code, err.message)
} else if (err instanceof TransactionError) {
console.error('Transaction failed:', err.resultCode)
} else if (err instanceof NetworkError) {
console.error('Network error:', err.message)
}
}| Status | Meaning |
|---|---|
| 200 | Success |
| 202 | Accepted (async job started) |
| 400 | Bad Request — invalid query parameters |
| 404 | Not Found — resource doesn't exist |
| 429 | Too Many Requests — rate limit exceeded |
| 500 | Internal Server Error |
All contract functions return Result<T, Error>. The numeric error code is embedded in the transaction result.
| Code | Variant | Likely Cause | Resolution |
|---|---|---|---|
| 1 | AlreadyInitialized |
initialize_admin called twice |
Deploy a fresh contract |
| 2 | Unauthorized |
Non-admin called admin function | Use the correct admin key |
| 3 | NotRegistered |
Participant not on chain | Call register_participant first |
| 4 | AlreadyRegistered |
Address already exists | Address is already active |
| 5 | NotManufacturer |
Role ≠ Manufacturer | Switch to a Manufacturer account |
| 6 | NotWasteOwner |
Caller doesn't own waste | Use the waste's current owner address |
| 7 | WasteNotFound |
Invalid waste ID | Check get_participant_wastes for valid IDs |
| 9 | IncentiveNotFound |
Invalid incentive ID | Check get_active_incentives |
| 12 | InvalidWeight |
Weight = 0 | Provide weight > 0 |
| 13 | InvalidCoordinates |
lat/lon out of range | lat ∈ [-90M, +90M], lon ∈ [-180M, +180M] |
| 14 | InvalidPercentage |
Sum > 100 | collector% + owner% ≤ 100 |
| 18 | WasteDeactivated |
Waste was deactivated | Cannot be used after deactivation |
| 22 | SelfConfirmation |
Owner confirmed own waste | Use a different confirmer address |
| 26 | NoRewardAvailable |
Budget exhausted | Create a new incentive with fresh budget |
| 27 | InvalidTransferRoute |
Invalid role combination | Only Recycler→Collector/Mfr, Collector→Mfr |
| 31 | InsufficientBudget |
Budget < reward | Increase incentive budget |
| 44 | WasteExpired |
Waste TTL elapsed | Waste can no longer be transferred |
The Indexer REST API uses URL-free versioning; breaking changes are indicated by the version field in the API spec and changelog.
Current version: v1.0
The Soroban contract API is versioned via on-chain upgrade proposals (see docs/CONTRACT_DOCUMENTATION.md → Upgrade Procedures). Read-only functions are forwards-compatible; new parameters in write functions are additive.
- Deprecated endpoints/fields are marked in the OpenAPI spec with
deprecated: true. - Deprecated items are supported for a minimum of 3 months before removal.
- Breaking changes are communicated via GitHub releases.
Indexer reference corrected against the implementation. No API behaviour changed; the previous documentation described intended rather than actual behaviour. If you integrated against v1.0.0 docs, review the following:
GET /events— defaultlimitis 100 (not 50) and the maximum is 1000 (not 500); added the previously undocumentedcontractIdandtxHashfilters.GET /eventsresponse — events areraw_eventsrows in snake_case (ledger_sequence,event_type,transaction_hash,topic,value), not the camelCase shape previously shown.BIGINTfields are returned as strings.POST /replay— onlyfromLedgeris required; added theeventTypesfilter. The202body is{replayId, eventCount, status}, not{id, status, progress}.GET /replay/status/:id— documented as an availability probe; per-job progress tracking does not exist, and the:idsegment is ignored.GET /alerts— documented thelimitparameter (default 50, max 200) and the actualalert_historyrow shape; this is alert history, not active-alert state.- Rate limiting — corrected to state that the Redis-backed limiter is not currently
attached to the HTTP server; suggested limits aligned with
.env.example(60 req/min, burst 10) rather than the previously stated 100 req/min. - Added common error responses, pagination guidance, SSE reconnect guidance, and a verification script.
- Flagged that
GET /events/streamis currently shadowed by the/eventsroute match and is unreachable until the route ordering is fixed.
Initial release.
- Indexer REST API:
/health,/metrics,/events,/events/stream,/replay,/alerts - Soroban contract API: full function coverage for admin, participants, waste, incentives, rewards
- TypeScript SDK (
@scavngr/sdk): typed wrappers for all contract functions - Support for Freighter wallet signing and secret-key signing
- Network presets: Standalone, Testnet, Futurenet, Mainnet
- Rate limiting: 100 req/min (indexer), burst 10 req/s
- Error mapping: contract error codes 1–54, SDK error class hierarchy
- Webhook subscriptions for real-time event delivery
- GraphQL endpoint for complex queries
- Pagination cursors (replace offset with cursor-based pagination)