Issue: #752
Version: v1.0.1
Last Updated: 2026-07-24
- Overview
- OpenAPI Specification
- Authentication
- Rate Limiting
- 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 |
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_GUIDE.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)