API and services for the Creditra adaptive credit protocol: credit lines, risk evaluation, and (future) Horizon listener and interest accrual.
This service provides:
- API gateway — REST endpoints for credit lines and risk evaluation
- Health check —
/healthfor readiness - Planned: Risk engine (wallet history, scoring), Horizon listener (events → DB), interest accrual, liquidity pool manager
Stack: Node.js, Express, TypeScript.
- Express — HTTP API
- TypeScript — ESM, strict mode
- tsx — dev run with watch
- Jest + ts-jest — unit & integration tests
- ESLint + @typescript-eslint — linting
- Node.js 20+
- npm
- Docker 24+ and Docker Compose v2 (for the containerised workflow below)
cd creditra-backend
npm installDevelopment (watch):
npm run devProduction:
npm run build
npm startAPI base: http://localhost:3000.
The fastest way to get the full stack (API + PostgreSQL) running locally without installing Node or Postgres on your host machine.
# 1. Copy the example env file and fill in your values
cp .env.example .env
# 2. Build the image and start all services (API + db)
docker compose up --build
# 3. (Separate terminal) Apply database migrations
docker compose exec api npm run db:migrate
# Stop everything and remove containers
docker compose down
# Stop and also delete the postgres volume (wipes DB data)
docker compose down -vThe API hot-reloads on every source-file save (via tsx watch) thanks to the bind-mount in docker-compose.yml.
| Service | Host port | Container port | Notes |
|---|---|---|---|
| API | 3000 |
3000 |
http://localhost:3000 · Swagger at /docs |
| Postgres | 5432 |
5432 |
Direct access via psql / TablePlus |
| File | Purpose |
|---|---|
.env.example |
Committed template — lists every variable with safe defaults |
.env |
Your local overrides — gitignored, never committed |
docker compose reads .env automatically. The DATABASE_URL set inside docker-compose.yml overrides whatever is in .env so the API always reaches the db service by its compose hostname.
Security notes
- Containers run as the non-root
nodeuser (UID 1000).API_KEYSandWEBHOOK_SECRETmust be changed from the placeholder values before any real traffic is served.- The Postgres password in
docker-compose.ymlis intentionally simple for local dev; never reuse it in staging/production environments.- Stellar private keys and PII should never be stored in
.envfiles checked into version control.
| Target | Used by | Includes devDeps | Start command |
|---|---|---|---|
development |
docker compose up |
✅ Yes | npm run dev |
build |
intermediate | ✅ Yes | npm run build |
runner |
production deploys | ❌ No | node dist/index.js |
Build the production image directly with:
docker build --target runner -t creditra-backend:latest .| Variable | Required | Description |
|---|---|---|
PORT |
No | Server port (default: 3000) |
API_KEYS |
Yes | Comma-separated list of valid admin API keys (see below) |
CORS_ORIGINS |
Prod | Comma-separated allowlist of exact browser origins |
DATABASE_URL |
No | PostgreSQL connection string (required for migrations) |
HTTP_CONNECT_TIMEOUT_MS |
No | Connection timeout for outbound HTTP (default: 5000) |
HTTP_READ_TIMEOUT_MS |
No | Read timeout for outbound HTTP (default: 10000) |
Optional later: REDIS_URL, HORIZON_URL, etc.
All outbound HTTP requests (Horizon API, risk providers) use configurable timeouts to prevent hanging connections. See docs/http-timeouts.md for configuration and usage details.
Browser clients are allowed only when their Origin header matches the
configured CORS policy.
- In production, set
CORS_ORIGINSto a comma-separated list of exact origins, for examplehttps://app.example.com,https://admin.example.com. - In non-production environments, the server falls back to loopback origins
such as
http://localhost:3000,http://127.0.0.1:3000, andhttp://[::1]:3000so local UI development stays frictionless. - Requests without an
Originheader are still accepted; CORS only controls browser access, not API authentication.
Security note: CORS is not an auth boundary. It does not protect
API_KEYS, PII, or Stellar secrets. Keep those values server-side, use HTTPS in production, and keep the browser allowlist tight.
The PostgreSQL schema is designed and documented in docs/data-model.md. It covers borrowers, credit lines, risk evaluations, transactions, and events, with indexes and security notes.
- Migrations live in
migrations/as sequential SQL files. See migrations/README.md for strategy and naming. - Apply migrations:
DATABASE_URL=... npm run db:migrate - Validate schema:
DATABASE_URL=... npm run db:validate
The migration CLI includes comprehensive safety guards to prevent accidental data loss in production environments.
- Dry-run mode: Preview pending migrations without applying them
- Environment detection: Automatic detection of production-like environments
- Force requirement: Production-like environments require explicit
--forceflag - Interactive confirmation: Prompts for confirmation in production environments
- Clear error messages: Detailed troubleshooting and rollback guidance
# Dry run - see what would be applied
DATABASE_URL=... npm run db:migrate -- --dry-run
# Development - safe to run
DATABASE_URL=... npm run db:migrate
# Staging - requires force flag
DATABASE_URL=... npm run db:migrate -- --force --env staging
# Production - requires force flag and explicit confirmation
DATABASE_URL=... npm run db:migrate -- --force --env production| Environment | Force Required | Confirmation |
|---|---|---|
development |
No | No |
test |
No | No |
staging |
Yes | Yes |
production |
Yes | Yes (requires "MIGRATE PRODUCTION") |
*prod* |
Yes | Yes |
Before Migration:
- Always run with
--dry-runfirst to review pending migrations - Ensure you have a recent database backup
- Test migrations in staging environment first
- Prepare rollback plan
- Notify relevant stakeholders
Production Migration Process:
- Run dry-run:
npm run db:migrate -- --dry-run --env production - Review pending migrations carefully
- Confirm backup is available
- Execute with force:
npm run db:migrate -- --force --env production - Type "MIGRATE PRODUCTION" when prompted
- Verify application functionality post-migration
Troubleshooting:
- Check database connection and permissions
- Verify migration file syntax
- Ensure database is in correct state
- Review migration logs for specific errors
- Consider rollback if production issues occur
Admin and internal endpoints are protected by an API key sent in the
X-API-Key HTTP header.
Set the API_KEYS environment variable to a comma-separated list of secret
keys before starting the service:
export API_KEYS="key-abc123,key-def456"
npm run devThe service will not start (throws at boot) if API_KEYS is unset or
empty, preventing accidental exposure of unprotected admin routes.
curl -X POST http://localhost:3000/api/credit/lines/42/suspend \
-H "X-API-Key: key-abc123"| Result | Condition |
|---|---|
401 Unauthorized |
X-API-Key header is absent |
403 Forbidden |
Header present but key is not in API_KEYS |
200 OK |
Key matches one of the configured valid keys |
Security note: The value of an invalid key is never included in error responses or server logs. Always use HTTPS in production.
| Method | Path | Description |
|---|---|---|
POST |
/api/credit/lines/:id/suspend |
Suspend an active credit line |
POST |
/api/credit/lines/:id/close |
Permanently close a credit line |
POST |
/api/risk/admin/recalibrate |
Trigger risk model recalibration |
Public endpoints (GET /api/credit/lines, POST /api/risk/evaluate, etc.)
do not require a key.
Use a rolling rotation to avoid downtime:
- Add the new key to
API_KEYS(keep the old key alongside it). - Deploy / restart the service.
- Update all clients and CI secrets to use the new key.
- Remove the old key from
API_KEYSand redeploy.
This ensures no requests are rejected during the transition window.
The GitHub Actions workflow (.github/workflows/ci.yml) runs on every push and pull request:
| Step | Command | Fails build on… |
|---|---|---|
| TypeScript typecheck | npm run typecheck |
Any type error |
| Lint | npm run lint |
Any ESLint warning or error |
| Dependency audit | npm audit --production |
Moderate+ vulnerabilities |
This project enforces automated dependency vulnerability checks as part of CI.
npm audit --productionruns on every push- GitHub Dependency Review runs on every pull request
These checks help prevent vulnerable dependencies from reaching production.
| Severity | Policy |
|---|---|
| Low | Allowed |
| Moderate | Allowed with justification |
| High | Must be fixed before merge |
| Critical | Blocker — cannot be merged |
Moderate vulnerabilities may be accepted only if:
- No fix is available
- The vulnerable code path is not used in production
- There is no impact on security-sensitive data (API keys, PII, Stellar data)
All exceptions must be documented in the pull request description.
Before pushing changes, run:
npm audit --production
### Run locally
```bash
# Typecheck
npm run typecheck
# Lint
npm run lint
# Lint with auto-fix
npm run lint:fix
# Tests (single run + coverage report)
npm test
# Tests in watch mode
npm run test:watchCoverage threshold: 95% lines, branches, functions, and statements (enforced by Jest).
All API endpoints use a standardized response envelope format for consistency:
{
data: T | null, // Success payload or null on error
error: string | null // Error message or null on success
}Examples:
// Success response (200)
{
"data": {
"id": "line-123",
"walletAddress": "GABCDEF...",
"creditLimit": "5000.00"
},
"error": null
}
// Error response (400)
{
"data": null,
"error": "Credit line not found"
}The envelope is implemented via helper functions in src/utils/response.ts:
ok(res, data, statusCode?)- Send success response (default 200)fail(res, error, statusCode?)- Send error response (default 500)
HTTP status codes follow REST conventions:
200- Success201- Created204- No Content (DELETE operations)400- Bad Request (client error)401- Unauthorized (missing auth)403- Forbidden (invalid auth)404- Not Found500- Internal Server Error
GET /health— Service healthGET /api/credit/lines— List credit lines with pagination support- Cursor pagination (recommended):
?cursor&limit=50or?cursor=<token>&limit=50 - Offset pagination (legacy):
?offset=0&limit=50 - See Cursor Pagination Guide for details
- Cursor pagination (recommended):
GET /api/credit/lines/:id— Get credit line by id (placeholder)POST /api/risk/evaluate— Risk evaluation; body:{ "walletAddress": "..." }
POST /api/credit/lines/:id/suspend— Suspend a credit linePOST /api/credit/lines/:id/close— Close a credit linePOST /api/risk/admin/recalibrate— Trigger risk model recalibrationPOST /api/webhooks/test— Test webhook connectivity
GET /api/webhooks/config— Get webhook configurationGET /api/webhooks/health— Webhook service health check
The /api/credit/lines endpoint supports two pagination modes:
-
Cursor-based (recommended for production): Provides stable pagination for large datasets
# First page curl "http://localhost:3000/api/credit/lines?cursor&limit=10" # Next page (use nextCursor from response) curl "http://localhost:3000/api/credit/lines?cursor=<nextCursor>&limit=10"
-
Offset-based (legacy): Traditional pagination with total count
curl "http://localhost:3000/api/credit/lines?offset=0&limit=10"
For detailed documentation, examples, and migration guide, see docs/cursor-pagination.md.
npm test # run once with coverage report
npm run test:watch # interactive watch modeTarget: ≥ 95 % coverage on all middleware and route files.
src/
config/
apiKeys.ts # loads + validates API_KEYS env var
cors.ts # loads + validates CORS_ORIGINS env var
env.ts # Zod schema; validates all env vars at startup
container/
Container.ts # DI container; wires repos → services
middleware/
auth.ts # requireApiKey (X-API-Key header)
adminAuth.ts # admin-only authz helper
validate.ts # Zod schema validation factory
errorHandler.ts # global Express error handler
models/ # shared TypeScript entity types
repositories/
interfaces/ # repository contracts (TypeScript interfaces)
CreditLineRepository.ts
RiskEvaluationRepository.ts
TransactionRepository.ts
memory/ # in-memory implementations (dev / tests)
InMemoryCreditLineRepository.ts
InMemoryRiskEvaluationRepository.ts
InMemoryTransactionRepository.ts
routes/
health.ts # GET /health
credit.ts # credit-line endpoints (public + admin)
risk.ts # risk endpoints (public + admin)
webhook.ts # webhook management endpoints
schemas/ # Zod request-body schemas
services/
creditService.ts # credit-line state machine + draw logic
CreditLineService.ts # repo-backed credit line service
riskService.ts # wallet risk evaluation
RiskEvaluationService.ts # repo-backed risk service
horizonListener.ts # Stellar Horizon event poller
drawWebhookService.ts # draw confirmation webhook delivery
jobQueue.ts # background job scheduler
reconciliationService.ts # chain vs DB reconciliation
reconciliationWorker.ts # scheduled reconciliation worker
sorobanClient.ts # Soroban RPC client
utils/
response.ts # ok() / fail() envelope helpers
stellarAddress.ts # Stellar public-key validation
openapi.yaml # machine-readable API contract
index.ts # app bootstrap + server listen
docs/
data-model.md # PostgreSQL schema documentation
REPOSITORY_ARCHITECTURE.md # deep-dive on the repository pattern
security-checklist-backend.md
security-pentest-checklist.md # pre-engagement API pentest readiness
migrations/ # sequential SQL migration files
.github/workflows/
ci.yml # CI: typecheck → lint → test → coverage
The backend follows a strict layered architecture with dependency injection, ensuring each layer has a single responsibility and can be tested in isolation.
HTTP Client
│
▼
┌─────────────────────────────────────────────┐
│ Express Middleware │
│ cors · json · validate · requireApiKey │
│ errorHandler │
└────────────────────┬────────────────────────┘
│
┌────────────────▼───────────────┐
│ Routes │
│ /health /api/credit /api/risk│
└────────────────┬───────────────┘
│ calls
┌────────────────▼───────────────┐
│ Services │
│ creditService · riskService │
│ CreditLineService │
│ RiskEvaluationService │
└────────────────┬───────────────┘
│ calls
┌────────────────▼───────────────┐
│ Repositories │
│ (interface contracts) │
│ InMemory* implementations │
│ → future: Postgres* │
└────────────────┬───────────────┘
│
┌────────────────▼───────────────┐
│ Data Store │
│ In-memory Maps (dev/test) │
│ PostgreSQL (production) │
└────────────────────────────────┘
┌────────────────────────────────┐
│ HorizonListener (background) │
│ polls Stellar Horizon API → │
│ dispatches HorizonEvent → │
│ jobQueue handlers → Services │
└────────────────────────────────┘
All repository and service instances are created once inside Container.getInstance() (singleton) and injected wherever needed. Swapping from in-memory to PostgreSQL requires only a change in Container.ts.
Full details: docs/REPOSITORY_ARCHITECTURE.md.
The draw operation is the core credit-line action. It validates the borrower's identity, checks credit limits, updates utilisation, and records a transaction.
sequenceDiagram
participant Client
participant Express
participant validate as validate middleware
participant CreditRoute as routes/credit.ts
participant CreditSvc as creditService
participant Store as In-Memory Store
Client->>Express: POST /api/credit/lines/:id/draw
Note over Client,Express: Body: { borrowerId, amount }
Express->>validate: Zod schema check (drawSchema)
alt invalid body
validate-->>Client: 400 Bad Request { error }
end
validate->>CreditRoute: req passes validation
CreditRoute->>CreditSvc: drawFromCreditLine({ id, borrowerId, amount })
CreditSvc->>Store: find credit line by id
alt not found
Store-->>CreditSvc: undefined
CreditSvc-->>CreditRoute: throw NOT_FOUND
CreditRoute-->>Client: 404 { error: "Credit line not found" }
end
alt line.status !== "Active"
CreditSvc-->>CreditRoute: throw INVALID_STATUS
CreditRoute-->>Client: 400 { error: "Credit line not active" }
end
alt line.borrowerId !== borrowerId
CreditSvc-->>CreditRoute: throw UNAUTHORIZED
CreditRoute-->>Client: 403 { error: "Unauthorized borrower" }
end
alt utilized + amount > limit
CreditSvc-->>CreditRoute: throw OVER_LIMIT
CreditRoute-->>Client: 400 { error: "Amount exceeds credit limit" }
end
CreditSvc->>Store: line.utilized += amount
Store-->>CreditSvc: updated line
CreditSvc-->>CreditRoute: updated CreditLine
CreditRoute-->>Client: 200 { message: "Draw successful", creditLine }
src/services/horizonListener.ts runs a background polling loop that watches for on-chain events emitted by Soroban smart contracts:
setInterval(pollOnce, POLL_INTERVAL_MS)
│
└─► GET {HORIZON_URL}/events?contractId=...&startLedger=...
│
└─► dispatchEvent(HorizonEvent)
│
└─► registered EventHandlers (jobQueue, etc.)
Key env vars (see Environment table and .env.example):
| Variable | Default | Purpose |
|---|---|---|
HORIZON_URL |
https://horizon-testnet.stellar.org |
Stellar Horizon endpoint |
CONTRACT_IDS |
(empty) | Comma-separated Soroban contract IDs to watch |
POLL_INTERVAL_MS |
5000 |
Polling frequency in ms |
HORIZON_START_LEDGER |
latest |
Ledger to begin replaying from |
Soroban contract dependency (high level) Creditra credit lines, draw authorisations, and repayments are ultimately settled against Soroban smart contracts deployed on the Stellar network. The backend treats these contracts as an external source of truth: the
HorizonListenerconsumes contract events (credit_line_created,draw_authorised, etc.) and propagates them into the service layer. The actual contract addresses are configured viaCONTRACT_IDSand are not hardcoded. No private keys or signing operations are performed by this service.
The backend provides optional webhook notifications when draw confirmations are detected via Horizon polling. Webhooks are delivered with HMAC signatures and include retry logic with exponential backoff.
| Variable | Required | Default | Description |
|---|---|---|---|
WEBHOOK_URLS |
No | (empty) | Comma-separated webhook endpoint URLs |
WEBHOOK_SECRET |
Yes (if URLs configured) | (empty) | HMAC secret for payload signing |
WEBHOOK_MAX_RETRIES |
No | 3 |
Maximum retry attempts |
WEBHOOK_INITIAL_BACKOFF_MS |
No | 1000 |
Initial backoff delay in milliseconds |
WEBHOOK_BACKOFF_MULTIPLIER |
No | 2.0 |
Exponential backoff multiplier |
WEBHOOK_TIMEOUT_MS |
No | 10000 |
Request timeout in milliseconds |
When a draw confirmation event is detected, the following payload is sent:
{
"event": "draw_confirmed",
"timestamp": "2024-01-15T10:00:00.000Z",
"data": {
"ledger": 12345,
"contractId": "CC7P3M7JZB3J5K5L5M5N5O5P5Q5R5S5T5U5V5W5X5Y5Z",
"drawAmount": "1000.00000000",
"drawId": "draw_abc123",
"borrowerWallet": "GABC1234567890DEF1234567890DEF1234567890",
"creditLineId": "credit_line_456",
"horizonTimestamp": "2024-01-15T09:59:58.000Z"
}
}- HMAC Signature: Each webhook includes an
X-Webhook-Signatureheader with formatsha256=<hex-signature> - Signature Verification: Verify signatures using your configured
WEBHOOK_SECRET:echo -n "<payload>" | openssl dgst -sha256 -hmac "<your-secret>"
- Timestamp:
X-Webhook-Timestampheader helps prevent replay attacks - User Agent:
Creditra-Webhook/1.0identifies legitimate webhook requests
Failed webhook deliveries are retried with exponential backoff:
- Initial delay:
WEBHOOK_INITIAL_BACKOFF_MS(default 1000ms) - Subsequent delays:
previous_delay * WEBHOOK_BACKOFF_MULTIPLIER(default 2.0x) - Maximum attempts:
WEBHOOK_MAX_RETRIES(default 3)
| Method | Path | Description |
|---|---|---|
GET |
/api/webhooks/config |
Get current webhook configuration (excludes secrets) |
POST |
/api/webhooks/test |
Test connectivity to all configured webhook URLs |
GET |
/api/webhooks/health |
Check webhook service health status |
# Test webhook connectivity
curl -X POST http://localhost:3000/api/webhooks/test \
-H "X-API-Key: your-api-key"
# Check webhook configuration
curl http://localhost:3000/api/webhooks/config
# Check webhook service health
curl http://localhost:3000/api/webhooks/healthThe full API contract is the machine-readable source of truth:
- Bundled spec (served at runtime):
src/openapi.yaml - Swagger UI:
http://localhost:3000/docs(available when the server is running) - Raw docs copy:
docs/openapi.yaml
Keep openapi.yaml in sync with route behaviour; the CI pipeline validates the spec on every push (npm run validate:spec).
Security is a priority for Creditra. Before deploying or contributing:
- Review the Backend Security Checklist
- Before an external pentest, work through the API pentest readiness checklist
- Ensure all security requirements are met
- Run
npm audit --productionto check for vulnerabilities - Maintain minimum 95% test coverage
git remote add origin <your-creditra-backend-repo-url>
git push -u origin main