Rafid Intelligence Network

Official registry Finance and payments

Paid intelligence tools for AI agents across risk, finance, property, logistics and more.

What can you do with Rafid Intelligence Network?

  • REST route registration (api/app.ts) — both the X-API-Key and x402 route families
  • The OpenAPI document (api/openapi.ts) — operationId is the capability's own name (analyze_property, compare_properties, estimate_maintenance, analyze_oman_property), and request/response examples come from capability.example/`capability.exec...
  • The MCP server (mcp/server.ts) — tool name, schemas, and a description built from capability.description + capability.whenToUse
  • The x402 payment gate and BillingService (billing/x402.ts, billing/service.ts) — billing/catalog.ts's prices object is derived from capabilities (Object.fromEntries(capabilities.map(c => [c.name, c.price]))), never a second literal
  • Every agent-marketplace/discovery endpoint (/api/v1/agent, /api/v1/pricing, /api/v1/tools, /api/v1/capabilities, /agent.json, the two /.well-known/... manifests, /llms.txt)

Install

Add this to your client's MCP configuration (Claude Desktop: claude_desktop_config.json; Cursor: .cursor/mcp.json; VS Code and others use the same "mcpServers" shape).

Remote (streamable-http)
Claude Code
claude mcp add --transport http rafid-intelligence-network 'https://api.rafidsystem.com/mcp'
JSON configuration (Claude Desktop, Cursor, VS Code)
{
    "mcpServers": {
        "rafid-intelligence-network": {
            "type": "http",
            "url": "https://api.rafidsystem.com/mcp"
        }
    }
}

Documentation

From the project's README on GitHub, refreshed weekly. Links open on GitHub.

Contents
  1. Rafid Intelligence Network — structured intelligence and paid tools for AI agents
  2. Platform architecture
  3. Agent discovery
  4. Design constraints
  5. Customer storage phase
  6. Deploy on Vercel
  7. Prerequisites and quick start
  8. Configuration
  9. Architecture
  10. REST API
  11. MCP usage
  12. Security, observability and billing boundary
  13. Next five production priorities
  14. Postman API Network

Rafid Intelligence Network — structured intelligence and paid tools for AI agents

Rafid Intelligence Network provides structured intelligence and paid APIs for AI agents across property, companies, suppliers, documents, risk, vehicles and logistics. AI agents are the primary consumer of this platform. MCP, OpenAPI, REST, x402 and API Credits are supported access channels.

Platform architecture

Rafid Intelligence Network
├── Property Intelligence
├── Company Intelligence
├── Supplier Intelligence
├── Document Intelligence
├── Risk Intelligence
├── Vehicle Intelligence
└── Logistics Intelligence

Existing property-specific capability contracts, routes and MCP tool names remain unchanged.

The intended flow for an agent is: discover a capability → select the right tool → pay per call over x402 (or authenticate with an API key) → execute → get a structured, machine-readable result. No account, dashboard or subscription is required for either access model, and none is planned — see "Design constraints" below.

Agent discovery

Website planning and inspection are available as the paid website_project_estimate ($0.25) and website_audit ($0.75) capabilities. See website capabilities for contracts, limitations, preview calls and an audit-to-estimate chaining example.

An agent (or an agent marketplace/directory crawler) can start from any of the following; all are public, unauthenticated, always present, and contain no secrets (no wallet private keys, no API keys, no usage data for other customers):

EndpointPurpose
GET /agent.jsonThe full agent manifest: product identity, every supported protocol (MCP, x402, REST) and its role, x402 terms, and the complete tool catalog with full input/output JSON Schemas. Start here.
GET /.well-known/ai-plugin.jsonManifest in the legacy OpenAI ChatGPT-plugin convention, for tooling that still discovers services this way.
GET /.well-known/agent.jsonAn Agent Card in the Agent2Agent (A2A) protocol's convention, listing each tool as a skill.
GET /llms.txtA plain-text briefing for an LLM-based agent: what Rafid does, every tool and how to call it, pricing, the x402 model, and known limitations — no JSON parsing required.
GET /api/v1/capabilitiesThe machine-first capability registry: name, description, whenToUse, price, schemas and examples for every tool — optimized for a model to decide what to call, not for a human to read.
GET /api/v1/mcp/statusFactual MCP status: which transports are live (stdio always, http only when MCP_REMOTE_ENABLED=true), the tool count, and the remote endpoint path if any.
GET /api/v1/agentLegacy service-metadata endpoint (kept for backward compatibility); superseded by /agent.json above.
GET /api/v1/pricingThe full price list (USD, pay-per-call), sourced from one catalog shared by every code path — never duplicated or out of sync.
GET /api/v1/toolsLegacy tool catalog (kept for backward compatibility); superseded by /api/v1/capabilities above.

Every one of these is generated from the single capability registry in src/domain/capabilities.ts — see "Capability registry" below — so they can never disagree with each other or with what a call actually does.

Once a tool is chosen, call it with an X-API-Key header, or — when X402_ENABLED=true — call its /api/v1/x402/... twin with no key and pay per call on-chain instead (see "Pay-per-call via x402" below).

A human visiting / in a browser instead gets a short landing page (agent integration examples, tool list, pricing, links to docs); agents and scripts that send Accept: application/json keep getting the original JSON discovery payload — see "Landing page" below.

Design constraints

This product is deliberately agent-native, not a human SaaS dashboard. It does not have, and is not planned to have, user accounts, a dashboard, subscriptions, Stripe billing, or a billing portal. The X-API-Key route family is a compatibility transport for callers that authenticate that way, not an invitation to build account management around it.

Customer storage phase

PostgreSQL-backed customers, hashed keys, usage tracking and per-customer limits are now available. See activation and administration. Existing env-key development mode remains unchanged; production requires AUTH_MODE=postgres. No payment collection is implemented outside x402 (see below).

Deploy on Vercel

The Express entry point exports the app when VERCEL=1 and keeps the normal port listener for local runs. vercel.json pins the function to iad1 near the current US East Neon database and limits requests to 15 seconds. .vercelignore prevents the local .env, caches and generated files from being uploaded.

Production API: https://api.rafidsystem.com
Landing page: https://api.rafidsystem.com/ (browsers) / same URL with Accept: application/json (agents)
Health: https://api.rafidsystem.com/api/v1/health
OpenAPI: https://api.rafidsystem.com/openapi.json
Agent metadata: https://api.rafidsystem.com/api/v1/agent

Connect the repository directory to a Vercel project, attach the Neon Marketplace database, and configure these Production environment variables:

AUTH_MODE=postgres
NODE_ENV=production
LOG_LEVEL=info
X402_ENABLED=false
DATABASE_URL=<sensitive pooled Neon URL with sslmode=verify-full>

The Neon integration may inject DATABASE_URL; verify that it points to the rotated credential and pooled endpoint. Mark manually added database values as sensitive. Do not upload .env or use the previously exposed password. Deploy with Vercel CLI or a connected Git repository, then verify /api/v1/health, /openapi.json, /api/v1/agent, an authenticated analysis request, and the PostgreSQL usage record. The local stdio MCP process is not deployed by this Express function.

To also accept pay-per-call crypto payments, set X402_ENABLED=true and X402_WALLET_ADDRESS=<a 0x-prefixed EVM address you control> in Production. Leave X402_ENABLED=false (the default) until you are ready to receive real payments; see Configuration and the "Pay-per-call via x402" section below. On Base mainnet (eip155:8453) also set CDP_API_KEY_ID/CDP_API_KEY_SECRET (see Configuration).

Prerequisites and quick start

Use Node.js 24+ and npm. From the repository root:

Set-Location 'C:\Projects\rafid-agent-api'
npm.cmd ci --cache .npm-cache
if (-not (Test-Path .env)) { Copy-Item .env.example .env }
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

Copy the generated key into RAFID_API_KEYS in .env. Keep it private. A blank or short key prevents HTTP startup. Multiple keys are comma-separated. On macOS/Linux use npm and cp .env.example .env.

npm.cmd run dev

Development loads .env and watches TypeScript sources. For a compiled run:

npm.cmd run typecheck
npm.cmd run build
npm.cmd test
npm.cmd start

npm test first builds and then runs service, live HTTP, agent-marketplace, OpenAPI contract, and compiled MCP stdio tests. npm start loads .env and runs dist/server.js; dependencies are pinned in package-lock.json.

Configuration

VariableDefault / behavior
PORT8787
NODE_ENVdevelopment; accepts development, test, production
RAFID_API_KEYSRequired for REST; random keys of at least 24 characters
API_KEYLegacy fallback when RAFID_API_KEYS is empty
LOG_LEVELinfo; error logs only HTTP/tool 5xx completions; silent disables request logs
X402_ENABLEDfalse; true enables pay-per-call crypto payments at /api/v1/x402/... (no API key needed there) and requires X402_WALLET_ADDRESS. Disabled (the default) never affects normal API-key calls — the x402 route family simply doesn't exist and is omitted from discovery//openapi.json
X402_NETWORKeip155:84532 (Base Sepolia testnet); the public facilitator only settles this network for EVM. Any other network, e.g. eip155:8453 (Base mainnet), requires CDP_API_KEY_ID/CDP_API_KEY_SECRET below
X402_WALLET_ADDRESSEmpty; required 0x-prefixed EVM address that receives payments when X402_ENABLED=true
X402_FACILITATOR_URLhttps://x402.org/facilitator; must be an https URL
CDP_API_KEY_IDEmpty; Coinbase Developer Platform API key ID (Ed25519 Secret API Key). Required together with CDP_API_KEY_SECRET for any network besides Base Sepolia
CDP_API_KEY_SECRETEmpty; Coinbase Developer Platform API key secret. When both CDP vars are set, they take over as the facilitator (X402_FACILITATOR_URL is then ignored)
MCP_REMOTE_ENABLEDtrue; mounts the Streamable HTTP MCP transport at /mcp. false unmounts it entirely (404, and every manifest/llms.txt advertises stdio only) — a rollback switch with no code change
RAFID_LOGO_URLEmpty; logo_url in /.well-known/ai-plugin.json. Left blank rather than fabricated
RAFID_CONTACT_EMAILEmpty; contact_email in /.well-known/ai-plugin.json
RAFID_LEGAL_INFO_URLEmpty; legal_info_url in /.well-known/ai-plugin.json
USAGE_REPOSITORYconsole (default: one JSON line to stderr per call); memory (local inspection only, lost on restart); postgres (durable rafid_agent_usage table — requires DATABASE_URL, independent of AUTH_MODE)
RATE_LIMIT_ENABLEDtrue; per-process, IP-keyed rate limiting on discovery, x402 and remote MCP routes (three independent budgets). false disables it (development/tests)
RATE_LIMIT_WINDOW_MS60000 (one minute)
RATE_LIMIT_MAX60 requests per window, per IP, per route group
OMAN_PROPERTY_DATA_MODEmanual (default); database; composite. Which Oman property data source(s) analyze_oman_property actually queries — see "Production Oman market data" below
OMAN_MARKET_DATABASE_URLEmpty; falls back to DATABASE_URL when unset. Lets the market-data database be separate from the customer/billing database if desired
OMAN_MARKET_STALE_DAYS365; a comparable sample whose median age exceeds this is flagged staleMarketData: true and its confidence score is deterministically reduced. Independent of and shorter than comparables.ts's 540-day hard recency cutoff (which excludes a record from the pool entirely)
OMAN_MARKET_CACHE_TTL_MSEmpty/0 (disabled); when set, DatabaseOmanPropertyDataProvider caches a comparable-pool query in memory for this many milliseconds — see "Production Oman market data" below
OMAN_RECENT_SALES_DAYS730 (~2 years); the window analyze_oman_property's historicalSalesContext.recentComparableSales/recentMedianPricePerSqmOMR treat as "recent" — separate from, and never affecting, comparables.ts's fixed 540-day cutoff used for the current market estimate. See "Historical sales context" below
NCSI_API_BASE_URLhttps://map.ncsi.gov.om/ODPAPI (verified live base — see "Official market context (NCSI)" below)
NCSI_REQUEST_TIMEOUT_MS8000
NCSI_CACHE_TTL_MS21600000 (6 hours); 0 disables caching official context lookups
NCSI_REAL_ESTATE_DATASET_IDEmpty; no default — see "Official market context (NCSI)" below for why this must be operator-verified, never guessed
NCSI_FIELD_MAP_JSONEmpty; a JSON object mapping this capability's field names to the configured dataset's actual field names — see "Official market context (NCSI)" below
MARKET_DATA_INTERNAL_API_KEYEmpty; no default. Shared secret gating GET /api/v1/internal/market-data/status and .../partners — see "Partner Data Feed" below. Unset means those two routes always return 503, never a silently-open endpoint
PARTNER_FEED_STALE_DAYS7; a partner whose latest accepted record is older than this is reported stale: true by GET /api/v1/internal/market-data/partners — a per-partner monitoring signal only, never affecting ingestion, analyze_oman_property, or any other partner's staleness
VEHICLE_MARKET_DATA_MODEnone (default: no vehicle market-data provider — vehicle_value_estimate returns insufficient_market_data); database enables the vehicle_market_records provider
VEHICLE_MARKET_DATABASE_URLEmpty; falls back to DATABASE_URL
VEHICLE_MARKET_COUNTRIESEmpty (the database provider claims no market); comma-separated ISO alpha-2 codes the imported evidence covers, e.g. OM,AE, or *
VEHICLE_MARKET_PROVIDER_TIMEOUT_MS4000; per-provider timeout — a slow provider is reported as timeout, never blocks the request
VEHICLE_MARKET_CACHE_TTL_MS21600000 (6 hours); provider search-result cache (valuations themselves are never cached)
VEHICLE_MARKET_MAX_LISTING_AGE_DAYS365; older evidence is never used
MARKETCHECK_API_KEYEmpty; setting it enables the MarketCheck provider (US/Canada used-car dealer listings, licensed/paid). Never logged or returned
MARKETCHECK_PAGES2; result pages of 50 listings per market per uncached search (1–10)
VEHICLE_MARKET_FEEDS_JSONEmpty; JSON array of partner HTTPS feeds [{"id","url","countries","format","authHeaderEnv"}] — see "vehicle_value_estimate → Market-data providers"
VEHICLE_FX_SOURCESEmpty (no currency conversion); comma list tried in order per pair: ecb, exchangerate_api
EXCHANGERATE_API_KEYEmpty; uses ExchangeRate-API's keyed endpoint instead of the rate-limited open-access one
VEHICLE_FX_CACHE_TTL_MS21600000 (6 hours)
VEHICLE_VIN_DECODERnone (offline VIN checks only); nhtsa decodes VINs with NHTSA vPIC (sends the VIN to NHTSA)
VEHICLE_VIN_DECODER_TIMEOUT_MS3000

The app accepts pre-existing environment variables over .env. No live credentials are included. .env, dependencies, build output and npm cache are ignored by Git. Never commit or log CDP_API_KEY_SECRET, X402_WALLET_ADDRESS's private key (never requested or stored by this app), or DATABASE_URL.

Architecture

src/
  api/app.ts             Express application factory; no listener side effects
  api/agent.ts           Agent-marketplace discovery, pricing, tool-catalog and capability-registry builders
  api/manifest.ts        /agent.json, /.well-known/ai-plugin.json and /.well-known/agent.json builders
  api/llms-txt.ts        /llms.txt plain-text briefing builder
  api/landing.ts         Dependency-free HTML landing page for browsers
  api/openapi.ts         OpenAPI 3.1 built from shared Zod schemas
  domain/capabilities.ts The single AgentCapability registry every consumer above reads from
  domain/roadmap.ts      Planned-but-not-implemented future tools (metadata only, nothing callable)
  domain/financial.ts    Percentage/rounding helpers and maintenance defaults
  domain/oman/types.ts          Oman domain vocabulary (property types, furnished status, source types)
  domain/oman/locations.ts      Muscat area/wilayat normalization (English + Arabic aliases)
  domain/oman/comparables.ts    Comparable selection: size/bedroom/furnished tolerances, IQR outlier removal
  domain/oman/confidence.ts     Deterministic confidence scoring (never model-invented)
  domain/oman/dataProviders.ts  OmanPropertyDataProvider abstraction: Official (real NCSI context) + Listing (stub) + Manual + Database + Composite
  domain/oman/fixtures.ts       Curated/demo Muscat rental & sale benchmark dataset (clearly labeled, not live data)
  domain/oman/marketRepository.ts PropertyMarketRepository interface (engine-agnostic) + MemoryPropertyMarketRepository
  domain/oman/importPipeline.ts Validating CSV/JSON import pipeline for production market records
  domain/oman/dataQualityScore.ts Deterministic, non-LLM per-record data-quality scoring (Partner Data Feed)
  domain/oman/partners.ts       PropertyDataPartner/PartnerRepository abstraction + MemoryPartnerRepository (Partner Data Feed)
  domain/oman/cache.ts          ComparableCache interface + MemoryComparableCache (Redis-ready shape, not yet wired)
  domain/oman/config.ts         OMAN_PROPERTY_DATA_MODE / staleness threshold / cache TTL / NCSI / Partner Data Feed config readers
  domain/oman/officialContext.ts       OfficialMarketContext type + field-map-driven NCSI record adapter (never a hardcoded field name)
  domain/oman/officialContextCache.ts  OfficialMarketContextCache interface + MemoryOfficialMarketContextCache
  services/ncsi/ncsiClient.ts   NcsiClient: catalog discovery, dataset records, retries, timeout, normalized errors
  db/marketSchema.ts    PostgreSQL schema for property_market_records (independent migration ledger)
  db/marketStore.ts     PostgresPropertyMarketRepository: the production PropertyMarketRepository implementation
  db/partnerSchema.ts   PostgreSQL schema for data_partners + property_market_records partner columns (migration v2)
  db/partnerStore.ts    PostgresPartnerRepository: the production PartnerRepository implementation (shares its Pool with PostgresPropertyMarketRepository)
  api/marketDataRoutes.ts Partner Data Feed ingestion + internal status/health routes (never in the capability registry)
  middleware/partnerAuth.ts Per-partner bearer-token auth (X-Partner-Token) + internal shared-secret auth (X-Internal-Api-Key)
  marketImportCli.ts    `npm run market:import -- <file> [PARTNER_ID]` CLI entry point
  ncsiDiscoverCli.ts    `npm run ncsi:discover` CLI: lists NCSI's live catalog and flags real-estate-relevant datasets
  schemas/              Strict shared input and output contracts
  services/property.ts  Validated calculations shared by both transports
  services/omanProperty.ts Deterministic Oman property-analysis pipeline (analyze_oman_property)
  middleware/auth.ts    API-key authentication
  middleware/rateLimit.ts In-memory, IP-keyed rate limiter for public agent endpoints
  billing/catalog.ts    Indicative prices and injectable authorization boundary (single source of truth)
  billing/service.ts    BillingService: the only place a price is read; usage recording
  billing/usage.ts      UsageRepository abstraction (Console/Memory/Postgres implementations)
  billing/x402.ts       Real x402 payment middleware + always-on x402 info builder
  config/env.ts         Validated environment configuration
  utils/                Safe errors and metadata-only logging
  mcp/server.ts         MCP registration factory (shared by both transports)
  mcp/remote.ts         Remote Streamable HTTP MCP handler (/mcp) + /api/v1/mcp/status builder
  mcp.ts                Local stdio entry point
  server.ts             HTTP listener and shutdown
  calculators.ts        Compatibility re-exports

Services validate direct calls as well as transport calls. New capabilities belong in the shared catalog with input/output schemas and a service function; the catalog drives REST, MCP and OpenAPI. No calculation logic lives in the HTTP or MCP handlers, and no pricing logic lives in the calculation services — every price is read through BillingService, which reads billing/catalog.ts.

Capability registry

src/domain/capabilities.ts exports one array, capabilities: AgentCapability[], and that array is the only place a tool's name, path, description, whenToUse/useCases (the recommendation-layer hint for which tool answers which situation), input/output schema, example, price, currency, payment protocol, idempotency and side-effect flag are written. Every consumer below reads this array rather than holding a second copy:

  • REST route registration (api/app.ts) — both the X-API-Key and x402 route families
  • The OpenAPI document (api/openapi.ts) — operationId is the capability's own name (analyze_property, compare_properties, estimate_maintenance, analyze_oman_property), and request/response examples come from capability.example/capability.execute(capability.example)
  • The MCP server (mcp/server.ts) — tool name, schemas, and a description built from capability.description + capability.whenToUse
  • The x402 payment gate and BillingService (billing/x402.ts, billing/service.ts) — billing/catalog.ts's prices object is derived from capabilities (Object.fromEntries(capabilities.map(c => [c.name, c.price]))), never a second literal
  • Every agent-marketplace/discovery endpoint (/api/v1/agent, /api/v1/pricing, /api/v1/tools, /api/v1/capabilities, /agent.json, the two /.well-known/... manifests, /llms.txt)

Adding a capability to this one array is what makes it real everywhere at once; nothing else needs to be told about it separately. src/domain/roadmap.ts holds a separate, much smaller list (plannedCapabilities) of future tools — estimate_property_rent, analyze_lease, check_contract_risk, diagnose_maintenance_issue, estimate_repair_cost, generate_property_report — that are metadata only: no route, no MCP registration, no schema, no price, nothing callable. They exist so /agent.json and /llms.txt can tell an agent what's coming without it mistaking a name on a list for a working endpoint; promoting one to a real capability means adding a full entry to capabilities, the only registry that drives actual behavior (analyze_oman_property was itself on this list until this phase; it now has a full entry and has been removed from plannedCapabilities).

REST API

REST base: http://localhost:8787.

MethodEndpointAuth
GET/Public — HTML landing page (browsers) or JSON discovery (Accept: application/json)
GET/agent.jsonPublic — full agent manifest (protocols, x402 terms, complete tool catalog)
GET/.well-known/ai-plugin.jsonPublic — OpenAI-plugin-style manifest
GET/.well-known/agent.jsonPublic — A2A-style Agent Card
GET/llms.txtPublic — plain-text briefing for LLM-based agents (text/plain)
GET/api/v1/capabilitiesPublic — machine-first capability registry (schemas, pricing, when to use)
GET/api/v1/agentPublic — legacy agent-marketplace metadata (superseded by /agent.json)
GET/api/v1/pricingPublic — full pay-per-call price list
GET/api/v1/toolsPublic — legacy tool catalog (superseded by /api/v1/capabilities)
GET/api/v1/x402Public — x402 protocol/pricing info (always available, independent of X402_ENABLED)
GET/api/v1/x402/statusPublic — factual runtime status: enabled, mode, network, asset, facilitator, walletConfigured, paymentEnforcement
POST/api/v1/property/analyzeX-API-Key
POST/api/v1/property/compareX-API-Key
POST/api/v1/maintenance/estimateX-API-Key
POST/api/v1/oman/property/analyzeX-API-Key
POST/api/v1/risk/company-reputation-checkX-API-Key (x402: /api/v1/x402/risk/company-reputation-check; L402: /api/v1/l402/risk/company-reputation-check)
POST/api/v1/risk/business-risk-scoreX-API-Key (x402: /api/v1/x402/risk/business-risk-score; L402: /api/v1/l402/risk/business-risk-score; MPP: /api/v1/mpp/charge/business_risk_score)
POST/api/v1/documents/facts-extractX-API-Key (x402: /api/v1/x402/documents/facts-extract; L402: /api/v1/l402/documents/facts-extract; MPP: /api/v1/mpp/charge/document_facts_extract) — 1 MB JSON body limit
POST/api/v1/finance/invoice-anomaly-checkX-API-Key (x402: /api/v1/x402/finance/invoice-anomaly-check; L402: /api/v1/l402/finance/invoice-anomaly-check; MPP: /api/v1/mpp/charge/invoice_anomaly_check) — 1 MB JSON body limit
POST/api/v1/automotive/vehicle-value-estimateX-API-Key (x402: /api/v1/x402/automotive/vehicle-value-estimate; L402: /api/v1/l402/automotive/vehicle-value-estimate; MPP: /api/v1/mpp/charge/vehicle_value_estimate)
POST/api/v1/intelligence/research-companyX-API-Key
POST/api/v1/intelligence/find-companiesX-API-Key
POST/api/v1/intelligence/analyze-company-riskX-API-Key
POST/api/v1/procurement/oman-supplier-checkX-API-Key
GET/api/v1/healthPublic liveness
GET/openapi.jsonPublic raw OpenAPI document
POST/api/v1/market-data/importX-Partner-Token (Partner Data Feed — infrastructure, not an agent capability; see below)
GET/api/v1/internal/market-data/statusX-Internal-Api-Key (Partner Data Feed; see below)
GET/api/v1/internal/market-data/partnersX-Internal-Api-Key (Partner Data Feed; see below)

Legacy /v1/... POST routes and /health remain available. Response migration: legacy routes now return the same envelope as canonical routes; read results from response.data. Unknown fields are rejected, names must be unique after trimming, and unauthenticated startup is no longer allowed.

Success:

{
  "success": true,
  "data": {},
  "meta": { "requestId": "server-generated-uuid" }
}

A tool call (the four POST capabilities, on either the API-key or x402 route family) additionally enriches meta with the tool name, its price and its currency, so an agent can confirm what it was charged without a second lookup:

{
  "success": true,
  "data": { "grossYield": 8.47 },
  "meta": { "requestId": "server-generated-uuid", "tool": "analyze_property", "price": 0.01, "currency": "USD" }
}

Error:

{
  "success": false,
  "error": {
    "code": "INVALID_INPUT",
    "message": "Input validation failed",
    "details": [{ "path": "propertyValue", "message": "Property value must be at least 0.01" }]
  },
  "meta": { "requestId": "server-generated-uuid" }
}

Responses include X-Request-ID. Codes cover 400 validation/malformed JSON, 401 authentication, 404 unknown endpoints, 413 bodies over 32kb, 415 unsupported content type/encoding, 500 sanitized internal failures, and 429 for a configured rate-limit adapter. PostgreSQL mode enforces customer minute limits and monthly quotas; env-key development mode has no default limiter. /openapi.json intentionally returns the raw specification for discovery tools.

Agent discovery, pricing and tool catalog
curl -s https://api.rafidsystem.com/api/v1/agent
{
  "success": true,
  "data": {
    "name": "Rafid Intelligence Network",
    "description": "Property and facility intelligence tools for AI agents",
    "version": "0.1.0",
    "mcp": true,
    "docs": "/docs",
    "openapi": "/openapi.json",
    "health": "/api/v1/health",
    "pricing": "/api/v1/pricing",
    "tools": "/api/v1/tools",
    "x402": "/api/v1/x402",
    "x402Enabled": false,
    "endpoints": ["/api/v1/property/analyze", "/api/v1/property/compare", "/api/v1/maintenance/estimate", "/api/v1/oman/property/analyze"]
  },
  "meta": { "requestId": "..." }
}

GET /api/v1/pricing returns { "currency": "USD", "model": "pay-per-call", "tools": { "analyze_property": 0.01, "compare_properties": 0.03, "estimate_maintenance": 0.02, "analyze_oman_property": 0.25 } } — the exact same object billing/catalog.ts defines, with nothing recomputed or duplicated. GET /api/v1/tools returns one entry per capability with a full JSON Schema for its input and a derived output summary, enough for an agent to construct a valid call without reading any documentation.

Landing page

Human visitors to / get a small, dependency-free HTML page (no build step, no external assets) led by the agent-native positioning ("Property intelligence built for AI agents. Discover. Pay per call. Execute."), an "Agent Integration" section with copyable MCP/x402/OpenAPI/manifest examples, the tool list and pricing, and links to /docs, /openapi.json and /api/v1/health. API-key/REST access is documented as a compatibility option, not the primary message. This is purely a presentation layer over the same data the JSON endpoints return; nothing about the API's behavior changes. Any client that sends Accept: application/json (including a plain fetch()/curl with an explicit header, or any existing integration) is unaffected and keeps receiving the JSON discovery payload it always has.

Property analysis

PowerShell (replace the key placeholder):

$headers = @{ 'X-API-Key' = '<your-api-key>' }
$body = @{ propertyValue = 85000; annualRent = 7200; serviceCharge = 650; maintenanceCost = 400 } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri http://localhost:8787/api/v1/property/analyze -Headers $headers -ContentType application/json -Body $body

The resulting data includes grossYield: 8.47, netYield: 7.24, annualOperatingCost: 1050, and annualNetIncome: 6150.

Optional inputs: serviceCharge, maintenanceCost, otherAnnualCosts, vacancyRatePct (0–100). The old maintenance alias is accepted, but cannot be supplied alongside maintenanceCost.

Formulas:

  • Gross annual income = annualRent.
  • Effective annual rent = annualRent × (1 − vacancyRatePct / 100).
  • Operating cost = serviceCharge + maintenanceCost + otherAnnualCosts.
  • Net annual income = effective annual rent − operating cost.
  • Gross yield = annualRent / propertyValue × 100.
  • Net yield = net annual income / propertyValue × 100.
  • Simple payback = propertyValue / net annual income; null for nonpositive income or a number too large to represent safely.

All money is OMR. Outputs are rounded to two decimals using JavaScript numbers, preserving the original MVP convention (not a settlement ledger). Finite input amounts are bounded to 1e12; property value must be at least 0.01. Negative net income is valid. Legacy output fields grossYieldPct, netYieldPct, and annualOperatingCosts remain. Financing, taxes, transaction fees and appreciation are excluded.

Property comparison
$body = @{ properties = @(
  @{ name = 'A'; propertyValue = 85000; annualRent = 7200 },
  @{ name = 'B'; propertyValue = 100000; annualRent = 7000 }
) } | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri http://localhost:8787/api/v1/property/compare -Headers $headers -ContentType application/json -Body $body

Requires 2–20 properties with unique, nonblank names (up to 120 characters). Returns calculated properties and sortedByNetYield names, descending by rounded net yield, retaining input order for ties. This ordering is a metric comparison, not investment advice.

Maintenance estimate
$body = @{ propertyValue = 100000; ageYears = 12; units = 1 } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri http://localhost:8787/api/v1/maintenance/estimate -Headers $headers -ContentType application/json -Body $body

This example returns annual maintenance 1300 OMR, monthly reserve 108.33 OMR and maintenance percentage 1.3, plus assumptionsUsed.

Default annual rates: age below 5 → 0.6%; 5–under 10 → 0.9%; 10–under 20 → 1.3%; 20+ → 1.8%. Defaults: age 0, units 1. Additional units add 35 OMR/year each. Supported ages are 0–200 and units 1–10000.

annual estimate = propertyValue × annualRatePct / 100
                + (units − 1) × additionalUnitCost

Override assumptions per request with "assumptions": { "annualRatePct": 1.5, "additionalUnitCost": 50 }. The returned percentage includes the additional unit allowance. Legacy annualRent is accepted but unused. Property type and area are not modeled yet and are rejected as unknown fields. This preserves the existing heuristic without inventing unsupported local cost factors; it requires calibration before commercial estimates.

Oman property analysis (Muscat)

analyze_oman_property ($0.25/call) is the first Rafid capability whose value comes from local market evidence, not just arithmetic over the caller's own numbers. It answers "is this Muscat property reasonably priced as a rental investment?" with structured, sourced evidence — never a hard-coded "good"/"bad investment" verdict.

$body = @{ governorate = 'Muscat'; area = 'Al Mouj'; propertyType = 'apartment'; bedrooms = 2; sizeSqm = 130; askingPriceOMR = 118000 } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri http://localhost:8787/api/v1/oman/property/analyze -Headers $headers -ContentType application/json -Body $body

Required: governorate, area, propertyType (apartment | villa | townhouse), sizeSqm, askingPriceOMR. Optional: wilayat, bedrooms, bathrooms, furnished (furnished | semi_furnished | unfurnished), optionalAnnualServiceChargeOMR, optionalAnnualMaintenanceOMR.

Coverage (MVP): Muscat governorate only. Supported areas: Al Mouj, Muscat Hills, Qurum, Bausher, Azaiba, Al Khuwair, Madinat Al Irfan, Ghubrah (domain/oman/locations.ts's MUSCAT_AREAS). English and common Arabic spellings both resolve to the same canonical area (e.g. الموج / Al Mouj / the wave → Al Mouj) — deliberately without fuzzy/phonetic matching, so an unlisted place name is reported matchType: "unmatched" rather than guessed at.

Comparable selection (domain/oman/comparables.ts): never compares across property type; filters by area + property type + a ±20% size tolerance (SIZE_TOLERANCE_PCT) to build the reported comparableCount, then narrows to an exact bedroom match and exact furnished-status match where at least 3 (MIN_COMPARABLES) exist, relaxing to ±1 bedroom or "any furnished status" only when the exact match is too small (reported via bedroomToleranceApplied/furnishedFilterRelaxed and surfaced in riskFlags/assumptions); records older than 540 days are excluded outright as stale before any of this; a standard 1.5×IQR rule then removes statistical outliers from what remains (sampleSizeUsed). Rents quoted annually are normalized to a monthly figure before any comparison.

Confidence (domain/oman/confidence.ts): a fixed, documented weighted formula over sample size, data freshness, size/bedroom similarity to the subject, and price dispersion (coefficient of variation) — never a model-invented number. Below MIN_COMPARABLES used comparables, confidence is always { score: 0, level: "insufficient" }.

Fallback behavior: below MIN_COMPARABLES comparables (rental side) or an unsupported governorate/area, insufficientMarketData: true is returned, unavailableOutputs lists exactly which fields were withheld (e.g. market.estimatedMonthlyRentOMR, investment.grossYieldPct), and deterministic figures that need no market data (pricePosition.askingPricePerSqmOMR, investment.estimatedOperatingCostOMR) are still computed. Nothing is ever fabricated to fill a gap.

Rafid does not scrape property websites, and never will as a way of sourcing this data. OfficialOmanDataProvider and ListingDataProvider (domain/oman/dataProviders.ts) are real, wired-in seams for a future official/licensed feed — they return empty today rather than faking or scraping data. Production market data is expected to come from one of: official government/statistical sources, a licensed real-estate data vendor, a partner data-sharing agreement, or a customer's own listing/transaction export. All of these reach the analysis pipeline the same way — as rows imported into the property_market_records table via the import pipeline below — so no scraping code exists or is planned anywhere in this codebase.

Provenance: every response includes a provenance array — { sourceType, sourceName, sourceDate, recordCount } — so a caller can tell official_statistics, listing_asking_price, partner_feed and manual_benchmark apart, and can tell an asking price from a confirmed completed-transaction price. The bundled ManualDatasetProvider (domain/oman/fixtures.ts) is a small, explicitly curated benchmark dataset, clearly labeled in every sourceName as demo/MVP data — never official statistics and never confirmed transaction prices. riskFlags includes demo_dataset_not_live_market_data only when a manual_benchmark record actually contributed to the comparables used for that specific answer — it is computed per-response, not hard-coded, so an answer built entirely from database-backed production records (official statistics, a licensed listing feed, or a partner feed) does not carry it.

Data freshness: every response includes a dataQuality block — { latestDataDate, dataFreshnessDays, sampleSize, sourceTypes, staleMarketData } — computed from whichever comparables actually drove the answer, even when the sample is too small to produce a full result (freshness is never hidden). dataFreshnessDays beyond OMAN_MARKET_STALE_DAYS (default 365 days) sets staleMarketData: true, adds a stale_market_data risk flag, and applies a fixed, documented penalty inside the confidence formula (domain/oman/confidence.ts) — so an agent consuming this API can decide how much to trust an answer from dataQuality/confidence/riskFlags alone, without needing to inspect raw records.

Output shape: normalizedLocation, subjectProperty, market (estimated monthly/annual rent range, comparable/sample counts, data freshness), investment (gross/net yield, operating cost, net income), pricePosition (asking price/sqm vs. the observed sale-comparable range, below_market/at_market/above_market/insufficient_data), comparablesSummary (rent-per-sqm low/median/high of the sample actually used), dataQuality, historicalSalesContext, riskFlags, confidence, provenance, assumptions, insufficientMarketData, unavailableOutputs. Strict output schema in schemas/omanOutputs.ts.

Historical sales context: every response also includes historicalSalesContext — { available, recordsAvailable, recentComparableSales, medianHistoricalPricePerSqmOMR, recentMedianPricePerSqmOMR, oldestRecordDate, latestRecordDate, sourceTypes, priceSemantics, phaseBreakdown } — a separate, additive field surfacing long-term completed-sale history (e.g. a multi-year partner feed spanning 2006-2026) alongside, but never mixed into, market/pricePosition/comparablesSummary/confidence above, which stay driven exclusively by the current-comparable pool (comparables.ts's 540-day MAX_DATA_AGE_DAYS cutoff, unchanged). recordsAvailable/oldestRecordDate/latestRecordDate/medianHistoricalPricePerSqmOMR are computed with real database aggregation (PropertyMarketRepository.getHistoricalSaleStatistics()) rather than reusing the capped, most-recent-first candidate pool findSaleComparables() returns, so they stay accurate for a history longer than that cap. recentComparableSales/recentMedianPricePerSqmOMR are a genuinely matched subset — area, property type, size similarity (±20%) and bedrooms where available — within the configurable OMAN_RECENT_SALES_DAYS window (default 730 days), computed by comparables.ts's selectRecentSaleRecords(); this is a deliberately separate selection path from selectComparables(), both because the recency window differs and because a completed-sale record routinely has no recorded furnished status (true of every partner-feed sale record observed so far) — selectComparables() requires one and would otherwise silently report zero recent sales for exactly this data. phaseBreakdown (only relevant to a phased-development partner feed like Al Mouj's) lists per-phase { phaseName, recordCount, medianPriceOMR, medianPricePerSqmOMR, oldestRecordDate, latestRecordDate }, but only for phases meeting the same MIN_COMPARABLES sample-size floor used elsewhere — a phase trend is never reported from an insufficient sample — capped to the busiest 10 phases. available: false (with every other field at its empty default) whenever the location is unsupported or the active provider has no genuine transaction-level record store for it (manual/demo mode, official statistics, the not-yet-integrated listing feed) — demo/benchmark data is never presented as real historical sales intelligence. priceSemantics reports only what the source data itself recorded (e.g. "contracted_unit_price") — never verified_conveyance_price, government_registered_transaction or final_title_transfer_price unless the source genuinely supports that claim.

Rafid Agent Intelligence: company research, discovery and risk (research_company, find_companies, analyze_company_risk)

Phase 1 of a broader expansion beyond Oman property/business data: three capabilities that gather evidence about a company from the open web (plus, for identity, Rafid's own Oman company registry data) rather than computing purely from caller-supplied numbers. All three are entirely inert by default — no external network call, no cost — until an operator explicitly configures a provider; see src/intelligence/config.ts and the block of WEB_SEARCH_PROVIDER/TAVILY_API_KEY/INTELLIGENCE_LLM_PROVIDER/ANTHROPIC_API_KEY/INTELLIGENCE_LLM_MODEL/RISK_LIVE_CHECKS_ENABLED/INTELLIGENCE_CACHE_TTL_MS/RISK_CACHE_TTL_MS variables in .env.example. With nothing configured, every one of these still returns a fully schema-valid, honest dataMode: "not_configured" result — an empty/null shape, never a fabricated one — which is also what makes them safe to exercise from the generic per-capability test loops (tests/http.test.ts/tests/mcp.test.ts/tests/x402.test.ts, which call capability.execute(capability.example) with no way to inject a mock network dependency).

  • research_company (POST /api/v1/intelligence/research-company, $0.15/call) — a structured research brief (overview, products/services, leadership, funding, competitors, technology signals, recent developments, risk flags) built from public web search results (src/intelligence/webSearch/), optionally passed through an LLM synthesis step (src/intelligence/synthesis/) to extract structured fields — every synthesized field is schema-validated before being returned, and any fact the evidence doesn't support is null/empty rather than guessed. Every response carries sources, confidence, dataFreshness and limitations; caching is by normalized company/website/country/depth/focusAreas (src/intelligence/companyResearch/provider.ts).
  • find_companies (POST /api/v1/intelligence/find-companies, $0.05/call) — discovers candidate companies from public web search results matching industry/location/size/keyword criteria. Never fabricates a company to fill a requested limit: without an LLM synthesis provider configured, no structured company records are extracted at all (only raw cited sources are returned) rather than risk inventing one. Results are capped internally at 20 per request regardless of the requested limit (up to 100); requestedLimit vs appliedLimit in the response makes that gap explicit (src/intelligence/companyDiscovery/provider.ts).
  • analyze_company_risk (POST /api/v1/intelligence/analyze-company-risk, $0.35/call) — gathers evidence-tiered risk signals across up to 8 checks (corporate_identity, domain, website, sanctions, adverse_news, security_signals, reputation, legal_signals) and never returns a safe/unsafe verdict — only evidence, each item explicitly tiered confirmed_evidence / public_allegation / automated_indicator / missing_information, for the calling agent or a human to weigh. corporate_identity reuses search_oman_company's own service function directly (no second lookup implementation) and is the only check performed by default; domain/website/sanctions are free public-source checks (RDAP, the public OFAC/Consolidated Screening List) gated behind RISK_LIVE_CHECKS_ENABLED because they make a real outbound call; adverse_news/reputation/legal_signals are web-search-backed and require a web search provider. The website check reuses domain/oman/feedSecurity.ts's and safeFeedFetch.ts's SSRF protections (private/loopback IP blocking, DNS-rebinding-safe resolution, validated redirects) unchanged — the same protection the Production Feed Runner relies on for partner-supplied URLs (src/intelligence/risk/provider.ts, src/intelligence/risk/checks/).

Cost/margin visibility (internal only): src/intelligence/costEstimator.ts holds Rafid's own documented, hand-estimated upstream cost assumptions per capability (never a provider's actual confidential pricing) plus a per-process running counter of real provider calls actually made; GET /api/v1/internal/revenue/unit-economics (same REVENUE_INTERNAL_API_KEY gate as the rest of revenueRoutes.ts — no new secret, no new dashboard) exposes revenuePerCallUSD/estimatedProviderCostPerCallUSD/estimatedLlmCostPerCallUSD/estimatedGrossMarginPerCallUSD per priced capability. This is a gross, not net, margin — it excludes hosting, database and facilitator/network fees — and is never exposed on any public endpoint.

Provider architecture: WebSearchProvider (src/intelligence/webSearch/provider.ts) and IntelligenceSynthesizer (src/intelligence/synthesis/synthesizer.ts) are both small interfaces with a NotConfigured* default and one real implementation each (Tavily for search, Anthropic for synthesis) — mirroring business-data/sources/provider.ts's own provider-interface pattern — so swapping either vendor later means writing one new class, never touching a capability's schema, route, MCP registration or pricing.

Procurement: Oman supplier screening (oman_supplier_check, $0.50/call)

POST /api/v1/procurement/oman-supplier-check (API key) · POST /api/v1/x402/procurement/oman-supplier-check (x402, $0.50) · MCP tool oman_supplier_check. For procurement agents that need to screen an Oman supplier before requesting a quotation. It is public-source procurement screening — not KYC/AML, not legal verification, not vendor approval — so it answers with identityConfirmed, procurementSuitability (appears_suitable / review_recommended / insufficient_information / potential_risk) and sanctions potential_match, never "verified", "approved" or "sanctioned".

Input: companyName (required), optional crNumber, website, email, phone, address, requiredProductOrService. Inputs are normalized deterministically (src/supplier-check/normalize.ts): the registry's own company-name normalizer, a small Arabic→English company-word glossary (ش.م.م → LLC, للتجارة → TRADING, ...), Oman phone numbers to E.164, URLs/domains (incl. co.om-style registrable domains), free-mail detection and CR cleanup.

Checks, each assessed independently (src/supplier-check/analysis/): company identity (strong/moderate/weak/unconfirmed, scored from name match + CR + contact corroboration × source trust — demo data can never reach "confirmed"), business-activity relevance (deterministic EN/AR keyword taxonomy; pass/partial/fail/unknown), website (SSRF-safe fetch of the supplied or registry website: name on site, domain vs name/registry, corporate email, phone, address, redirects), contact consistency (email/website/phone vs registry and website; lookalike-domain detection; a Gmail/Outlook address alone is only a low note), address consistency (pass/partial/conflict/unknown vs registry governorate/wilayat/area), sanctions (provider-based: UN Security Council Consolidated List and US Consolidated Screening List incl. OFAC SDN, with a conservative central fuzzy matcher — ≥0.88 similarity and ≥60% distinctive-token overlap; exact-name matches are still only potential_match), and public-risk signals (web results must name the supplier next to a risk term; derived lookalike-domain / contradictory-CR indicators; neutral "potential public-risk signal detected" wording).

Risk model (src/supplier-check/scoring.ts, fully documented in code and returned per call as riskModel.components): adverse evidence adds points (e.g. exact-name sanctions match +60, CR registered to another company +35, fuzzy sanctions match +35, lookalike domain +25, activity mismatch +15, email-domain mismatch +10); missing information adds no risk points and instead lowers confidence (weighted evidence coverage). score ≥45 → high, ≥20 → medium, else low; thin coverage → insufficient_data unless the score is already high.

Data sources and reuse: identity resolves through the existing canonical Oman company registry (getOmanCompanyDataProvider() — same OMAN_BUSINESS_DATA_MODE provider, rankCompanies, mergeCompanyRows and provenance as search_oman_company). No company records are created. Website and sanctions checks reuse RISK_LIVE_CHECKS_ENABLED; public-web signals reuse WEB_SEARCH_PROVIDER. With nothing enabled, those checks report not_checked honestly.

Caching & idempotency: each provider's evidence is cached independently in rafid_supplier_evidence (PostgreSQL when SUPPLIER_EVIDENCE_DATABASE_URL/DATABASE_URL is set — one CREATE TABLE IF NOT EXISTS, upsert on (provider_id, evidence_key); in-memory otherwise). Default TTLs: identity 7 days, website 3 days, sanctions 12 hours, public web 1 day (override with SUPPLIER_*_CACHE_TTL_MS). Only successful evidence is cached (outages are retried), and the final risk result is never cached — it is recomputed from evidence on every call. This is an evidence cache, not a second company/source-evidence model: it is never merged into oman_companies.

Risk intelligence: global company reputation check (company_reputation_check, $0.40/call)

POST /api/v1/risk/company-reputation-check (API key) · POST /api/v1/x402/risk/company-reputation-check (x402, $0.40) · POST /api/v1/l402/risk/company-reputation-check (L402, when enabled) · MCP tool company_reputation_check. Category risk_intelligence, idempotent. For an agent that must look into a company in any country before signing a contract, onboarding a vendor, sending money, partnering, investing or approving a marketplace seller. It is evidence-first: every signal cites evidence ids, every evidence item carries its source, authority tier, retrieval time and relevance. It never labels a company "safe", "legitimate", "fraudulent" or "sanctioned". It is not a legal, KYC/AML, credit or compliance determination and does not guarantee legitimacy.

Input: companyName (required); strongly recommended country (ISO-2/ISO-3/English name); optional website, domain, registrationNumber, lei (checksum-validated), legalName, city, industry. Strict schema: unknown fields, unknown countries, invalid LEIs and a website/domain pair that disagree are rejected with 400 INVALID_INPUT — which also means no x402 settlement and no L402 token burn.

curl -X POST http://localhost:8787/api/v1/risk/company-reputation-check \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"companyName":"Example Technologies Ltd","country":"United Kingdom","website":"https://example.com"}'

Output (abridged): company, resolution (status resolved|probable|ambiguous|unresolved|registry_not_checked, confidence, candidates), reputationScore 0-100, confidenceScore 0-100, trustLevel (insufficient_evidence / significant_concerns / some_concerns / no_major_concerns_found / favorable_public_signals), scores per dimension plus scoreBreakdown (weight, raw score, coverage) and confidenceBreakdown, identity, sanctions (possible vs high-confidence matches, lists checked/unavailable), adverseMedia (events with category, legalStage, established, coverage count, sources), customerSentiment, onlinePresence, legalRiskSignals, businessStabilitySignals, cyberDomainSignals, transparencySignals, positiveSignals, redFlags, evidenceSummary, evidence[], coverage.providers[] (per-provider status incl. not_configured / timeout / stale_cache), warnings[] (COMPANY_NOT_RESOLVED, AMBIGUOUS_COMPANY, INSUFFICIENT_EVIDENCE, PROVIDER_UNAVAILABLE, PROVIDER_TIMEOUT, RATE_LIMITED, DATA_STALE, PROVIDER_NOT_CONFIGURED, COUNTRY_NOT_PROVIDED), limitations, summary, methodology. The registry's documented example (/api/v1/capabilities, OpenAPI) is the real pipeline run over an explicitly synthetic scenario (fictional company, .example publishers) — regenerate with node --import tsx scripts/generateCompanyReputationExample.ts.

Pipeline (src/company-reputation/): input validation → normalization (normalization.ts: global legal forms, ISO countries, domains, URLs, registration numbers, LEI) → providers run concurrently, isolated, time-boxed (providers/runner.ts: per-provider timeout, one retry for free idempotent sources only, never for paid searches) with a cache-first hybrid lookup → evidence dedup and syndication grouping (deduplication.ts) → entity resolution (companyResolver.ts) → relevance filtering of third-party items → analyzers (analyzers/*: identity, sanctions, adverse media, customer sentiment, online presence, business stability, cyber/domain, transparency) produce signals → scoring.ts → confidence.ts. Providers only collect normalized evidence; analyzers never fetch; scoring only sees signals.

Scoring (config.ts, version crc-1.0.0): weights identity 20%, legal/regulatory 20%, adverse media 15%, customer reputation 15%, online presence 10%, business stability 10%, cyber/domain 5%, transparency 5%. Per dimension: signal points = severity points (info 3, low 8, medium 20, high 40, critical 80) × strength (authority × relevance × bounded corroboration), saturating (swing × (1 − e^(−points/40))) so many weak items cannot add up linearly and one weak item moves a dimension only a few points. Each dimension is then shrunk toward a neutral prior of 50 by its evidence coverage: score = 50 + coverage × (raw − 50). Missing data therefore reads as "unknown" (50) with low confidence, never as "good". Documented caps: a high-confidence sanctions match caps the score at 20; an established conviction/judgment for fraud, corruption, sanctions or criminal conduct from an official or major source caps it at 35.

Confidence (separate from reputation): 30% entity-resolution confidence, 20% weighted dimension coverage, 15% independent sources (event groups, not articles), 10% source authority, 10% freshness (stale cache and old coverage count less), 10% evidence diversity, 5% jurisdiction-registry coverage; −3 per provider outage; capped at 40 when identity is ambiguous and 50 when it is unresolved. 78/91 and 78/34 mean very different things.

Safety properties (tested): a similar sanctioned name is not a match (shared conservative matcher from src/shared/sanctions/, ≥0.88 similarity and ≥60% token overlap); an exact name without a corroborating country/registration identifier is only possible; one anonymous complaint cannot cause a major penalty; ten syndicated copies are one event; missing data and provider timeouts are never negative signals; allegations stay allegations (legal stage is taken only from explicit source wording); same-name companies in other jurisdictions (different country, different legal form in the text) are excluded, and with an ambiguous identity only identifier-backed items are attributed. External text is sanitized and instruction-like text is removed; nothing is sent to an LLM.

Providers:

ProviderCategoryEnabled byCost
GLEIF Global LEI Index (global, official)registryRISK_LIVE_CHECKS_ENABLED=truefree
UK Companies Houseregistry (GB only)COMPANIES_HOUSE_API_KEYfree key
Rafid Oman company registryregistry (OM only)OMAN_BUSINESS_DATA_MODE=database/composite (demo data never used)own DB
UN Security Council Consolidated ListsanctionsRISK_LIVE_CHECKS_ENABLED=truefree
US Consolidated Screening List (incl. OFAC SDN)sanctionsRISK_LIVE_CHECKS_ENABLED=true (+ optional SANCTIONS_CSL_API_KEY)free
EU Financial Sanctions FilessanctionsEU_SANCTIONS_LIST_URL (tokenised URL issued by the EU)free
OpenSanctions (UN/EU/UK/US and national lists)sanctionsOPENSANCTIONS_API_KEYpaid licence for commercial use
News / adverse media (2 searches)newsWEB_SEARCH_PROVIDER=tavily + TAVILY_API_KEY~$0.016
Review platforms / forums (1 search)reviewssame as news~$0.008
Company website (1 page, SSRF-safe)websiteRISK_LIVE_CHECKS_ENABLED=truefree
RDAP domain registrationdomainRISK_LIVE_CHECKS_ENABLED=truefree

Any provider can be switched off with COMPANY_REPUTATION_DISABLED_PROVIDERS (ids or categories). With nothing enabled the call still returns a valid, clearly limited result (trustLevel: insufficient_evidence).

Cache: provider evidence is cached per (provider, normalized company identity) in rafid_company_evidence_cache (PostgreSQL via COMPANY_REPUTATION_DATABASE_URL or DATABASE_URL, one CREATE TABLE IF NOT EXISTS; in-memory otherwise). TTLs: registry 7d, sanctions 12h, news 1d, reviews 3d, website 3d, domain 7d (COMPANY_REPUTATION_*_TTL_MS). Cached evidence keeps its original retrieval time (fetchedAt/observedAt, fromCache: true). If a refresh fails, expired evidence up to COMPANY_REPUTATION_MAX_STALE_MS (default 14 days) is served flagged stale_cache + DATA_STALE and lowers confidence. Outages are never cached, and the final score is never cached.

Unit economics: worst case uncached ~3 web searches (≈ $0.024 at Tavily-class pricing); everything else is free public data. Estimated gross margin at $0.40 ≈ $0.37/call before hosting and facilitator fees. OpenSanctions, if enabled, adds its own per-call licence cost. Per-provider calls, cache hits, stale serves, outages and latency are recorded in-process (src/company-reputation/telemetry.ts) and returned by the internal GET /api/v1/internal/revenue/unit-economics route (companyReputationTelemetry).

Premium decision API: company_due_diligence ($1.50/call)

POST /api/v1/risk/company-due-diligence · MCP tool company_due_diligence. This is the normalized decision layer over the existing business_risk_score evidence pipeline: it resolves the company, evaluates registration, website identity, sanctions, adverse news, legal, financial and reputation signals, then returns a deterministic riskScore/riskLevel, confidence, provenance, a recommendation and a machine-actionable decision.action.

Request fields are company (required), plus optional domain, country, registrationNumber, lei, purpose, depth (quick, standard, enhanced, default standard) and a strict checks object (registry, sanctions, adverseMedia, reputation, businessRisk, all defaulting to true). The capability costs $1.50 USD and uses the shared registry/payment rails; it is automatically present in REST, MCP, x402/L402/MPP/account billing where enabled, OpenAPI and discovery manifests.

Example:

curl -X POST https://<host>/api/v1/risk/company-due-diligence \
  -H "Content-Type: application/json" -H "X-API-Key: <key>" \
  -d '{"company":"Example Ltd","domain":"example.com","country":"GB","purpose":"supplier_onboarding"}'

The free preview only reports recognized input, source coverage and available result sections. It never returns the paid score, sanctions result, red flags, recommendation or decision. If the entity is ambiguous or cannot be reliably resolved, the paid request returns AMBIGUOUS_ENTITY or ENTITY_NOT_FOUND rather than guessing; unavailable providers lower coverage/confidence and remain explicit in the result.

Risk intelligence: global business risk score (business_risk_score, $0.50/call)
business_risk_score
$0.50 / call
Global business due-diligence and risk assessment

POST /api/v1/risk/business-risk-score (API key) · POST /api/v1/x402/risk/business-risk-score (x402, $0.50 = 500000 USDC atomic units) · POST /api/v1/l402/risk/business-risk-score (L402, when enabled) · POST /api/v1/mpp/charge/business_risk_score and MPP session calls (when enabled) · MCP tool business_risk_score (remote /mcp, stdio, and paid /mcp/mpp when enabled). Category risk_intelligence, idempotent. Answers one question for procurement, finance, vendor-onboarding, B2B sales, marketplace, insurance, lending, compliance and autonomous purchasing agents: is it risky to do business with this company? — anywhere in the world, from evidence rather than model knowledge.

Agent:
"Check this supplier before I pay their invoice."

↓

business_risk_score
$0.50

↓

Returns:
- risk score (0–100, 100 = highest detected risk) and risk level
- confidence (0–1, separate from risk)
- company identity (entity match confidence, registry status)
- corporate risk
- financial risk
- sanctions / compliance risk (with match strength)
- reputation risk
- operational risk
- digital risk
- evidence (source, URL, observed/retrieved dates, reliability, claim)
- positive signals
- risk flags (each traceable to evidence ids)
- recommended due-diligence action

Input (strict; unknown fields → 400 INVALID_INPUT, no payment): companyName (required) plus optional country (ISO 3166 alpha-2/alpha-3/English name — strongly recommended), registrationNumber, lei, website, address, city, industry, knownAliases (≤ 5), includeNews (default true), includeDigitalSignals (default true). Names are normalized on their distinctive core (legal forms compared separately), websites to an https:// origin + registrable domain, countries to ISO-2.

curl -X POST http://localhost:8787/api/v1/risk/business-risk-score \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"companyName":"Example Trading Ltd","country":"GB","website":"https://example.com"}'

Output: status (assessed | insufficient_data), assessmentId, business (name, legalName, country, registrationNumber, registrationStatus, incorporationDate, registry, website, entityMatchConfidence, resolutionStatus), riskScore, riskLevel (0–20 low · 21–40 moderate · 41–60 elevated · 61–80 high · 81–100 critical), confidence, components (corporate/financial/compliance/reputation/operational/digital risk; null = no data coverage), scoreBreakdown (per-category contributions, effective weights, floors applied), riskFlags[] and positiveSignals[] (code, category, severity, weight, confidence, points, evidenceIds, factStatus observed|reported|alleged, requiresVerification), sanctionsScreening (lists checked/unavailable, names screened, matches with matchStrength possible|high and list type sanctions vs export-control/debarment), evidence[] (type, evidenceClass — official_record / regulatory_action / court_record / credible_journalism / other_publication / user_generated / allegation / self_published — sourceName, sourceUrl, publishedAt, observedAt, retrievedAt, ageDays, stale, freshness, claim, reliability, relevance, fromCache; lookup_result items document clean checks), recommendation (proceed / proceed_with_monitoring / enhanced_due_diligence / manual_review / avoid_automated_transaction + reason codes), dataCoverage, confidenceBreakdown, providers[], warnings[], assumptions[], limitations[], methodology, evaluatedAt. The documented example (OpenAPI, /api/v1/capabilities, x402 Bazaar metadata) is the real pipeline over an explicitly synthetic scenario — regenerate with node --import tsx scripts/generateBusinessRiskExample.ts (a test fails if it drifts).

Structured errors (no charge on x402/L402/MPP — only successful calls settle): AMBIGUOUS_ENTITY 409 (details.candidates with registration numbers — retry with one), ENTITY_NOT_FOUND 404 (the jurisdiction's registry has no such company and nothing else shows it exists), PROVIDER_TIMEOUT 504 / PROVIDER_UNAVAILABLE 503 / RATE_LIMITED 429 (every identity source failed — the minimum identity check could not be performed), INVALID_INPUT 400, INTERNAL_ERROR 500. INSUFFICIENT_DATA is a 200 result (status: "insufficient_data", null score) returned only when the deployment has no evidence source for the company at all.

Pipeline (src/business-risk/): normalization → stage 1 (registries GLEIF / UK Companies House / Rafid Oman registry, company website, RDAP — concurrent, isolated, time-boxed, cache-first) → entity resolution (companyResolver.ts from company_reputation_check: registration number/LEI decisive, other countries never match, near-equal candidates are ambiguous — deep scoring and every paid search are skipped for ambiguous/unknown entities) → stage 2 with the resolved identifiers (sanctions & restricted-party lists screening the name, supplied aliases and former registry names; news; reviews; regulator publications; Companies House filing status; Google Safe Browsing) → dedup + relevance filtering (same-name companies in other jurisdictions are excluded, never merged) → six detectors (signals.ts) → scoring.ts → confidence.ts → recommendation.ts. It reuses company_reputation_check's provider contract, runner, evidence cache, normalizers, resolver and sanctions/adverse-media classifiers; only the risk model is new.

Scoring (config.ts, version brs-1.0.0, echoed in every response's methodology): every signal contributes SEVERITY_POINTS[severity] (info 0 · low 8 · medium 20 · high 40 · critical 70) × rule weight × confidence. Category score = baseline − baseline·(1−e^(−positive/30)) + (100−baseline)·(1−e^(−negative/45)) — positives only erode the residual baseline, they never cancel a red flag; categories without coverage are null. Overall = weighted mean of the covered categories with weights corporate 20% · financial 20% · compliance 25% · reputation 15% · operational 10% · digital 10% (renormalized; override with BUSINESS_RISK_CATEGORY_WEIGHTS), raised to documented floors when a confident severe signal exists (high-confidence sanctions match ≥ 85, dissolved company or malware/phishing listing ≥ 81, insolvency proceedings or restricted-party match ≥ 70, any critical ≥ 61, any high ≥ 35), clamped 0–100. Missing data lowers confidence and coverage only; it raises risk solely where the absence contradicts the request (company not in its jurisdiction's registry, supplied registration number not found, supplied website unreachable, supplied domain unregistered, reachable site with no contact information) — listed in methodology.missingDataRiskRules. LLMs are not used anywhere in scoring.

Confidence (separate from risk): 30% entity-match confidence, 25% weighted category coverage, 15% source reliability, 10% authoritative registry records, 10% evidence freshness (media decays with a half-life; stale cache ×0.5), 10% consistency (−0.25 per contradiction); capped at 0.55 when identity is not verified against a registry.

Providers:

ProviderRoleEnabled byCost
GLEIF Global LEI IndexcorporateRISK_LIVE_CHECKS_ENABLED=truefree
UK Companies House (profile + filing status)corporate + financialCOMPANIES_HOUSE_API_KEYfree key
Rafid Oman company registrycorporateOMAN_BUSINESS_DATA_MODE=database/compositeown DB
UN Consolidated List, US CSL (OFAC SDN + BIS/DDTC export-control & debarment)sanctionsRISK_LIVE_CHECKS_ENABLED=truefree
EU FSF / OpenSanctionssanctionsEU_SANCTIONS_LIST_URL / OPENSANCTIONS_API_KEYfree / paid licence
News + review-platform search (3 searches)newsWEB_SEARCH_PROVIDER=tavily + TAVILY_API_KEY~$0.024
Regulator / enforcement publications (1 search restricted to regulator domains)regulatorysame~$0.008
Company website (SSRF-safe), RDAPdigitalRISK_LIVE_CHECKS_ENABLED=truefree
Google Safe BrowsingdigitalGOOGLE_SAFE_BROWSING_API_KEY (+ RISK_LIVE_CHECKS_ENABLED=true)free key

Any provider or role can be switched off with BUSINESS_RISK_DISABLED_PROVIDERS (ids or roles, e.g. news,regulatory).

Caching and persistence: provider evidence goes through the SAME evidence cache as company_reputation_check (rafid_company_evidence_cache, same provider ids ⇒ evidence is fetched once and shared; TTLs registry 7d, sanctions 12h, news 1d, reviews 3d, website 3d, domain 7d; keys include the normalized identity, so same-name companies in different countries never share rows; failed refreshes may serve flagged stale evidence). Each assessment is recomputed from evidence (deterministic) and persisted best-effort to rafid_business_risk_assessments (PostgreSQL via BUSINESS_RISK_DATABASE_URL or DATABASE_URL, created on first use; memory otherwise; BUSINESS_RISK_ASSESSMENT_STORE=memory|off), deduplicated on (identity, scoring version, evidence fingerprint) — the audit trail for "why was this company scored 67?".

Unit economics: worst case uncached 4 web searches ≈ $0.032; everything else free. Estimated gross margin at $0.50 ≈ $0.47/call before hosting and facilitator fees (internal GET /api/v1/internal/revenue/unit-economics).

x402 challenge size: the x402 Bazaar discovery declaration is embedded in the PAYMENT-REQUIRED header; declarations are now size-bounded (MAX_DISCOVERY_DECLARATION_CHARS, dropping the output example and then the output schema when needed) so every 402 stays readable by Node-based x402 clients (16 KB header limit). Full schemas/examples remain in /openapi.json.

Document intelligence: evidence-backed document facts (document_facts_extract, $0.25/call)
document_facts_extract
$0.25 / call
Structured, verifiable facts from business documents (any country)

POST /api/v1/documents/facts-extract (API key) · POST /api/v1/x402/documents/facts-extract (x402, $0.25 = 250000 USDC atomic units) · POST /api/v1/l402/documents/facts-extract (L402, when enabled) · POST /api/v1/mpp/charge/document_facts_extract and MPP session calls (when enabled) · MCP tool document_facts_extract. Category document_intelligence, idempotent. Converts contracts, invoices, purchase orders, quotations, tenders/RFPs, leases, policies, financial reports, legal documents, CVs and company profiles into facts another agent can act on — not a summary: every fact carries a 0–1 extraction confidence and source evidence.

Input (strict; exactly one of documentUrl / text): documentUrl (public https; PDF with a text layer, DOCX, HTML, text, Markdown, CSV) or text (≤ 200,000 characters; separate pages with \f for page-level evidence), optional documentType (auto default, or contract | invoice | purchase_order | quotation | tender | policy | financial_report | legal_document | lease | resume | company_profile | other), requestedFacts (≤ 20 plain-language questions), mode (auto | requested_only), language (ISO 639), includeSourceEvidence (default true). Limit: 25 pages — larger documents are rejected (DOCUMENT_TOO_LARGE), never truncated.

curl -X POST http://localhost:8787/api/v1/documents/facts-extract \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"documentUrl":"https://example.com/supplier-contract.pdf","requestedFacts":["contract expiry date","payment terms","termination notice period"]}'

Output: documentType / documentSubtype / documentTypeConfidence, language, title, facts[] (key, label, value, normalizedValue, confidence, method = labeled_field | definition | pattern | clause | table | llm_verified, sourceEvidence), requestedFacts[] (one per request, found / not_found — absence is reported, never filled from model knowledge), entities[], dates[], amounts[], percentages[], obligations[] (party, obligation, must/must_not, deadline, condition), requirements[] (category, mandatory), deadlines[], riskFlags[], metadata (pageCount, characterCount, format, source, pageProvenance, extraction mode/method, LLM-assist status), warnings[], overallConfidence, limitations[]. sourceEvidence = { page?, section?, text, startOffset, endOffset } — a short verbatim excerpt plus offsets into the extracted text; page appears only when the source has real pages (PDF pages, \f-separated text, DOCX rendered page breaks).

Normalization (only when unambiguous; the original is always returned): dates → ISO 8601 (textual months in English/French/Spanish/German/Portuguese/Italian/Arabic; numeric dd/mm vs mm/dd only when the document itself proves the order); amounts → {amount, currency, frequency} (ISO codes, unambiguous symbols, currency words incl. Arabic; $, ¥, bare "dollars" leave currency unset with currencySymbol; 3-decimal currencies like OMR/KWD/BHD handled); percentages → ratio; durations → {value, unit, iso8601}. Nothing is computed, converted or annualized.

Document-type-aware facts: contract (parties, effective/expiry dates, term, value, payment terms, renewal, termination, notice period, penalties, SLA, governing law, liability, warranties) · invoice (supplier, customer, number, dates, subtotal, tax, total, currency, payment details, line items verified by qty × unit price) · purchase order · quotation · tender (issuer, number, submission deadline, eligibility/technical/financial requirements, bid/performance bonds, mandatory documents, evaluation criteria) · lease (landlord, tenant, property, unit, dates, rent + frequency, deposit, renewal, notice) · policy · financial report · legal document · CV · company profile · generic.

Risk flags are observable conditions with a rule-defined severity (src/document-facts/extract/risk.ts), never legal conclusions — e.g. automatic_renewal, short_termination_notice, missing_expiry_date, missing_currency, contradictory_amounts, totals_do_not_reconcile, penalty_clause_detected, unlimited_liability_language, missing_signature, ambiguous_payment_terms, embedded_instructions_detected, macros_present, hidden_html_content_removed.

Security — the document is untrusted data: SSRF-safe single download (https only, private/loopback/link-local/metadata targets and redirects into them refused — the shared safeFeedFetch), size/time bounded; no link inside the document is ever fetched; PDF parsed text-only with pdf.js eval disabled; DOCX macros detected and never executed; HTML scripts and human-hidden elements removed; zero-width/bidi-override characters stripped. Passages addressed to AI systems ("ignore previous instructions…") are detected, flagged (embedded_instructions_detected) and excluded from facts, obligations and requirements. The optional LLM assist (DOCUMENT_FACTS_LLM=auto + the intelligence synthesizer) is used only for requested/priority facts the deterministic extractor could not establish; it gets a system prompt declaring the document data-only, never sees detected injection passages, has no tools, and every answer must quote the document verbatim — the quote is located by our code (which also derives the page), and answers whose quote is missing, lies in an injection passage, does not support the value, or contains a secret environment value are discarded.

Structured errors (no charge on x402/L402/MPP): MISSING_DOCUMENT / CONFLICTING_DOCUMENT_SOURCES / EMPTY_DOCUMENT / INVALID_URL 400, DOCUMENT_TOO_LARGE 413, UNSUPPORTED_FORMAT 415, UNREADABLE_DOCUMENT 422 (corrupt, password-protected, scanned without a text layer), DOCUMENT_FETCH_FAILED 502, DOCUMENT_TIMEOUT 504, EXTRACTION_FAILED 500, plus the shared INVALID_INPUT / INVALID_JSON.

Example output in OpenAPI / /api/v1/capabilities is the real pipeline over a synthetic agreement — regenerate with node --import tsx scripts/generateDocumentFactsExample.ts (a test fails if it drifts). Unit economics: deterministic extraction has no per-call provider cost; LLM assist (when enabled and needed) ≈ ≤ $0.02.

Finance: invoice anomaly check (invoice_anomaly_check, $0.25/call)
invoice_anomaly_check
$0.25 / successful call
Pre-payment invoice anomaly and risk-indicator detection (any country, any currency)

POST /api/v1/finance/invoice-anomaly-check (API key) · POST /api/v1/x402/finance/invoice-anomaly-check (x402, $0.25 = 250000 USDC atomic units) · POST /api/v1/l402/finance/invoice-anomaly-check (L402, when enabled) · POST /api/v1/mpp/charge/invoice_anomaly_check and MPP sessions (when enabled) · MCP tool invoice_anomaly_check. Category finance_risk, OpenAPI tag "Finance", idempotent, no side effects. Use before approving, paying, booking, reconciling or auditing an invoice, especially when an agent needs to determine whether the invoice requires human review.

The engine (src/invoice-anomaly/) is deterministic and in-process: no LLM, no external API, no credentials, nothing stored or logged. Money is parsed from its decimal text into scaled BigInts (never compared as binary floating point) and every arithmetic comparison has an explicit rounding tolerance of one currency minor unit per rounding step (0.01 USD, 0.001 OMR, 1 JPY; overridable with options.roundingTolerance).

Two modes. Standalone — only invoice (the only required field is invoice.total): arithmetic, dates/terms, repeated lines, invoice-number sanity, currency-code validity, missing business fields. Context-aware — add any of historicalInvoices (≤ 1,000, any supplier), supplierProfile (ids, aliases, bankAccounts with verified/addedDate, currencies, terms, status, createdDate), purchaseOrder (supplier, currency, totalAmount, invoicedToDate / remainingAmount, amountsIncludeTax, issueDate, status, line items), contract (supplier, currency, maxAmount, maxInvoiceAmount, invoicedToDate, period, terms), approvalContext (approvalThreshold, currency, splitWindowDays) and paymentHistory (≤ 1,000). options.asOfDate pins the reference date (default: today, UTC) for fully reproducible output.

curl -X POST http://localhost:8787/api/v1/finance/invoice-anomaly-check \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"invoice":{"invoiceNumber":"INV-2026-1043","supplierName":"ABC Trading LLC","supplierId":"SUP-291","invoiceDate":"2026-09-20","dueDate":"2026-10-20","currency":"USD","subtotal":9200,"tax":460,"total":9660,"bankAccount":"US123456789","paymentTermsDays":30,"poNumber":"PO-2026-818","lineItems":[{"description":"Consulting services","quantity":10,"unitPrice":920,"taxRate":5,"total":9200}]},
       "historicalInvoices":[{"invoiceNumber":"INV-2026-1021","supplierId":"SUP-291","invoiceDate":"2026-08-20","currency":"USD","total":8694,"bankAccount":"US987654321"}],
       "options":{"asOfDate":"2026-09-23"}}'

Response (abridged):

{
  "riskScore": 44, "riskLevel": "high", "decision": "review", "anomalyCount": 1,
  "anomalies": [{
    "code": "BANK_ACCOUNT_CHANGED", "severity": "high", "confidence": 0.8, "field": "bankAccount",
    "explanation": "The payment account on the invoice differs from the 1 account(s) previously recorded for this supplier; payment details should be verified with the supplier through a known contact before payment.",
    "evidence": { "reason": "account_differs_from_supplier_records", "invoiceAccountMasked": "****6789", "knownAccountsMasked": ["****4321"], "lastUsedDate": "2026-08-20", "accountSeenForOtherSuppliers": false }
  }],
  "financialChecks": { "subtotalValid": true, "taxValid": true, "totalValid": true, "lineTotalsValid": true, "computed": { "lineItemsSum": 9200, "expectedTax": 460, "expectedTotal": 9660, "tolerance": 0.01 } },
  "recommendedAction": "Verify the payment details directly with the supplier using contact details already on file (not those on the invoice) before paying.",
  "summary": "1 anomaly detected (BANK_ACCOUNT_CHANGED); risk high (44/100). Advisory decision: review — …",
  "mode": "context_aware", "contextUsed": { "historicalInvoices": 1, "supplierHistoryMatched": 1, "…": "…" },
  "invoiceSummary": { "invoiceNumber": "INV-2026-1043", "total": 9660, "bankAccountMasked": "****6789", "…": "…" },
  "dataCompleteness": { "missingRequiredFields": [], "missingRecommendedFields": [] },
  "scoring": { "model": "iac-1.0.0", "components": [{ "code": "BANK_ACCOUNT_CHANGED", "severity": "high", "confidence": 0.8, "basePoints": 55, "points": 44 }], "escalationBonus": 0, "…": "…" },
  "limitations": ["The capability identifies invoice anomalies and risk indicators. It does not independently establish fraud or replace accounting, audit, compliance, or payment-authorization controls.", "…"],
  "asOfDate": "2026-09-23", "checkedAt": "2026-09-23T00:00:00.000Z"
}

(The full, generated example — a near-duplicate with a changed bank account billed against another supplier's PO, critical / hold — is in OpenAPI and /api/v1/capabilities; regenerate it with node --import tsx scripts/generateInvoiceAnomalyExample.ts, a test fails if it drifts.)

Anomaly codes (one record per code; sub-findings in evidence.issues):

FamilyCodesWhat triggers it
ArithmeticLINE_TOTAL_MISMATCH, SUBTOTAL_MISMATCH, TAX_MISMATCH, TOTAL_MISMATCHquantity × unitPrice − discount ≠ line total; Σ lines ≠ subtotal; stated tax ≠ line/invoice rate × base; subtotal − discount + tax + shipping ≠ total (beyond tolerance). ≤ 0.5% of the total → low, else medium
DuplicatesDUPLICATE_INVOICE, POSSIBLE_DUPLICATEconfirmed only for same supplier + same (or same-digit) invoice number + same amount, or an already-recorded payment; possible for a number variant (suffix/typo), same amount within duplicateWindowDays (14) plus a corroborating signal (similar number, same line items, same PO), same amount within 3 days, a reused number with a different amount, a resubmitted cancelled/rejected invoice, or the same number and amount under another supplier record. Sequential numbering and recurring same-amount billing are not treated as corroboration. The matched record is returned
Supplier behavior (≥ 3 prior invoices)UNUSUAL_AMOUNT, UNUSUAL_CURRENCY, UNUSUAL_PAYMENT_TERMS, INVOICE_NUMBER_ANOMALY, SUPPLIER_PATTERN_DEVIATIONabove the historical max AND ≥ 2× the median AND robust z (median/MAD) ≥ 3.5 (≥ 5× → high); currency new for the supplier / outside the profile / differs from the contract / not ISO 4217; terms ≤ 50% and ≥ 10 days shorter than contract, profile or history; placeholder numbers, a format deviating from the supplier's ≥ 80%-dominant format, out-of-sequence numbers; blocked/suspended/inactive/unverified supplier, supplier id/name differing from the profile, a supplier record created ≤ 30 days before the invoice, ≥ 3 invoices in 30 days at ≥ 3× the prior monthly rate
Payment detailsBANK_ACCOUNT_CHANGED, UNKNOWN_BANK_ACCOUNTaccount not among the supplier's profile/history/payment accounts (high), or on the profile but added ≤ 30 days ago and never paid (medium); a profile without the account, or the account only on file unverified (high); critical when the account also appears on another supplier's records. Always masked
PO / contract / approvalPO_MISMATCH, PO_AMOUNT_EXCEEDED, CONTRACT_LIMIT_EXCEEDED, APPROVAL_THRESHOLD_EXCEEDED (info)PO reference, supplier, currency, closed/cancelled status, quantity above ordered, unit price above PO, unmatched lines; amount above the remaining PO balance (> 10% of the PO → high); cumulative invoicing (caller-supplied or summed from history in the contract period) above the contract cap, or above the per-invoice maximum; above the approval threshold (routing info, 0 points)
Split invoicesSPLIT_INVOICE_PATTERNsame-supplier invoices within splitWindowDays (7) each ≤ the approval threshold but together above it (confidence rises with same-day dates, similar lines/same PO, all ≥ 85% of the limit), or ≥ 3 invoices within 90 days at 90–100% of the threshold. A risk indicator, never an allegation
DatesDATE_ANOMALY, DUE_DATE_ANOMALYdated > 1 day in the future, > 365 days old, before the PO issue date or outside the contract period; due before the invoice date, due date > 3 days off the stated terms, terms > 1 year. Same-day due dates, past due dates and next-day invoice dates are not flagged
Line itemsDUPLICATE_LINE_ITEMthe same description/SKU at the same price and quantity twice (medium), or near-identical descriptions at the same price (low)
Data qualityMISSING_REQUIRED_FIELDbusiness fields missing (invoice number, supplier, invoice date, currency): low, medium when ≥ 3. Recommended fields (due date/terms, lines, subtotal, tax, bank account) are listed in dataCompleteness only

Risk score (model iac-1.0.0, src/invoice-anomaly/scoring.ts + config.ts): each anomaly contributes base points × confidence — info 0, low 5, medium 20, high 35, critical 50, with overrides DUPLICATE_INVOICE 50/60, POSSIBLE_DUPLICATE 20/40, BANK_ACCOUNT_CHANGED 25/55/70, UNKNOWN_BANK_ACCOUNT 20/40/60, SPLIT_INVOICE_PATTERN 20/40, MISSING_REQUIRED_FIELD 3/8. Low/info contributions are capped at 10 in total and medium ones at 45. Correlation bonus: +8 per additional risk family with a ≥ medium anomaly (max +24) and +10 when a ≥ high payment-detail anomaly coincides with a duplicate, PO/contract or split anomaly. Severity ceiling: only-low findings score ≤ 14, a maximum of medium ≤ 64 — so trivial warnings can never produce a critical result. Levels / advisory decision: 0–14 low → continue; 15–39 medium → review; 40–64 high → review; 65–100 critical → hold. The scoring object returns every component, cap, bonus and the raw score.

Errors (not charged on x402 / L402 / MPP): INVALID_INPUT (schema / unknown fields) and INVALID_JSON 400, INVALID_MONETARY_VALUE 400, INVALID_DATE 400, UNSUPPORTED_CURRENCY_FORMAT 400 (each with details.path, never the value), ANALYSIS_FAILED 500 (sanitized). Unit economics: no upstream provider or LLM cost.

Limitations. The capability identifies invoice anomalies and risk indicators. It does not independently establish fraud or replace accounting, audit, compliance, or payment-authorization controls. decision is advisory, not a payment approval or rejection. Findings are limited to the data supplied (no ERP, bank, registry or third-party fraud source is consulted; connectors are a future phase). Supplier baselines need ≥ 3 prior invoices from the supplier; amounts in different currencies are never converted or compared; checkedAt is day-granular (the as-of date) so identical inputs give identical output.

Automotive: vehicle value estimate (vehicle_value_estimate, $0.25/call)
vehicle_value_estimate
$0.25 / successful call
Fair-market valuation of a passenger vehicle from comparable-market evidence (global, multi-provider)

POST /api/v1/automotive/vehicle-value-estimate (API key) · POST /api/v1/x402/automotive/vehicle-value-estimate (x402, $0.25 = 250000 USDC atomic units) · POST /api/v1/l402/automotive/vehicle-value-estimate (L402, when enabled) · POST /api/v1/mpp/charge/vehicle_value_estimate and MPP sessions (when enabled) · MCP tool vehicle_value_estimate · Free Preview POST /api/v1/preview/vehicle_value_estimate. Category automotive, OpenAPI tag "Automotive", idempotent, no side effects. All of this comes from the one registry entry in src/domain/capabilities.ts. Use this capability when an AI agent needs to estimate the current market value of a passenger vehicle, determine whether an asking price is reasonable, estimate private-sale or dealer values, assess depreciation, or evaluate a vehicle using local or regional market comparables.

It returns a low/mid/high fair-value range, private-sale, dealer-buy (trade-in) and dealer-retail estimates, the asking price's exact difference from the midpoint and its market position, depreciation, every valuation adjustment with its amount and reason, the comparables used (with similarity scores), market statistics, a deterministic confidence score, machine-readable risk flags, assumptions and data freshness. The valuation maths is deterministic application code: no LLM is involved, and the same normalized input over the same market evidence always gives the same output.

Input. Required: make, model, year (1950 – next calendar year), country (ISO alpha-2/alpha-3 or name, any country). Strongly recommended (each missing one lowers confidence and raises a flag): mileageKm (0–2,000,000), trim, city, condition (excellent, very_good, good, fair, poor, unknown). Optional: askingPrice (> 0), currency (ISO 4217; default = the market's currency), fuelType, transmission, bodyType, engine, drivetrain, accidentHistory (false/"none", "minor_cosmetic", "repaired", "structural", true/"reported" = severity not stated, "unknown"), serviceHistory (full, partial, none, unknown), owners (1–20), color, options (≤ 40), valuationDate (YYYY-MM-DD, not in the future; evidence observed later is ignored — for insurance/historical valuations). vin (optional, 17 characters — see "VIN" below). Common synonyms are normalized ("gasoline" → petrol, "Auto" → automatic, "4x4" → 4wd, "VW" → Volkswagen, "UAE" → AE); unknown values and unknown fields are rejected.

curl -X POST https://api.rafidsystem.com/api/v1/automotive/vehicle-value-estimate \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"make":"Toyota","model":"Land Cruiser","year":2022,"trim":"GXR","mileageKm":68000,"condition":"good",
       "country":"Oman","city":"Muscat","currency":"OMR","fuelType":"petrol","transmission":"automatic","bodyType":"suv",
       "drivetrain":"4WD","accidentHistory":false,"serviceHistory":"full","owners":1,
       "options":["sunroof","leather seats","360 camera"],"askingPrice":22500}'

Response (abridged; generated from the synthetic fixture listings — see below — by the real engine):

{
  "status": "estimated",
  "estimatedValue": { "low": 22100, "mid": 23000, "high": 23900, "currency": "OMR" },
  "estimatedPrivateSalePrice": 23400, "estimatedDealerBuyPrice": 20600, "estimatedDealerRetailPrice": 25700,
  "askingPriceAnalysis": { "askingPrice": 22500, "differenceFromMid": -500, "differencePercent": -2.17, "marketPosition": "near_market", "withinEstimatedRange": true, "thresholdsPercent": { "wellBelow": -15, "below": -5, "above": 5, "wellAbove": 15 } },
  "depreciation": { "estimatedOriginalPrice": null, "totalDepreciationAmount": null, "marketImpliedAnnualDepreciationPercent": null, "…": "…" },
  "adjustments": [
    { "factor": "mileage", "impactAmount": -20, "impactPercent": -0.1, "basis": "market_derived", "reason": "Mileage (68,000 km) is above the comparable-market median (66,000 km); …" },
    { "factor": "listing_to_transaction", "impactAmount": -680, "impactPercent": -3, "basis": "heuristic", "reason": "Comparable prices are listing asking prices; a 3.0% typical negotiation margin for Oman …" },
    { "factor": "full_service_history", "impactAmount": 330, "impactPercent": 1.5, "basis": "heuristic", "reason": "Documented full service history supports stronger resale value." }
  ],
  "marketComparables": [{ "year": 2022, "trim": "GXR", "mileageKm": 71000, "askingPrice": 23300, "currency": "OMR", "city": "Muscat", "sourceName": "synthetic-dealer-feed", "observedAt": "2026-09-14T12:00:00.000Z", "similarityScore": 0.99, "…": "…" }],
  "marketStats": { "comparableCount": 10, "comparablesConsidered": 23, "outliersExcluded": 2, "medianPrice": 22875, "medianMileageKm": 64000, "priceDispersionPercent": 1.6, "…": "…" },
  "methodology": { "fallbackLevel": "same_trim_same_year", "mileageEffect": { "basis": "market_derived", "percentPer10000Km": -0.53 }, "rangeHalfWidthPercent": 4, "…": "…" },
  "confidence": { "score": 0.94, "level": "high", "reasons": ["10 comparable listing(s) used …", "…"], "factors": { "comparableVolume": 0.83, "…": "…" } },
  "riskFlags": ["HEURISTIC_ADJUSTMENTS_USED", "OUTLIERS_REMOVED"],
  "assumptions": ["Vehicle has clear legal ownership/title status …", "No undisclosed accident or structural damage; …", "…"],
  "dataFreshness": { "latestComparableAt": "2026-09-20T12:00:00.000Z", "oldestComparableAt": "2026-08-19T12:00:00.000Z", "medianComparableAgeDays": 17, "recentListingCount": 8, "recentWindowDays": 30 },
  "marketCoverage": { "countryCode": "OM", "valuationParametersConfigured": true, "marketDataProviders": [{ "id": "…", "status": "ok", "comparablesReturned": 23 }], "liveMarketDataAvailable": true, "…": "…" }
}

(The full example is in OpenAPI and /api/v1/capabilities; regenerate it with node --import tsx scripts/generateVehicleValueExample.ts — a test fails if it drifts. It is explicitly labelled synthetic in its first assumption.)

Example agent use case. "Estimate the current market value of this 2022 Toyota Land Cruiser GXR with 68,000 km in Muscat and tell me whether OMR 22,500 is a reasonable asking price." → the agent calls vehicle_value_estimate with the fields above and askingPrice: 22500, then answers from estimatedValue and askingPriceAnalysis ("OMR 22,500 is 2.2 % below the OMR 23,000 midpoint — near market; fair range OMR 22,100–23,900; confidence high"). If status is insufficient_market_data it says so instead of guessing.

Architecture (src/vehicle-value/). input validation (schemas/vehicleValueInputs.ts) → normalization (normalization.ts, subject.ts) → market detection (markets.ts) → market-data router (providers/router.ts: selects every provider that supports() the country or its regional-fallback countries, runs them concurrently, each under VEHICLE_MARKET_PROVIDER_TIMEOUT_MS, sanitizes and de-duplicates their results, caches provider searches) → comparable normalization + currency conversion + eligibility (comparables.ts) → progressive fallback selection → weighted similarity (similarity.ts) → outlier removal → base estimate and adjustments (valuation.ts) → confidence (confidence.ts) → structured result (service.ts). The core never knows which marketplace a comparable came from.

Market-data providers. Any source implements VehicleMarketProvider (types.ts: id, supports(market), searchComparables(query, signal), optional getNewVehiclePrice / newPriceRank) and is added in buildVehicleRuntime() (service.ts) — the valuation core does not change. Every provider is off until configured; on a default deployment every call honestly returns insufficient_market_data with NO_MARKET_DATA_PROVIDER, and no market is ever claimed to have live data unless a configured provider covers it. Shipped providers:

Provider (id)MarketsEnable withWhat it is
vehicle_market_recordsdeclared in VEHICLE_MARKET_COUNTRIES (e.g. OM,AE or *)VEHICLE_MARKET_DATA_MODE=databaseRafid's own table of imported, licensed evidence (dealer/partner exports, auction results) — see "Data layer". Also serves verified new-vehicle prices.
marketcheckUS, CAMARKETCHECK_API_KEY (+ optional MARKETCHECK_PAGES, default 2)MarketCheck Inventory Search, a licensed, paid listings API: `GET https://api.marketcheck.com/v2/search/car/active?api_key=…&make&model&year_range&car_type=used&country=us\ca&rows=50&start=…. Miles → km, USD/CAD, body/fuel/transmission/drivetrain mapped to enums, dealer contact details and VINs dropped (VIN → hash for self-exclusion). Its listings' dealer-reportedmsrp` gives a new-price reference when ≥ 3 listings of the exact year (and trim) agree. The key stays server-side and never appears in responses, logs or errors.
feed:<id>declared per feedVEHICLE_MARKET_FEEDS_JSONPartner / licensed HTTPS feeds for markets with no public listings API (e.g. an Oman or UAE dealer group, a classifieds or auction partner): [{"id":"om-dealer","url":"https://…/rafid.json","countries":["OM"],"format":"json","authHeaderEnv":"OM_DEALER_FEED_AUTH"}]. The partner publishes JSON ([...] or {"records":[...]}) or CSV in the vehicle_market_records row format; Rafid fetches it through the Production Feed Runner's SSRF-protected fetcher (https only, private/loopback addresses and unvalidated redirects refused, 8 s / 10 MB caps), validates each row, drops personal-data columns and caches the feed for an hour. authHeaderEnv names an env var holding the Authorization header value (the secret never goes in the JSON). No scraping — only feeds a partner publishes for Rafid.

UK/European listings APIs (e.g. MarketCheck UK) and GCC classifieds have no verified public contract integrated yet; those markets are served through partner feeds or imported records. Valuation parameters (default currency, expected annual km, dealer/private spreads, negotiation margin, conservative fallbacks) are configured for Oman, UAE, Saudi Arabia, Qatar, Bahrain, Kuwait, the United States, Canada, the United Kingdom, Australia and 14 European markets; any other country is valued with generic defaults when a provider covers it (and must pass currency). marketCoverage reports valuation-parameter support and provider/data support separately in every response.

Data layer. vehicle_market_records (store/postgres.ts, created with CREATE TABLE IF NOT EXISTS): make/model/trim with normalized keys, year, mileage, condition, body/fuel/transmission/drivetrain, country/city/region, price + currency + price_type (listing/sale), source type/name/record id/url, observed_at, ingested_at, metadata. Indexes on normalized make+model+year, trim, country+city, mileage and observed_at; a unique index on (source_name, source_record_id) de-duplicates re-imports. Vehicle and market evidence only — there is no column for a seller's name, phone, e-mail, VIN or plate. Import with npm run vehicle:import -- listings.csv [--dry-run] [--source-type=licensed_feed] (CSV or JSON, ≤ 20 MB): every row is validated/normalized, invalid rows are reported, and personal-data-looking columns are dropped and listed, never stored (a vin column is reduced to a hash kept only in memory for feed rows; it is not written to the table). New-vehicle prices: vehicle_new_prices (make/model/year/trim/country, price + currency, source name/URL, effective date; unique per make/model/year/trim/country/source) holds verified original prices — a distributor's official model-year price list, a manufacturer's published MSRP. Import with npm run vehicle:import -- new-prices.csv --new-prices [--dry-run] (columns make,model,year,trim,country,price,currency,sourceName,sourceUrl,effectiveDate). A price is used only for an exact trim match (or, with no trim given, when the model year has one unambiguous price), and it outranks MarketCheck's dealer-reported MSRP. Only import data you are licensed or authorized to use.

Comparable selection. Mandatory make + model match; evidence must be observed on/before the valuation day and within VEHICLE_MARKET_MAX_LISTING_AGE_DAYS (365). Progressive widening, recorded in methodology.fallbackLevel: same_trim_same_year → same_trim_year_plus_minus_1 → same_model_year_plus_minus_1 → same_model_year_plus_minus_2 → regional_same_model_year_plus_minus_2 (GCC ↔ GCC, US ↔ CA, AU ↔ NZ, eurozone ↔ eurozone; flagged REGIONAL_FALLBACK_USED). The narrowest level with ≥ 5 comparables wins; otherwise the level with the most comparables, provided ≥ 3 remain after outlier removal — below that, insufficient_market_data. Similarity (0–1): trim 25 %, model year 20 %, mileage 20 %, location 15 % (same city 1 · same country 0.8 · regional 0.35), condition 5 %, fuel + transmission 5 %, body + drivetrain 5 %, recency 5 %. Similarity weights the median; it never averages listings blindly.

Valuation methodology. (1) Model-year and mileage effects are fitted from the comparables (weighted least squares on log price, similarity-weighted) when there are ≥ 6 comparables with ≥ 2 model years / a mileage spread ≥ 10,000 km, bounded to plausible ranges and shrunk toward the market default by n/(n + 8); otherwise the market's conservative default is used (basis: "heuristic", confidence reduced) — there is no single global depreciation-per-km number. (2) In model-level pools, other trims are aligned to the subject's trim using the price gap measured in the pool (needs ≥ 2 listings of the subject's trim; otherwise no trim premium is invented and the estimate is labelled model-level). (3) Outliers: Tukey 1.5×IQR fences on log normalized price for n ≥ 5, modified z-score > 3.5 on MAD for n = 4 — never a hard-coded price threshold (marketStats.outliersExcluded). (4) Anchor = similarity-weighted median of the year/trim-aligned prices, moved by the mileage effect from the comparables' weighted median mileage to the subject's. (5) Market-derived local-market (regional pools) and transmission/drivetrain/fuel adjustments only when both groups have ≥ 2 comparables. (6) Listing asking prices → fair value by the market's negotiation margin (none for recorded sales). (7) Vehicle-specific adjustments — condition (relative to the comparables' typical condition), accident history (minor −3 %, repaired/reported −10 %, structural −25 %), service history (full +1.5 %, none −4 %), owners, premium options (+0.5 % each, ≤ 2 %) — are conservative, labelled heuristic, and capped at −35 % / +6 % in total so they can never dominate the market evidence. Range: ± (½ × robust dispersion + 15 % × (1 − confidence) + 2 %), clamped to 4–40 %: weak evidence widens it. Dealer / private: each market has dealerBuyDiscountRange, privateSaleAdjustmentRange, dealerRetailMarkupRange; the engine picks a deterministic point from the confidence (weaker evidence ⇒ larger dealer discount) and guarantees dealer buy ≤ private sale ≤ dealer retail. Rounding to 10/50/100/500/1,000 by magnitude (doubled at low confidence) so weak evidence is never presented with false precision. Asking price: differenceFromMid and differencePercent (2 dp) against the rounded midpoint; ≤ −15 % well_below_market, ≤ −5 % below_market, < +5 % near_market, < +15 % above_market, else well_above_market. Depreciation: original price (estimatedOriginalPrice, with originalPriceSource), total and annualized depreciation (1 − (mid / original)^(1/age)) only when a verified new-price reference exists — an imported official price list, else ≥ 3 agreeing MarketCheck dealer-reported MSRPs (null otherwise — never assumed); marketImpliedAnnualDepreciationPercent when the year effect is market-derived.

Confidence methodology (confidence.ts, deterministic): weighted sum of comparable volume (22 %, full at 12), mean similarity (18 %), exact-trim share (10 %), mileage evidence (10 %), locality (10 %), freshness (12 %, median age ≤ 30 days → 1, ≥ 180 → 0), provider diversity (6 %) and price consistency (12 %, robust dispersion vs 35 %); then ×0.85 stale evidence (median age > 90 days), ×0.95 condition unknown, ×0.93 heuristic mileage effect, ×0.95 heuristic year effect, ×0.9 regional fallback; capped at 0.59 below 5 comparables and 0.39 when insufficient. Levels: ≥ 0.80 high, ≥ 0.60 medium, ≥ 0.40 low, else very_low. confidence.factors and confidence.reasons expose every input.

Currency. Result currency = currency or the market default. Rates are never hard-coded or guessed: evidence (or a new-price reference) in another currency is converted only through a configured, dated source and each conversion used is reported in currencyConversion.conversions (from, to, rate, source, rateDate); without one it is excluded and flagged CURRENCY_CONVERSION_UNAVAILABLE. VEHICLE_FX_SOURCES lists sources tried in order per currency pair:

  • ecb — European Central Bank euro reference rates (eurofxref-daily.xml): free, keyless, ~30 currencies (USD, GBP, CAD, AUD, CHF, SEK, DKK, PLN, …) — not the GCC currencies. Published for information purposes, which fits comparable normalization.
  • exchangerate_api — ExchangeRate-API: covers OMR, AED, SAR, QAR, BHD, KWD and ~160 more, updated daily. Uses the keyed endpoint (v6.exchangerate-api.com/v6/{EXCHANGERATE_API_KEY}/latest/USD) when EXCHANGERATE_API_KEY is set, otherwise the rate-limited open-access endpoint (which asks for attribution). Its terms forbid redistributing its rates and disclaim accuracy; Rafid only applies them to comparables, but review the terms before relying on them commercially. Rates are cached per source (VEHICLE_FX_CACHE_TTL_MS, 6 h), loaded concurrently under a 3 s timeout, and a failing source is skipped (a previously loaded table is reused for up to 3 days). Recommended: VEHICLE_FX_SOURCES=ecb,exchangerate_api. A country without a configured default currency requires currency (CURRENCY_REQUIRED, 400).

VIN. vin is optional. It is normalized and validated (17 characters, no I/O/Q); North American VINs must also pass the ISO 3779 check digit (VIN_CHECK_DIGIT_INVALID otherwise). Offline, the manufacturer prefix (a partial WMI table) and the model-year code are compared with the request. With VEHICLE_VIN_DECODER=nhtsa it is also decoded with NHTSA vPIC (DecodeVinValues/{VIN}?format=json, free, keyless — opt-in because it sends the VIN to a third party; VEHICLE_VIN_DECODER_TIMEOUT_MS, 3 s) concurrently with the market search. A decode that agrees with the stated make and year fills only fields the caller left out (trim, body, fuel, drivetrain, transmission, engine — listed in vinCheck.enrichedFields); a VIN that disagrees is flagged VIN_MISMATCH and the vehicle is valued as described in the request. A decoder failure is VIN_DECODE_UNAVAILABLE, never fatal. Listings of the same vehicle (matched by a SHA-256 hash of the VIN from MarketCheck, feeds or imports) are removed from its own comparables (SUBJECT_LISTING_EXCLUDED). The VIN is never stored, logged, cached, put in telemetry/analytics or preview fingerprints, and responses carry only vinCheck.vinMasked (the 6-character serial hidden).

Risk flags. INSUFFICIENT_COMPARABLES, NO_MARKET_DATA_PROVIDER, STALE_MARKET_DATA, HIGH_PRICE_DISPERSION, MILEAGE_UNKNOWN, TRIM_UNKNOWN, CONDITION_UNKNOWN, ACCIDENT_HISTORY_UNKNOWN, ACCIDENT_SEVERITY_UNVERIFIED, STRUCTURAL_DAMAGE_REPORTED, HIGH_MILEAGE_FOR_AGE, ASKING_PRICE_SIGNIFICANTLY_ABOVE_MARKET, ASKING_PRICE_SIGNIFICANTLY_BELOW_MARKET, CURRENCY_CONVERSION_UNAVAILABLE, REGIONAL_FALLBACK_USED, BROADENED_COMPARABLE_SEARCH, HEURISTIC_ADJUSTMENTS_USED, PROVIDER_PARTIAL_FAILURE, OUTLIERS_REMOVED, VIN_MISMATCH, VIN_CHECK_DIGIT_INVALID, VIN_DECODE_UNAVAILABLE, SUBJECT_LISTING_EXCLUDED.

Performance, caching, observability. Providers run concurrently with per-provider timeouts; a timeout or error is reported in marketCoverage.marketDataProviders (PROVIDER_PARTIAL_FAILURE) and never fails the request. Provider searches (never valuations) are cached in-process for VEHICLE_MARKET_CACHE_TTL_MS (6 h) keyed by provider, market countries, make, model, model-year window and evidence cut-off day. Every call is recorded by the analytics layer (capability, channel, success, latency, dataSource live_provider / not_configured), and valuation telemetry (country, make, model, model year, comparable count, provider count/failures, confidence level, fallback level, latency — no VIN, no prices, no personal data) is on the internal unit-economics route.

Billing and preview. $0.25 per successful call through the shared payment infrastructure (API credits/subscriptions, x402, L402, MPP) — no capability-specific payment code. Schema-invalid requests (400) and VALUATION_FAILED (500) are never charged. A structured insufficient_market_data result is a successful 200 response and is charged like every other capability's honest "no data" result; agents should call the free preview first, which reports (without querying any provider or pricing anything) whether a configured provider covers the market (status: "available" vs "limited"), under the shared preview rate limits.

Limitations. Market evidence, not a physical inspection, vehicle-history check or formal appraisal; not financial, lending or insurance advice. Coverage depends entirely on the providers configured on the deployment. Comparables are mostly asking prices (converted with a documented negotiation margin). Options, colour and engine are not priced from market evidence (options get only a small capped heuristic). Title, liens and undisclosed damage are assumed clean (stated in assumptions); a VIN decode confirms identity/specification, not history. Cross-currency evidence needs a configured rate source. MarketCheck coverage is US/Canada dealer inventory only; other markets need imported records or partner feeds.

Production Oman market data (database mode, import, caching)

By default (OMAN_PROPERTY_DATA_MODE=manual, or unset) analyze_oman_property uses only the bundled curated fixtures — no database is required to run or evaluate the capability. Production deployments instead populate a real PostgreSQL-backed data layer and switch modes:

  • OMAN_PROPERTY_DATA_MODE=manual (default) — the curated ManualDatasetProvider fixtures only. No database connection is attempted.
  • OMAN_PROPERTY_DATA_MODE=database — production records only, read through DatabaseOmanPropertyDataProvider (domain/oman/dataProviders.ts) from PostgresPropertyMarketRepository (db/marketStore.ts). Requires OMAN_MARKET_DATABASE_URL (or DATABASE_URL) to be set; the manual fixtures never contribute to an answer in this mode.
  • OMAN_PROPERTY_DATA_MODE=composite — database records first, falling back to the manual fixtures only where the database has nothing to offer. Useful while a real feed is still being onboarded for some areas but not others.

Schema and migrations: db/marketSchema.ts defines property_market_records (governorate, wilayat, area, normalizedArea, propertyType, bedrooms, bathrooms, sizeSqm, transactionType, priceOMR, rentPeriod, furnished, sourceType, sourceName, sourceRecordId, sourceUrl, observedAt, ingestedAt, metadata JSONB), indexed on normalizedArea/propertyType/transactionType/bedrooms/sizeSqm/observedAt, plus a partial unique index on (sourceName, sourceRecordId) for records that carry a source record id. PostgresPropertyMarketRepository.migrate() applies this schema idempotently using its own migration ledger (rafid_market_migrations, advisory lock 74382002) — entirely independent of the customer/billing store's migrations, so market-data schema changes never contend with or depend on customer data migrations. Records are upserted (INSERT ... ON CONFLICT (source_name, source_record_id) DO UPDATE) when a sourceRecordId is present, so re-importing the same feed is safe and idempotent; records with no sourceRecordId are always inserted fresh, since they cannot be safely deduplicated.

Importing a dataset:

npm run market:import -- ./data/example-oman-market-import.csv

This runs PostgresPropertyMarketRepository.migrate() (safe to run repeatedly) and then imports the file, printing a JSON summary of { totalRows, imported, updated, skipped, errors }. Both CSV and JSON are accepted ({ "records": [...] } or a bare array); see data/example-oman-market-import.csv for the expected columns. Every row is validated against a strict schema (unknown columns are rejected outright), locations are normalized through the same English/Arabic area resolution analyze_oman_property itself uses, rent periods/furnished status/transaction type aliases are normalized, and text fields are sanitized — a malformed row is skipped and reported individually rather than aborting the whole import. Import is bounded (10 MB file size, 20,000 rows) and never executes anything found in the file; there is no web UI, only the CLI and the underlying importMarketRecords() service for programmatic use.

Size validation is property-type-specific, not one generic range: SIZE_BOUNDS_BY_PROPERTY_TYPE in domain/oman/importPipeline.ts — apartment 20-1500 sqm, townhouse 40-2000 sqm, villa 50-10,000 sqm — centralized in one place rather than a single bound tuned for a typical apartment (which previously rejected genuine ultra-luxury villas well over 3,000 sqm while being far looser than warranted for an apartment). Still finite in both directions per type: widening the villa band never means "accept anything."

Caching: an optional ComparableCache (domain/oman/cache.ts) can front repeated comparable lookups against the database — keyed by area, property type, bedrooms, a bucketed property size, furnished status and transaction type — with a configurable TTL (OMAN_MARKET_CACHE_TTL_MS, default disabled). The bundled MemoryComparableCache is process-local; the interface is deliberately not coupled to Redis so a distributed cache can be added later (e.g. a future RedisComparableCache) without touching DatabaseOmanPropertyDataProvider or any analysis logic.

What stays the same regardless of mode: comparable selection, outlier removal, confidence scoring and provenance labeling (domain/oman/comparables.ts/confidence.ts) are untouched by any of this — the database provider only retrieves a broader candidate pool (matching area, property type, transaction type and recency); every downstream decision about which candidates actually get used is made in the same place regardless of where the records came from.

Partner Data Feed (authorized property-level data from real estate companies, brokers, property managers)

The Oman property intelligence engine (comparable selection, confidence scoring, official NCSI context) is complete on its own; this layer exists solely to make it easy — and safe — for a real estate company, brokerage or property manager in Oman to supply Rafid with authorized market data, instead of every production record coming only from a manually curated fixture or an operator hand-loading a file. It is infrastructure, not an agent capability: none of it is registered in domain/capabilities.ts, so none of it appears in /agent.json, /api/v1/capabilities, the MCP tool catalog, or the x402 payment-gated route family — an agent calling analyze_oman_property has no way to reach or even discover these routes, and they carry no price. analyze_oman_property's own request/response shape is completely unchanged by any of this.

Partner model. A PropertyDataPartner (domain/oman/partners.ts) is deliberately minimal — partnerId (a short slug, e.g. gulf-realty-om), partnerName, feedType (csv | json | http_feed), sourceType (almost always partner_feed), enabled, dataLicenseReference, contactReference (a free-text pointer — an email alias or contract number — never a person's name or phone number), and createdAt. It deliberately stores no personal data about individuals at the partner. PartnerRepository is the storage abstraction, exactly like PropertyMarketRepository — MemoryPartnerRepository for tests, PostgresPartnerRepository (db/partnerStore.ts) for production, sharing the same Pool/database as PostgresPropertyMarketRepository rather than opening a second connection pool.

Authentication model — three distinct credentials, each scoped to what it's for:

  • X-API-Key (existing) — the normal customer credential for the four billed capabilities. Never accepted by the partner-feed routes.
  • X-Partner-Token — a per-partner bearer token (rafid_partner_<64 hex chars>), generated once at partner:create time and returned to the operator exactly once; only its SHA-256 digest is ever stored (data_partners.token_digest), mirroring how rafid_keys.digest protects a customer API key. Authenticates the ingestion endpoint below as exactly one partner — a token can never act as, or see, another partner's data.
  • X-Internal-Api-Key — a single shared secret (MARKET_DATA_INTERNAL_API_KEY) gating the two read-only internal endpoints below. Deliberately distinct from every partner token (a partner must never see cross-partner aggregate data) and from the customer API key. Unset means those routes always answer 503, never silently open.

Ingestion modes. CSV file upload, JSON file/array upload, and an authenticated HTTP feed — all three go through the exact same POST /api/v1/market-data/import endpoint and the exact same validating pipeline (domain/oman/importPipeline.ts) the operator-run market:import CLI already used; there is no separate write path and no scraping of any kind.

POST /api/v1/market-data/import — never public. Requires X-Partner-Token. The request body is either:

  • Content-Type: text/csv (or text/plain) with the CSV text as the body, in the exact column format below; or
  • Content-Type: application/json with a bare JSON array of records, or { "records": [...] }.

Response: { success: true, data: { partnerId, totalRows, imported, updated, skipped, rejected, averageDataQualityScore, rejections: [{ row, code, message }] } }. A malformed row never aborts the batch — it is rejected individually with its 1-based row number, a stable error code (see Rejection codes below), and a safe message; your original row data is never echoed back. Two rows in the same batch sharing a sourceRecordId but differing in content are treated as a legitimate same-batch update (last one wins, exactly as before); two rows that are otherwise byte-identical are treated as a true duplicate — the earlier occurrence is rejected with code DUPLICATE_RECORD and only the later one is written.

CSV format (identical columns to data/example-oman-market-import.csv; see data/example-partner-feed.csv for a full fictional example):

governorate,wilayat,area,propertyType,bedrooms,bathrooms,sizeSqm,transactionType,priceOMR,rentPeriod,furnished,sourceType,sourceName,sourceRecordId,sourceUrl,observedAt,metadata
Muscat,Muscat,Al Mouj,apartment,2,2,135,rental,820,monthly,furnished,partner_feed,Gulf Realty Oman,GRO-2001,https://example-partner.test/units/2001,2026-08-01,"{""floor"": 6}"

JSON format (identical field names as the CSV header; see data/example-partner-feed.json):

{ "records": [ {
  "governorate": "Muscat", "wilayat": "Muscat", "area": "Al Mouj", "propertyType": "apartment",
  "bedrooms": 2, "bathrooms": 2, "sizeSqm": 135, "transactionType": "rental", "priceOMR": 820,
  "rentPeriod": "monthly", "furnished": "furnished", "sourceType": "partner_feed",
  "sourceName": "Gulf Realty Oman", "sourceRecordId": "GRO-2001",
  "sourceUrl": "https://example-partner.test/units/2001", "observedAt": "2026-08-01",
  "metadata": { "floor": 6 }
} ] }

Required per row: governorate, area (must resolve to a supported Muscat area — English or Arabic spelling), propertyType (apartment/villa/townhouse), sizeSqm, transactionType (rental/sale), priceOMR, rentPeriod (required for rental, must be blank for sale), sourceType, sourceName, observedAt. Optional: wilayat, bedrooms, bathrooms, furnished, sourceRecordId, sourceUrl, metadata (a small JSON object). Never include a partnerId column — the row schema is strict and rejects unrecognized columns outright, so a file cannot even attempt to claim a partner identity; attribution comes only from the authenticated X-Partner-Token (or, for a CLI-run bulk import, an explicit operator-supplied partner id) and silently overwrites whatever sourceType/sourceName the row itself claims. This is deliberate: a partner's own file can never spoof another partner's identity or claim a provenance (e.g. official_statistics) it hasn't earned. sourceType/sourceName are still required columns for schema validation to pass — fill them with your own values (typically sourceType=partner_feed, sourceName=<your company name>) even though the server overwrites them, so the file is self-consistent if inspected on its own.

Validation — "reject impossible or suspicious values", not just malformed ones. Beyond type/enum checks, application-level bounds (tighter than the database's own wide CHECK constraints, which remain a last-resort safety net): sizeSqm between 10 and 3,000; a sale priceOMR between 3,000 and 20,000,000 OMR; a rental's monthly-equivalent price (annual rents divided by 12 first) between 30 and 15,000 OMR; observedAt no more than a day in the future and not before the year 2000. A row failing any of these is rejected individually, with a plain-English reason, exactly like a malformed or unrecognized-area row.

Deduplication strategy. Idempotent upsert on (sourceName, sourceRecordId) — the same partial unique index and INSERT ... ON CONFLICT ... DO UPDATE the base import pipeline already used. Because sourceName is always forced to the authenticated partner's own name, this is effectively (partnerId, sourceRecordId) in practice: re-sending the same sourceRecordId (whether in a later request or twice within the same batch) updates the existing record rather than duplicating it. A row with no sourceRecordId can never be safely matched against a prior import and is always inserted as new — exactly as documented for the base pipeline.

Data-quality scoring — deterministic, never an LLM. Every accepted record gets a dataQualityScore in [0, 1] (domain/oman/dataQualityScore.ts), a fixed weighted sum computed the same way every time: completeness of optional fields — bedrooms/bathrooms/furnished/sourceUrl/metadata (30%), size plausibility (15%), price plausibility (20%), freshness — linear decay to 0 at a two-year-old record (20%), and source identity — 1.0 for an authenticated partner or official statistics, 0.7 for the manual benchmark, 0.5 for an unattributed listing (15%). The import response's averageDataQualityScore is the batch mean; the CLI and HTTP paths both compute and store it identically.

Partner-feed statistics. data_partners tracks recordsReceived/recordsAccepted/recordsRejected/recordsUpdated/latestObservationDate per partner, updated after every import (HTTP or CLI) — see Partner Operations layer below for the full monitoring/health/audit surface built on top of this.

GET /api/v1/internal/market-data/status (X-Internal-Api-Key) returns exactly:

{ "records": 1250, "rentalRecords": 740, "saleRecords": 510, "areas": 8, "partners": 3, "latestDataDate": "2026-08-05T00:00:00.000Z", "sourceTypes": ["partner_feed", "listing_asking_price", "official_statistics"] }

Cross-partner aggregates only — no individual partner's token, digest, statistics or identity is ever exposed here (that detail lives behind .../partners, gated by the same key).

Provenance. analyze_oman_property's existing provenance/dataQuality.sourceTypes grouping (unchanged code — services/omanProperty.ts) already distinguishes partner_feed from official_statistics, manual_benchmark and listing_asking_price by (sourceType, sourceName), because sourceType/sourceName were already part of every MarketRecord; this layer only had to make sure partner-ingested records are honestly attributed at write time, which the forced server-side attribution above guarantees.

Onboarding the first Oman real-estate data partner — quick version (the full, actionable checklist is docs/FIRST_PARTNER_ONBOARDING.md):

  1. Agree a data license/contact reference with the partner out of band (a signed data-sharing agreement, a contract number) — this becomes dataLicenseReference/contactReference, never a person's name or phone number.
  2. Issue the partner's credentials from the operator's own machine (never over chat/email in plaintext beyond the initial handoff):

     npm run admin -- partner:create gulf-realty-om "Gulf Realty Oman" csv partner_feed "Data License #2026-014" "partnerships@example.com"

    This prints the new partner record and the bearer token exactly once — store it in the partner's secrets manager immediately; it cannot be retrieved again (rotate it — see Token rotation below — if it's ever compromised).

  3. Generate the partner's onboarding package and deliver it (see Onboarding package below) — it explains the endpoint, header, formats and rules without ever including the real token.
  4. The partner either calls the endpoint directly from their own systems (the http_feed feedType), or hands Rafid a file for an operator-run bulk import attributed to them:
     npm run market:import -- ./gulf-realty-export.csv gulf-realty-om
  5. Monitor onboarding with GET /api/v1/internal/market-data/status (aggregate), GET /api/v1/internal/market-data/partners (per-partner health) and GET /api/v1/internal/market-data/partners/:partnerId/quality (data-quality summary), all behind MARKET_DATA_INTERNAL_API_KEY.
  6. To pause a partner without losing its history, npm run admin -- partner:disable gulf-realty-om (its token immediately stops authenticating); partner:enable resumes it.

Testing. tests/partner-feed.test.ts covers partner authentication (valid/unknown/disabled/rotated tokens), CSV ingestion, JSON ingestion (array and {records:[...]} forms), idempotent upsert (across requests, within one batch, and true in-batch duplicates), invalid/rejected rows and their codes, the deterministic data-quality score and per-partner quality summary, partner provenance flowing into analyze_oman_property, stale-partner detection (including that one stale partner never marks another partner or the market stale), unauthorized-import rejection, token rotation, onboarding-package generation, the ingestion audit log, and the exact /api/v1/internal/market-data/status/.../partners response shapes — all against in-memory repositories, plus an opt-in PostgreSQL integration test (gated behind TEST_DATABASE_URL, matching tests/market-data.test.ts's pattern) covering the migration schema, PostgresPartnerRepository/PostgresPartnerIngestionAuditRepository, and the admin/market:import CLI commands against a live database. npm run test:partner-e2e (scripts/partner-e2e.ts) is a standalone, self-contained end-to-end rehearsal of the entire first-partner flow — create, issue token, ingest, re-ingest (idempotency), check health, run analyze_oman_property, disable, verify ingestion is then rejected — using only in-memory repositories, so it needs no database or production secrets and can run in CI.

Partner Operations layer (onboarding package, token rotation, ingestion audit, health monitoring, staleness policy)

Built directly on top of the Partner Data Feed layer above, to make onboarding and operating a real partner safe and practical without adding any new public capability, dashboard, or scraping. Every piece here is either the existing internal-key-gated JSON routes, or an operator-run CLI command — nothing is registered in domain/capabilities.ts.

Onboarding package. npm run admin -- partner:package <partnerId> [baseUrl] (domain/oman/partnerPackage.ts) writes partner-packages/<partnerId>/{README.md, sample.csv, sample.json, schema.json, curl-example.txt} (git-ignored) — a complete, self-contained explanation of the endpoint, required header, CSV/JSON formats, field meanings, allowed property/transaction types, rentPeriod/observedAt rules, max batch size, rejection codes, and retry/idempotency behavior. Never contains the partner's real token — every example uses a <PARTNER_TOKEN> placeholder; deliver the real token separately, through a channel appropriate for a credential.

Token rotation. npm run admin -- partner:rotate-token <partnerId> invalidates the current token immediately (a single UPDATE ... SET token_digest — no window where both the old and new token work), issues exactly one new token, prints it once, and records a token_rotated event in data_partner_audit_log (also recorded for created/enabled/disabled).

Ingestion audit log. partner_ingestion_audit (db/partnerOpsSchema.ts, migration v3; domain/oman/partnerAudit.ts's PartnerIngestionAuditRepository) records exactly one row per ingestion attempt that reaches an authenticated partner — success or failure: id, partnerId, requestId, receivedAt, recordsReceived, recordsAccepted, recordsRejected, recordsUpdated, httpStatus, durationMs, errorCode. It never stores the partner's token, the raw request body, or any individual record's fields — the row shape has no room for any of those. This is what powers the windowed figures in the health endpoint below without ever re-reading anything sensitive.

Partner health. GET /api/v1/internal/market-data/partners (X-Internal-Api-Key) now returns, per partner:

{ "partnerId": "gulf-realty-om", "partnerName": "Gulf Realty Oman", "enabled": true, "feedType": "csv",
  "latestIngestionAt": "2026-09-20T08:12:03.000Z", "latestObservationDate": "2026-09-18T00:00:00.000Z",
  "recordsAcceptedLast24h": 42, "recordsAcceptedLast7d": 310, "rejectionRateLast7d": 1.3,
  "stale": false, "staleDays": 7 }

latestIngestionAt (most recent ingestion attempt, from the audit log) is deliberately distinct from latestObservationDate (freshest accepted record's observedAt, from PartnerFeedStats as before) — a partner can have a very recent latestIngestionAt while every attempt was rejected, which is exactly what an operator needs to see. Never exposes a token, digest, contact detail, or license reference.

Staleness policy. PARTNER_FEED_STALE_DAYS (default 7; replaces the prior phase's MARKET_PARTNER_STALE_DAYS, default 30) — a partner is stale once no accepted fresh observation has arrived within the threshold. Staleness is always per-partner — one partner going stale never marks any other partner, or the market as a whole, stale; it never gates ingestion or analyze_oman_property either.

Rejection codes. Every rejected row's code is one of: INVALID_FORMAT, INVALID_AREA, INVALID_PROPERTY_TYPE, INVALID_TRANSACTION_TYPE, INVALID_SIZE, INVALID_PRICE, INVALID_BEDROOMS, INVALID_BATHROOMS, INVALID_RENT_PERIOD, INVALID_FURNISHED, INVALID_SOURCE_TYPE, INVALID_SOURCE_NAME, INVALID_URL, INVALID_DATE, INVALID_METADATA, DUPLICATE_RECORD. The accompanying message is a safe, human-readable summary — the rejected row's original values are never echoed back.

Data-quality summary. GET /api/v1/internal/market-data/partners/:partnerId/quality (X-Internal-Api-Key) returns { partnerId, recordCount, averageDataQualityScore, percentageAbove80, percentageBelow50, missingBedroomRate, missingBathroomRate, missingFurnishedRate }, computed with database-side aggregation (a single SELECT ... count(*) FILTER (WHERE ...) query in PostgresPropertyMarketRepository.getPartnerQualitySummary()) rather than reading every record into memory.

Security. The internal API key is compared with timingSafeEqual (unchanged from the prior phase); partner-token authentication is a SHA-256 digest lookup, never a raw-token comparison, so the value that would need to be timing-attacked is already a cryptographic digest, not the secret itself. Disabled partners fail X-Partner-Token auth immediately (already tested); a rotated-away token fails immediately too (new tests). Neither the internal API key nor any partner token, nor any raw ingested payload, is ever logged — the request logger records only the matched route template, status and duration — and the ingestion audit table's row shape structurally excludes all three.

First partner checklist. See docs/FIRST_PARTNER_ONBOARDING.md for the full, step-by-step operational checklist: agree data rights, create the partner, generate the onboarding package, securely deliver the token, send a test batch, verify accepted/rejected records and provenance, confirm analyze_oman_property surfaces partner_feed (and no demo-data flag when only partner data contributes), monitor the first 24 hours, then enable the partner's production schedule.

Production Feed Runner (scheduled HTTPS JSON/CSV ingestion — no scraping, no new public capability)

Built directly on top of the Partner Operations layer above, so the first real Oman partner feed can run on a schedule instead of a human pushing files by hand. Like everything else in the Partner Data Feed/Operations layers, this is infrastructure — domain/oman/partnerFeedRunner.ts, its CLI entry points, and its own database columns/table are never registered in domain/capabilities.ts and never appear in /agent.json, the tool catalog, remote MCP, or the x402 route family. It never touches analyze_oman_property itself, x402 pricing, or any other public capability.

Feed configuration model. Six new, all-optional columns on data_partners (migration v4, db/partnerFeedSchema.ts): feedUrl, feedFormat ("json" | "csv"), scheduleEnabled, scheduleIntervalMinutes, lastSuccessfulRunAt, lastAttemptAt, plus consecutiveFailures. Every partner created before this migration simply has no schedule configured — nothing to backfill. Set with npm run admin -- partner:set-feed <partnerId> <feedUrl> <json|csv> <true|false> [intervalMinutes] — a full replace of the schedule config each time (mirrors setEnabled()'s "one explicit value" simplicity), never a partial patch.

Security model (credentials). Feed authentication is never stored on PropertyDataPartner itself. A separate table, partner_feed_credentials (also migration v4; one row per partner: authType — bearer | api_key_header | none — plus secretRef and headerName), stores only a credential's shape. secretRef is a reference — an environment variable name — never a secret value; the actual value is resolved only at fetch time, in memory, for the duration of one outbound request, via an injectable SecretProvider abstraction (domain/oman/partnerFeedCredentials.ts). The bundled EnvSecretProvider reads process.env[secretRef]; a future deployment can swap in a real secrets-manager-backed provider without touching the feed runner or the credential schema. Set with npm run admin -- partner:set-feed-credential <partnerId> <bearer|api_key_header|none> [secretEnvVarName] [headerName] — the secret's actual value is never an argument to this command, only the name of the environment variable holding it.

SSRF controls. Every feed URL — and every redirect target, independently re-validated before being followed, never blindly trusted — must pass domain/oman/feedSecurity.ts's validateFeedUrl(): HTTPS-only by default (a test/dev-only allowInsecureHttp escape hatch exists but is never partner-influenced), rejects localhost/*.localhost/0.0.0.0, rejects private/loopback/link-local/reserved IPv4 and IPv6 ranges (checked both as a literal address and via DNS resolution of a hostname, through an injectable HostResolver so this is fully unit-testable without real DNS), and supports an optional exact-hostname allowlist. domain/oman/safeFeedFetch.ts layers the actual bounded HTTP GET on top: a timeout (AbortController), a streamed response-size cap (aborts mid-download rather than buffering an oversized body), and manual (redirect: "manual") redirect handling so a redirect to a different, unsafe host can never be followed silently.

Retry policy. domain/oman/feedRetry.ts retries only: a network/connection error, a request timeout, or an HTTP 502/503/504 — bounded exponential backoff (200ms, 400ms, 800ms, capped at 4s), maximum 3 attempts total. A 400, 401, 403, 404, or any validation/parse failure is never retried — repeating an already-wrong request would only fail identically.

Scheduling model. computeNextDueAt()/isFeedDue()/computeFeedHealth() (domain/oman/partners.ts) are pure, stateless functions shared by both the scheduling pass and the health endpoint, so "which partner is due" and "what does the health endpoint report" can never disagree. A partner that has never been attempted is due immediately, not after waiting a full interval from "now".

CLI commands.

  • npm run partner:feeds — one scheduling pass: identifies every enabled, due partner, runs each safely, prints a safe JSON summary ({"attempted": N, "successful": N, "failed": N, "results": [...] }), and exits. Never an always-running process — intended for an external scheduler (Vercel Cron, a GitHub Actions schedule: trigger, Windows Task Scheduler, plain cron).
  • npm run admin -- partner:run-feed <partnerId> — a single, on-demand run, for testing a partner before enabling its schedule. Prints only {partner, httpStatus, recordsReceived, recordsAccepted, recordsRejected, recordsUpdated, durationMs, auditId, ok, errorCode} — never the resolved credential header or a raw record.
  • npm run admin -- partner:set-feed / partner:set-feed-credential — see above.

Failure isolation (Section 7). PartnerFeedRunner.runDueFeeds() wraps each partner's run in its own try/catch — one partner's failure (however it fails) can never abort the loop or affect any other partner's run. Returns {attempted, successful, failed, results}.

Data safety. The feed runner calls the exact same importMarketRecords() every other ingestion path (the manual HTTP endpoint, market:import) already uses — never a duplicated or parallel validation/upsert implementation. Existing idempotent upsert rules, rejection codes, and the narrow DUPLICATE_RECORD semantics are entirely unchanged. A rejected batch never deletes existing records; raw partner data is never logged; credentials are redacted everywhere by construction (there is structurally no code path that stores or logs a secret value).

Feed health fields. GET /api/v1/internal/market-data/partners now additionally returns, per partner: lastFeedAttemptAt, lastFeedSuccessAt, consecutiveFailures, nextDueAt, and feedHealth ("healthy" | "degraded" | "stale" — consecutiveFailures >= 3 → "degraded", checked first; else the existing per-partner stale flag → "stale"; else "healthy"). Every existing Partner Operations field on this endpoint is unchanged. A partner's feed health never affects any other partner, or analyze_oman_property's availability globally.

Observability. Every completed run (success or failure) emits one structured JSON log line: {partnerId, requestId, attempt, status, httpStatus, durationMs, recordsReceived, recordsAccepted, recordsRejected} — no raw records, no tokens, no auth headers; every field is safe by construction, with nothing left to redact.

Tests. tests/partner-feed-runner.test.ts covers, entirely with mock HTTP (an injected fetchImpl and HostResolver — no real network or DNS): a successful JSON feed, a successful CSV feed, an HTTP timeout, HTTP 503 retried to success and retried to exhaustion (exactly maxAttempts calls, never more), HTTP 401 never retried (exactly one call), an oversized response, an unexpected content type, a malformed body, SSRF rejection of localhost, SSRF rejection of a hostname that resolves to a private IP, a redirect to a blocked host never followed, partner isolation in runDueFeeds(), idempotent re-fetch (no duplicate records), one audit row per attempt with a safe field set, consecutiveFailures/feedHealth transitions (including reset to 0 on the next success), secret redaction (a fake secret is sent to the mock feed endpoint but never appears in the outcome, logs, or audit rows), a misconfigured credential, and the three operator-misconfiguration guard cases (partner not found / disabled / unconfigured) that throw rather than recording a silent audit failure.

First live partner runbook. See docs/FIRST_LIVE_PARTNER_RUNBOOK.md for the full, step-by-step checklist: create the partner, generate the token/package, agree the feed URL/auth with the partner, configure the secret, test with partner:run-feed, inspect the audit log and data quality, verify provenance, run analyze_oman_property, verify no demo flag, enable the schedule, and monitor the first 24 hours.

Official market context (NCSI)

analyze_oman_property's response includes an officialMarketContext object sourced from Oman's National Centre for Statistics and Information (NCSI) Open Data Portal API — the first, and so far only, live external network integration this capability makes. It is deliberately not another comparable data source: NCSI publishes governorate-level aggregate statistics (a price index, traded value, contract counts), never individual listing or transaction records, so this integration never feeds NCSI data into comparablesSummary, market, pricePosition or the comparable-based confidence score — those remain exactly as documented above, unaffected by whether NCSI is configured at all. officialMarketContext carries its own, separate confidence (unavailable/low/medium/high) so an agent can't mistake "official statistics exist" for "the comparable estimate is more trustworthy."

Rafid does not scrape NCSI or any other property/statistics website. This integration calls NCSI's own documented REST API (services/ncsi/ncsiClient.ts) — the "Explore Api" served from map.ncsi.gov.om/ODPAPI — nothing else. NCSI's data is published under the Open Government License – Sultanate of Oman, which supports API/web-service reuse; this integration only reads NCSI's own published aggregate statistics and always attributes them back to NCSI (officialMarketContext.provenance.source/license) — Rafid claims no ownership of NCSI's data.

What was actually verified live, and what wasn't (read before configuring this): before writing NcsiClient, the live API was inspected directly rather than guessed at. Its OpenAPI/Swagger document (https://map.ncsi.gov.om/ODPAPI/swagger/v1/swagger.json) was successfully retrieved and confirms a real, working "Explore Api" v1 with no authentication required and exactly the endpoints this client implements: GET /catalog/datasets (list), GET /catalog/datasets/{id} (metadata), GET /catalog/datasets/{id}/records (query, with Select/Where/order_by/offset/limit/language parameters), and GET /catalog/datasets/{id}/download. However, every attempt to actually call the catalog-listing or records endpoints during development returned HTTP 500 — including a request for a deliberately nonexistent dataset id, which a healthy API would 404 on rather than 500 — indicating the live backend was temporarily broken at the time, not that a parameter or dataset id was missing. api.ncsi.gov.om (the second URL commonly referenced for this API) serves only a bare Swagger UI shell with no working spec or data routes found at any standard path. Because of this, no real dataset id and no real record field names could be verified against live data, and none are hardcoded anywhere in this codebase as a result.

Configuration is therefore intentionally inert until an operator verifies it:

  1. Run npm run ncsi:discover (retries the live catalog and filters titles/descriptions for real-estate keywords — English and Arabic). If NCSI's catalog backend is healthy when you run it, it prints candidate dataset ids/titles; if it 500s the way it did during development, it says so plainly rather than pretending to have found something.
  2. Once you have a confirmed dataset id, inspect a real response from GET /catalog/datasets/{id}/records (via the discovery output, curl, or NCSI support) to find the actual field names for governorate, period, price index, traded value, and sale/mortgage contract counts.
  3. Set NCSI_REAL_ESTATE_DATASET_ID to that id, and NCSI_FIELD_MAP_JSON to a JSON object mapping this capability's concepts to those exact field names, e.g.:

     NCSI_REAL_ESTATE_DATASET_ID=real-estate-price-index
     NCSI_FIELD_MAP_JSON={"governorate":"GOVERNORATE_EN","period":"PERIOD","priceIndexValue":"PRICE_INDEX","tradedValueOMR":"TRADED_VALUE","saleContracts":"SALE_CONTRACTS","mortgageContracts":"MORTGAGE_CONTRACTS","publishedAt":"LAST_UPDATED"}

    Only governorate is required; any concept you omit from the map is reported as null in the response rather than guessed — "Only populate fields actually supported by retrieved data" is enforced structurally, not by convention.

Until both are set, officialMarketContext.available is always false with reason: "ncsi_not_configured" and no network call is attempted at all — this is the example output shown in /openapi.json and the MCP tool catalog today.

Failure behavior: a timeout, a 5xx, a malformed response, or no record matching the requested governorate all resolve to officialMarketContext.available: false with a specific reason (ncsi_timeout / official_source_temporarily_unavailable / ncsi_malformed_response / no_data_for_governorate) — never a fabricated fallback figure, and never a failed analyze_oman_property call as a whole; the comparable-based analysis always completes independently (see tests/ncsi.test.ts's "an NCSI outage does not prevent the comparable analysis from completing").

Caching: a successful lookup is cached per governorate (domain/oman/officialContextCache.ts's MemoryOfficialMarketContextCache) for NCSI_CACHE_TTL_MS (default 6 hours, since official statistics are published quarterly/annually, not continuously) — a failed lookup is never cached, so the next call retries NCSI rather than pinning "unavailable" for the full TTL.

Testing: tests/ncsi.test.ts covers catalog/record parsing against a mocked HTTP layer (an injectable fetchImpl, never the network), field-map-driven parsing (missing metrics, stale records), every failure mode (timeout/5xx/4xx/malformed response), cache hit/expiry/no-cache-on-failure, and full-pipeline integration (NCSI down vs. healthy, zero-record dataset, the early-return unsupported-area path) — none of it depends on live NCSI availability. A separate, opt-in npm run test:ncsi-live (gated behind NCSI_LIVE_TEST=1 in .env, exactly like test:db is gated behind a live database) smoke-tests the real API when you want to re-verify it.

Known limitation: because the live catalog could not be queried successfully during development, this integration has not yet been exercised against a single real NCSI record — everything above is verified against the documented contract and mocked responses, not a live payload. Re-running npm run ncsi:discover and npm run test:ncsi-live once NCSI's backend is healthy (or once you have a dataset id/field list from NCSI directly) is the remaining step before this integration is proven end-to-end against real data.

Unified billing: API credits, subscriptions and every payment rail

Paid capabilities can be bought through any enabled rail, and the capability never knows which one paid: x402 (USDC, no account), prepaid API credits and subscription allowances on a Rafid API key (Authorization: Bearer raf_live_… — no wallet needed), L402 and MPP when enabled. Every rail charges the same canonical capability.price. The canonical route POST /api/v1/<tool-path> picks the rail deterministically (X-Rafid-Payment-Method: auto | credits | subscription | x402 | l402 | mpp), re-dispatching x402/L402/MPP credentials to their unchanged, protocol-correct gates. GET /api/v1/payment-methods lists the enabled rails; a caller without a usable credential gets 402 payment_required with that discovery instead of an x402-only answer. API-credit calls are atomic and concurrency-safe (PostgreSQL row locks + conditional updates, BIGINT micro-USD), idempotent (Idempotency-Key), fully ledgered, and refunded when the capability fails. Accounts, keys, credits and plans are managed with npm run billing -- … or the BILLING_ADMIN_SECRET-protected internal API; customers read GET /api/v1/account/{balance,usage,transactions}. MCP clients can pay with API credits via POST /mcp/credits, or run local stdio with RAFID_API_KEY set. Everything is inert until API_CREDITS_ENABLED / SUBSCRIPTIONS_ENABLED is set.

Full reference: docs/billing.md. Example client: examples/api-credits-client.

Pay-per-call via x402 (no API key)

GET /api/v1/x402 is always available, regardless of X402_ENABLED, and returns the protocol, scheme, whether payments are currently accepted, the network/receiving address (only when enabled — never a secret, just the public address), the facilitator in use, and per-tool price/endpoint. An agent can read this before deciding whether to pay, with no side effects.

GET /api/v1/x402/status is a smaller, always-on companion endpoint whose only job is to state the runtime facts plainly, so nothing has to be inferred from this README or the landing page's copy: { enabled, mode, network, asset, facilitator, walletConfigured, paymentEnforcement }. mode is "disabled", "testnet" (Base Sepolia — the public facilitator settles it, but no real value moves), or "production" (any other configured network, where a verified payment is a real on-chain transfer). enabled and paymentEnforcement are the same value by construction: this app only ever mounts the real @x402/express payment gate when X402_ENABLED=true, and loadConfig() fails startup closed if that gate's required configuration is missing or invalid — there is no state where the app is running with X402_ENABLED=true but the gate isn't actually enforcing. Never includes a secret.

When X402_ENABLED=true, the same four capabilities are also available, unauthenticated, under /api/v1/x402/...:

MethodEndpointPrice
POST/api/v1/x402/property/analyze$0.01
POST/api/v1/x402/property/compare$0.03
POST/api/v1/x402/maintenance/estimate$0.02
POST/api/v1/x402/oman/property/analyze$0.25

These routes accept the same JSON request/response bodies as their authenticated counterparts above, but skip the X-API-Key header entirely. Authorization is a valid on-chain payment instead: a request with no X-PAYMENT header (or an invalid/insufficient one) receives an HTTP 402 response. Per the x402 v2 protocol, the accepted payment options (accepts, prices, network) are carried in a base64-encoded JSON PAYMENT-REQUIRED response header, not the JSON body (the body is intentionally {}); decode that header (JSON.parse(Buffer.from(header, "base64").toString())) to read it. An x402-aware HTTP client does this automatically: it attaches a valid X-PAYMENT header for the quoted amount and network, and the request then proceeds normally. The price quoted always comes from BillingService.getToolPrice(), i.e. the same catalog /api/v1/pricing reads — the x402 gate cannot drift from the advertised price.

Payments settle in USDC on the network set by X402_NETWORK (default: Base Sepolia testnet, eip155:84532). By default, verification/settlement goes through the public facilitator at X402_FACILITATOR_URL (https://x402.org/facilitator, no account needed) -- but that free facilitator only settles Base Sepolia for EVM "exact" payments. To use any other network, including Base mainnet (eip155:8453), set both CDP_API_KEY_ID and CDP_API_KEY_SECRET (from a free Coinbase Developer Platform account, under API Keys — an Ed25519 Secret API Key, whose downloaded JSON file has id/privateKey fields that map to CDP_API_KEY_ID/CDP_API_KEY_SECRET): when both are set, the server authenticates to CDP's facilitator (https://api.cdp.coinbase.com/platform/v2/x402) instead, via the official @coinbase/x402 package, and X402_FACILITATOR_URL is ignored. Startup fails fast with a clear error if X402_NETWORK is anything but eip155:84532 and the CDP variables are not both set, rather than accepting payment requests it cannot actually settle. X402_WALLET_ADDRESS is the receiving wallet and must be set to an address you control before enabling this in production; the testnet default network is safe for development but moves no real money. These routes are entirely separate from the API-key/PostgreSQL customer system: no customer account, quota or usage record is created or consulted for x402 calls (though every call, on either route family, is still recorded through UsageRepository — see below). Disabling X402_ENABLED removes the routes entirely: they 404, and they are omitted from /openapi.json and the / discovery response — normal API-key calls are completely unaffected by this flag either way.

Current production status — stated precisely rather than as a blanket "live": the payment gate is real and operating on Base mainnet (eip155:8453) via the Coinbase CDP facilitator — production has returned a genuine HTTP 402 with correct terms (payTo matching the configured X402_WALLET_ADDRESS, network eip155:8453, price matching the catalog), which is only possible if the real @x402/express middleware is mounted, configured, and actually talking to the real facilitator. What has not yet been exercised in production is a completed mainnet payment settling all the way through to a tool executing — that would require sending real USDC. Base Sepolia testnet, where no real value moves, has been exercised fully end-to-end (a real funded test wallet completing a real payment against the public facilitator). Until a real mainnet payment has actually been completed and observed, the landing page deliberately says "x402 ready" rather than "live" for this reason, and GET /api/v1/x402/status is the authoritative place to check the current enforcement state rather than trusting any prose (this README included). There is no automated CI coverage for either live payment flow (the live tests are opt-in: RUN_X402_LIVE_TESTS=true npm test, which requires real internet access to the facilitator).

Pay-per-call via L402 (Lightning, no API key)

A second, independent payment rail next to x402 for agents that pay in bitcoin over the Lightning Network, implementing Lightning Labs' L402 protocol (formerly LSAT). Off unless L402_ENABLED=true; when off, /api/v1/l402/<tool> 404s and is left out of /openapi.json, /, /agent.json and /llms.txt. GET /api/v1/l402 (terms and USD prices per tool) and GET /api/v1/l402/status ({ enabled, mode, network, asset, backend, lightningBackendConfigured, rootKeyConfigured, paymentEnforcement }, never a secret) are always available.

Flow, for every capability (POST /api/v1/l402/property/analyze, /api/v1/l402/intelligence/research-company, …):

  1. Call without an Authorization header → 402 with WWW-Authenticate: L402 macaroon="<base64>", invoice="<bolt11>", plus the same details in the JSON body (l402.amountSats, priceUsd, btcUsdRate, tokenValidUntil).
  2. Pay the invoice with any Lightning wallet and keep the 32-byte preimage.
  3. Retry with Authorization: L402 <macaroon>:<preimage-hex> → 200 and the normal tool response.
  • Price: the tool's USD catalog price (BillingService.getToolPrice(), the same source as x402 and /api/v1/pricing), converted to sats at the live BTC/USD rate when the challenge is issued and rounded up. Rate sources: Coinbase spot, then Kraken, then a cached rate up to 15 minutes old, then L402_BTC_USD_FALLBACK if set. With none available the route returns 503 L402_PRICING_UNAVAILABLE; it never guesses a price.
  • Verification: purely cryptographic, with no node call on the paid path. The macaroon's HMAC chain is checked against L402_ROOT_KEY. Its caveats (service, capability, expires, amount_sats, price_usd, btc_usd) must all be present and valid, and sha256(preimage) must equal the payment hash signed into the macaroon identifier. Macaroons are standard V2 binary and byte-compatible with go-macaroon/js-macaroon, so standard L402 clients work unchanged.
  • One call per payment: a token is claimed atomically before the tool runs (rafid_l402_redemptions, PRIMARY KEY on the payment hash). A successful call burns it. A failed call (4xx/5xx) releases it so the payer can retry what they already paid for. A reused, expired, tampered or wrong-tool token gets a fresh 402 challenge plus an X-L402-Error header naming the reason, which L402 clients already handle by paying again.
  • Lightning backend: LND REST (POST /v1/invoices), authenticated with the least-privileged invoice macaroon, which cannot spend funds. Works with a self-hosted node (set LND_TLS_CERT for its self-signed cert) or a hosted one such as Voltage. If the node is unreachable, the route returns 503 L402_LIGHTNING_UNAVAILABLE; the error never leaks the node's host.
  • Voltage Payments backend (L402_BACKEND=voltage): instead of an LND node, invoices come from a hosted Voltage Payments wallet. Rafid sends POST /organizations/{org}/environments/{env}/payments (payment_kind: "bolt11", amount in msats, x-api-key header), then polls GET …/payments/{id} until the BOLT11 is ready (up to ~12 s). The payment hash is read from the invoice itself using a built-in, checksum-verified BOLT11 decoder, and the invoice's encoded amount must equal the amount requested or it is refused. It needs VOLTAGE_API_KEY, VOLTAGE_ORGANIZATION_ID, VOLTAGE_ENVIRONMENT_ID and VOLTAGE_WALLET_ID. Use a staging (Mutinynet) environment with L402_NETWORK=signet for testing. Ledger rows record facilitator: "voltage".
  • Accounting: each redeemed token writes one settlement_succeeded row to the same revenue ledger as x402. It has network: "lightning:<L402_NETWORK>", currency/asset: "BTC", sats in amountAtomic, BTC in amountDecimal, the payment hash as transactionHash (never the preimage) and facilitator: "lnd". It is deduplicated on the payment hash. Revenue totals keep BTC and USDC separate, and the internal dashboard shows BTC to 8 decimals. Reconciliation counts paid executions across both rails (analytics channel l402) and skips the USD price check for BTC rows. The amount_decimal column is widened automatically to numeric(24,10) on startup, which is lossless for existing rows.
  • Production requirements: either the LND settings (LND_REST_URL (https), LND_INVOICE_MACAROON (hex)) or the four VOLTAGE_* settings with L402_BACKEND=voltage, plus L402_ROOT_KEY (openssl rand -hex 32), and DATABASE_URL or L402_DATABASE_URL for cross-instance single-use enforcement. loadConfig() refuses to start if any of these is missing. See .env.example.
MPP: Machine Payments Protocol (charge + metered sessions, no API key)

MPP is a third payment rail. It runs alongside x402 and L402 and replaces neither. MPP is the Machine Payments Protocol co-authored by Tempo and Stripe. On the wire it uses the IETF "Payment" HTTP authentication scheme (WWW-Authenticate: Payment … / Authorization: Payment … / Payment-Receipt). Rafid implements it with the official TypeScript SDK, mppx (^0.11.0), and does not reimplement any of the protocol.

MPP is off unless MPP_ENABLED=true. When it is off, every /api/v1/mpp/* payment route returns 404 MPP_DISABLED and is left out of OpenAPI and discovery. Two routes are always available: GET /api/v1/mpp (terms and per-tool prices) and GET /api/v1/mpp/status (secret-free runtime status).

RailWhat it isUnit of payment
x402Pay-per-request, USDC on Baseone on-chain payment per call
L402Lightning pay-per-requestone BOLT11 invoice per call
MPP chargeOne-time machine payment (tempo/charge on Tempo, optional evm/charge = Base USDC through the existing CDP facilitator)one MPP credential per call
MPP sessionReusable authorization + metered machine usage (tempo/session: a TIP-1034 payment channel)one deposit per session, one signed voucher per call, settled once
Agent
  |
  +-- x402 ------------ POST /api/v1/x402/<path>
  |
  +-- L402 ------------ POST /api/v1/l402/<path>
  |
  +-- MPP Charge ------ POST /api/v1/mpp/charge/{tool}
  |
  +-- MPP Session ----- POST /api/v1/mpp/sessions            (authorize: open channel, deposit = budget)
        |
        +-- Tool Call - POST /api/v1/mpp/sessions/{id}/tools/{tool}   (voucher → run → meter)
        +-- Tool Call
        +-- Tool Call
        |
        +-- Meter ----- GET  /api/v1/mpp/sessions/{id}            (spent / remaining / per-tool usage)
        |
        +-- Settlement  POST /api/v1/mpp/sessions/{id}/close      (settle exactly the metered spend)

Architecture. The code lives in src/billing/mpp/ and is kept apart from the domain services. The capabilities stay protocol-agnostic: MPP runs the same capability.execute() as REST, x402, L402 and MCP.

FileRole
config.tsMPP_* parsing; fails closed at startup
provider.tsthe MppProvider interface (createCharge, verifyCharge, settleCharge, createSession, verifySession, sessionCallChallenge, verifySessionCall, recordUsage, settleSession, closeSession)
mppx.tsthe only file that talks to the SDK
service.tsorchestration
sessions.tspersistence
store.tsthe SDK's AtomicStore on Postgres
metering.tsmetering
settlement.tsrevenue-ledger rows
routes.ts, middleware.tsHTTP
mcp.tsoptional MCP binding
audit.tsaudit log
openapi.tsOpenAPI paths

To add another MPP method later (Stripe SPT, Solana, another EVM chain), write a new provider adapter. The API does not change.

Pricing. Prices always come from the capability registry (BillingService.getToolPrice()), so x402, L402 and /api/v1/pricing see the same numbers. Internally every amount is integer micro-USD. That equals one raw unit of the 6-decimal USD stablecoins MPP settles in. The server is authoritative: the SDK derives each route's payment request itself and rejects any credential whose amount, recipient, currency, realm, scope (rafid:charge:<tool>) or expiry differs. A client-supplied price, spent or remaining is never read.

Charge flow (POST /api/v1/mpp/charge/{tool}):

  1. The tool's JSON input is validated first. Invalid input returns 400 with no challenge and no payment.
  2. If there is no credential, the response is 402 with one WWW-Authenticate: Payment challenge per configured method. The body contains { protocol: "mpp", mode: "charge", tool, amount, currency: "USD", paymentRequired: true, challenges, problem }.
  3. If a credential is present:
    • It is verified without consuming it (validateCredential).
    • It is claimed as single-use (mpp_charge_redemptions).
    • The tool runs.
    • Only after the tool succeeds is the payment settled (broadcastCredential). The response carries the normal tool output, a payment block and a Payment-Receipt header.
  4. If the tool fails, the payment is not settled and the claim is released.
  5. If settlement fails, the result is withheld (402 MPP_SETTLEMENT_FAILED). This is the same rule x402 follows.
  6. A replayed credential gets 402 MPP_PAYMENT_REPLAYED.

Session flow. In the real protocol, a session is a Tempo payment channel.

  1. Open. POST /api/v1/mpp/sessions with { "maxBudget": 20, "currency": "USD", "allowedTools": [...] }:
    • Without a credential it creates a pending session (mpp_<32 hex>) and returns a 402 asking the payer to open a channel. The suggested deposit is the budget.
    • The budget and tools are bound into the challenge's HMAC'd meta. If they are changed, the request gets 409 MPP_TERMS_MISMATCH.
    • Retrying the same body with the open credential opens the channel on-chain. The response is 201, and the session is active with maxBudget = min(requested, deposit).
    • An open credential can only ever bind one session.
  2. Call. POST /api/v1/mpp/sessions/{id}/tools/{tool} with an Idempotency-Key header runs these steps in order:

    1. The session exists and is active.
    2. The tool is allowed.
    3. The price is read from the registry.
    4. The input is validated.
    5. The budget is reserved atomically. SELECT … FOR UPDATE plus a DB CHECK (spent + reserved <= max_budget) means concurrent calls can never overspend. An unaffordable call returns 402 MPP_SESSION_BUDGET_EXCEEDED { required, remaining } before it executes.
    6. The payer's voucher is verified without consuming it. Without a voucher, the response is 402 with a voucher challenge for this channel.
    7. The tool executes.
    8. Only on success, the voucher is accepted and the price is metered against the channel (tempo.session.charge()).
    9. spent, remaining, calls and per-tool usage are updated.

    The response is the normal tool output plus usage: { sessionId, tool, charge, spent, remaining, calls, status } (kept outside data). A failed tool call is never charged. Retrying the same Idempotency-Key returns the stored result and charges nothing. The same key with a different tool or input returns 422 MPP_IDEMPOTENCY_CONFLICT.

  3. Meter. GET /api/v1/mpp/sessions/{id} returns status, spent, remaining, calls, usageByTool, and the channel view (deposit, accepted voucher, metered, settled on-chain).
  4. Close. POST /api/v1/mpp/sessions/{id}/close is idempotent. It stops calls, finalizes metering and settles:
    • With the payer's channel close credential, the channel closes on-chain capturing exactly the metered spend, and the rest of the deposit is refunded. The response carries the SDK's Payment-Receipt. mppx's sessionManager.close() sends this credential as a body-less POST to the last URL it used (often a tool route), so every session route accepts a close credential before body parsing and never runs a tool for it. topUp credentials are refused (400): open a new session instead.
    • Without it, the server calls tempo.session.settle(), but only when the signed voucher equals the metered spend. That function captures the signed voucher, so it is never used when that would over-capture. Otherwise settlement.status is pending_payer_close.
    • Settled amounts are written to the revenue ledger once, as toolName: "mpp_session". The per-tool reconciliation leaves these rows out because they are not per-call settlements.
  5. States. A session is one of pending, active, exhausted, closed, expired (MPP_SESSION_TTL_SECONDS) or failed. Calls are rejected unless the session is active: exhausted → 402, closed → 409, expired → 410.
  6. Settlement states. settlement.status is one of:

    StatusMeaning
    not_startedNot attempted yet
    pendingAn attempt holds a lease (MPP_SETTLEMENT_LEASE_SECONDS), or its outcome is unknown (RPC failure after broadcast). Never reported as success
    pending_payer_closeNo safe server settle exists; only the payer's close credential can capture exactly the spend
    settledThe settle/close transaction's receipt was confirmed, or the chain already shows settled ≥ spent
    nothing_to_settleNothing was metered
    failedA definitive failure (settlement_error holds the reason); the reconciler retries

    Every attempt first claims the row with a conditional UPDATE (a lease), so concurrent closes and maintenance runs can never both submit. On-chain settlement is cumulative, so even a retry after an unknown outcome cannot capture twice, and the ledger only records the delta the chain reports. The settlement reference (tx hash), attempt count and last error are stored on the session.

Pending-session cleanup and abuse limits.

  • A pending session expires at challenge TTL + MPP_PENDING_GRACE_SECONDS (default 300 + 60 s). After that it can never be activated: activation is a conditional UPDATE … WHERE status = 'pending' AND expires_at > now. If a channel opens on-chain after expiry, the session is marked failed with the orphan channel id recorded, the payer gets 410 MPP_SESSION_EXPIRED with the channelId, and nothing is metered (close the channel to recover the deposit).
  • POST /api/v1/mpp/sessions is limited per client address (MPP_SESSION_CREATE_RATE_LIMIT_MAX per minute, the existing fixed-window limiter, on when RATE_LIMIT_ENABLED=true). Each client may also hold at most MPP_MAX_PENDING_SESSIONS_PER_CLIENT unpaid pending sessions (429 MPP_TOO_MANY_PENDING_SESSIONS). The client key is HMAC(MPP_SECRET_KEY, IP): the raw IP isn't stored, and a wallet address is never used because it is unproven before the channel opens.
  • Maintenance (GET or POST /api/v1/mpp/internal/maintenance, Authorization: Bearer <MPP_MAINTENANCE_SECRET or CRON_SECRET>, not in OpenAPI) does four things. Each is idempotent and safe on several instances at once (FOR UPDATE SKIP LOCKED, leases):

    1. expire overdue pending sessions
    2. expire active sessions past their TTL with nothing in flight (the channel is untouched)
    3. reconcile settlements (read the chain first, then settle only if it can't over-capture)
    4. delete expired never-opened sessions older than MPP_PENDING_RETENTION_DAYS (audit rows of opened sessions are kept)

    Without a secret it answers 503. There is no in-process scheduler. To schedule it on Vercel, set CRON_SECRET and add a cron to vercel.json, for example {"crons":[{"path":"/api/v1/mpp/internal/maintenance","schedule":"*/10 * * * *"}]}. Hobby plans only allow daily crons, so this repository doesn't ship one; any external scheduler that sends the bearer header works too. Lazy expiry on each request still applies in between runs.

  • GET /api/v1/mpp/status, when MPP is enabled, also reports:
    • configured
    • provider.reachable (a cached eth_chainId probe; never a payment)
    • database.ready, plus session and settlement counts
    • charge, session, network (chainId, observedChainId, testnet)

Persistence. Tables are created automatically, like every other store in this repository, in one DDL transaction under an advisory lock:

TableHoldsKeys and constraints
mpp_sessionssessionsunique external_session_id (one channel backs one session); named CHECKs: spent + reserved ≤ max, spent ≤ max, remaining ≥ 0, max ≤ requested, non-negative counters, valid settlement status, a channel required once active/exhausted/closed
mpp_usage_eventsusage eventsunique (session_id, request_id) = idempotency
mpp_charge_redemptionssingle-use charge credentialsprimary key on the challenge id; in_flight / redeemed / settlement_unknown (only a stale in_flight claim is ever re-claimable)
mpp_kvthe SDK's own channel state and replay markersa linearizable read-modify-write under a per-key advisory lock

MPP uses MPP_DATABASE_URL if set and falls back to DATABASE_URL. Production refuses to start MPP without a database.

Security.

  • Credentials are HMAC-bound, route-scoped and expiring. The SDK validates the challenge, signature, amount, recipient and scope.
  • Charge credentials are single-use.
  • A session's open credential cannot be replayed into a second session, and its terms cannot be tampered with.
  • Vouchers must come from the session's own channel.
  • Budget enforcement is atomic.
  • Settlement never captures unmetered authorization.
  • Structured audit events (one JSON line each) are emitted:

    • mpp.charge.challenge|verified|settled|replay_rejected
    • mpp.session.pending|pending_rejected|activated|call|usage_recorded|exhausted|budget_rejected|expired|closed|settlement_pending|settled|settlement_failed
    • mpp.payment.failed and mpp.maintenance.run

    They carry only allow-listed public fields (ids, amounts, tx hashes, reasons). Credentials, signed vouchers, receipts, raw payment headers, keys and secrets are never logged, and anything that looks like one is dropped.

  • A charge whose settlement outcome is unknown (network failure while broadcasting) keeps its credential claimed (settlement_unknown), withholds the result and answers 502 MPP_SETTLEMENT_UNCONFIRMED. It is never reported as success or as "not charged".

MCP. Normal MCP stays at /mcp, free and unchanged: MCP handles tool discovery and invocation, and MPP handles payment. With MPP_MCP_ENABLED=true, /mcp/mpp serves the MPP MCP transport binding:

  • A paid tools/call without payment fails with JSON-RPC -32042 and data.challenges.
  • The client retries with params._meta["org.paymentauth/credential"].
  • Results carry _meta["org.paymentauth/receipt"].

The official mppx/mcp/client McpClient.wrap() handles this automatically, and tests/mpp.test.ts exercises it end to end. For session mode over MCP, open the session over HTTP and pass _meta["com.rafidsystem/mpp-session"] = { sessionId, idempotencyKey } along with the voucher credential. initialize and tools/list are passed straight through to the standard MCP server. More detail is in docs/mpp.md.

Configuration (see .env.example):

VariableDefaultPurpose
MPP_ENABLEDfalsemaster switch
MPP_PROVIDERmppxprovider adapter
MPP_NETWORKtempotempo (chain 4217, USDC) or tempo-testnet (Moderato 42431, pathUSD)
MPP_MODEScharge,sessionenabled intents
MPP_CHARGE_METHODStempotempo and/or evm
MPP_CURRENCYUSDpricing currency (USD only)
MPP_SECRET_KEY—≥32 random bytes, HMAC for challenges (openssl rand -base64 32)
MPP_REALMapi.rafidsystem.comstable realm bound into challenges
MPP_TEMPO_PRIVATE_KEY—payee key; required for session mode (server-submitted close/settle)
MPP_TEMPO_RECIPIENTderived from the keyTempo payee address
MPP_TEMPO_CURRENCY, MPP_TEMPO_RPC_URLchain defaultsoverrides
MPP_EVM_NETWORKeip155:8453evm/charge network; mainnet needs CDP_API_KEY_*, like x402
MPP_EVM_RECIPIENTX402_WALLET_ADDRESSevm/charge payee
MPP_SESSION_TTL_SECONDS3600session lifetime
MPP_MAX_SESSION_BUDGET_USD / MPP_MIN_SESSION_BUDGET_USD1000 / 0.01budget bounds
MPP_REQUIRE_IDEMPOTENCYtruerequire Idempotency-Key on session calls
MPP_CHALLENGE_TTL_SECONDS300challenge lifetime
MPP_MCP_ENABLEDfalse/mcp/mpp binding
MPP_DATABASE_URLDATABASE_URLpersistence

Examples. examples/mpp-client/ contains charge.ts, session.ts (a $10 session, two tools, read the remaining budget, close) and bulk-supplier-check.ts (screen suppliers until the budget runs out, then close and settle).

# Unpaid quote (no wallet needed):
curl -si -X POST https://api.rafidsystem.com/api/v1/mpp/charge/analyze_oman_property \
  -H 'Content-Type: application/json' \
  -d '{"governorate":"Muscat","area":"Al Mouj","propertyType":"villa","bedrooms":4,"sizeSqm":420,"askingPriceOMR":450000}'
# → HTTP/1.1 402 … WWW-Authenticate: Payment id="…", method="tempo", intent="charge", request="…"

# Start a session (402 → pay the open challenge with an MPP client → 201):
curl -si -X POST https://api.rafidsystem.com/api/v1/mpp/sessions -H 'Content-Type: application/json' \
  -d '{"maxBudget":20,"currency":"USD","allowedTools":["oman_supplier_check","analyze_oman_property"]}'

MCP usage

MCP is one of Rafid's two primary agent interfaces (alongside x402), not an afterthought bolted onto the REST API. The installed MCP SDK v2 is retained; see the official SDK documentation. Two transports are available, and both are built by the exact same createMcpServer() factory (src/mcp/server.ts) reading the exact same capabilities registry — there is no second tool registry, schema set, description set, or execute() path to keep in sync by hand:

  • Local stdio — build first, then run npm.cmd run mcp for a manual local process.
  • Remote Streamable HTTP — POST /mcp on the deployed server, mounted whenever MCP_REMOTE_ENABLED=true (the default). Stateless (sessionIdGenerator: undefined): required, not just simpler, since a serverless deployment (Vercel) can route consecutive requests to different warm instances with no shared memory, so no server-side session state can be relied on. GET /api/v1/mcp/status always reports which transports are actually live: { enabled, transport: ["stdio","http"], tools: 4, endpoint: "/mcp" } (or endpoint: null/transport: ["stdio"] when remote is disabled).

For local stdio, launch Node directly so npm banners cannot contaminate protocol stdout:

{
  "mcpServers": {
    "rafid": {
      "command": "node",
      "args": ["C:/Projects/rafid-agent-api/dist/mcp.js"],
      "env": { "LOG_LEVEL": "info", "X402_ENABLED": "false" }
    }
  }
}

Use your own absolute project path. For remote Streamable HTTP, point an MCP client at the deployed URL instead of a local command, for example:

{
  "mcpServers": {
    "rafid": { "url": "https://api.rafidsystem.com/mcp" }
  }
}

Or drive it directly with JSON-RPC 2.0 over HTTP:

curl -s https://api.rafidsystem.com/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"analyze_property","arguments":{"propertyValue":85000,"annualRent":7200,"serviceCharge":650,"maintenanceCost":400}}}'

Tools on both transports: analyze_property, compare_properties, estimate_maintenance, analyze_oman_property. Tool results include JSON text and structuredContent, with strict input/output schemas, validation errors and read-only annotations; each tool's MCP description is its registry description plus its whenToUse recommendation sentence, verbatim — never a second copy of that prose. Custom cross-field constraints (alias exclusivity, unique names) are enforced at runtime, and every error on either transport goes through the same publicError() sanitizer as REST, so an internal failure never leaks internals remotely any more than it does locally.

MCP and REST are not two implementations that happen to agree: all three (stdio, remote MCP, REST) call the exact same capabilities catalog (src/domain/capabilities.ts) and the exact same services/property.ts functions, so a formula or price change made in one place is correct everywhere, and there is nothing to keep in sync by hand.

Paying with API credits over MCP (unified billing, see docs/billing.md): point a remote MCP client at POST /mcp/credits with Authorization: Bearer raf_live_… — initialize/tools/list behave exactly like /mcp, and each paid tools/call is billed to the key's account (subscription allowance, then prepaid credits; result._meta["com.rafidsystem/billing"] carries the charge). Or run local stdio in hosted mode — RAFID_API_KEY=raf_live_… node dist/mcp.js forwards every tool call to the hosted API (RAFID_API_URL) where it is billed. MPP over MCP stays at /mcp/mpp; x402 and L402 are HTTP-only rails. /mcp itself is unchanged.

Stdio is local to the launching OS user; REST API keys are not an authentication mechanism for stdio, and stdio calls remain deliberately unmetered — an MCP client's own OS user launched that process directly, with no shared server resource to protect. Remote MCP is different: it is a shared, public server resource, so every remote tool call is recorded through the same UsageRepository as REST/x402 calls (accessMode: "mcp-remote", billableAmount: 0 — remote MCP is not a paid channel in this phase) and is protected by the same rate limiting as the other public agent endpoints (see "Security, observability and billing boundary" below). Logs go to stderr on both transports; stdout is reserved for MCP stdio framing. Remote auth (beyond rate limiting) is not implemented yet — see "Next five production priorities" below.

Security, observability and billing boundary

API-key middleware compares SHA-256 digests with constant-time comparisons. Keys come from PostgreSQL in postgres mode and from configuration in legacy development mode. Request logs contain only generated request ID, matched route template/tool name, status, timestamp and duration; bodies, headers, query strings, raw API keys and unknown URL paths are not logged. Internal exceptions return a generic message. Every response — success or error — carries a Cache-Control: no-store, X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: no-referrer header, plus a permissive-but-safe CORS policy (Access-Control-Allow-Origin: *) that is safe here because every route authorizes itself with a request header (X-API-Key or X-PAYMENT) rather than an ambient browser cookie or session — there is no credentialed state a cross-origin page could ride on. Request bodies are capped at 32kb (413 PAYLOAD_TOO_LARGE) before parsing.

createApp accepts an injectable rateLimiter middleware after authentication and before body parsing, for authenticated PostgreSQL-mode customer traffic. Separately, middleware/rateLimit.ts's createRateLimiter() protects the public, unauthenticated agent endpoints — discovery (/, /agent.json, the .well-known manifests, /llms.txt, /api/v1/agent, /api/v1/pricing, /api/v1/tools, /api/v1/capabilities, /api/v1/mcp/status), the whole /api/v1/x402/... family, and remote MCP (/mcp) — each as its own independent, IP-keyed, fixed-window budget (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX, both configurable; RATE_LIMIT_ENABLED=false to disable for local development). This is an honest, per-process, in-memory default, not a distributed limiter: on a serverless platform running several concurrent instances (Vercel), each instance enforces the limit independently, so the effective ceiling across a whole deployment can exceed RATE_LIMIT_MAX under traffic spread across instances — a production deployment expecting many concurrent instances should swap in a shared-store limiter (e.g. Upstash Redis) behind the same RequestHandler shape; nothing else in this app needs to change to do that. Proxy trust is not enabled. Deploy behind HTTPS; do not publicly expose the MVP without these controls.

The isolated billing catalog (billing/catalog.ts) has indicative USD prices: analysis $0.01, comparison $0.03, maintenance $0.02, Oman property analysis $0.25. It is the single source of truth: BillingService (billing/service.ts) is the only code that reads it, and every other module — route handlers, the x402 gate, the agent/pricing/tools endpoints — asks BillingService for a price rather than reading the catalog or a literal directly. REST calls the injectable BillingGate.authorize after validation and before calculation; its default implementation does nothing and must not be interpreted as billing. This seam allows future credit or usage-billing adapters without changing calculation services. PostgreSQL mode supplies a durable customer UUID; env-key mode retains a configuration-local identity. Stdio remains unmetered local execution.

Every capability call — API-key, x402, or remote MCP — and regardless of outcome (success, validation failure, auth failure, rate limit), is recorded through a UsageRepository (billing/usage.ts): request ID, a redacted identifier (the existing customer ID, an x402:<network> tag, or mcp-remote — never a raw API key, wallet secret, or payment proof/signature), tool name, accessMode ("api-key" / "x402" / "mcp-remote"), status, duration, billable amount and currency. USAGE_REPOSITORY selects the store: console (default — one JSON line to stderr per call, wired by server.ts's ConsoleUsageRepository), memory (local inspection/tests only, lost on restart), or postgres (PostgresUsageRepository, a durable, queryable rafid_agent_usage table — its own table, independent of the customer store in db/store.ts, created on first use with CREATE TABLE IF NOT EXISTS, reusing DATABASE_URL). Swapping repositories requires no change to any route handler or to mcp/remote.ts.

When X402_ENABLED=true, @x402/express, @x402/core and @x402/evm gate a separate, unauthenticated /api/v1/x402/... route family (see "Pay-per-call via x402" above). Payment verification and settlement are delegated entirely to the configured facilitator (the public one, or CDP's when CDP_API_KEY_ID/CDP_API_KEY_SECRET are set -- see above); this repo does not implement wallet custody, settlement or replay protection itself. Never commit or log CDP_API_KEY_SECRET; store it the same way as DATABASE_URL.

Next five production priorities

  1. Customer keys and abuse controls: persistent customer identities, hashed keys, issuance/rotation/revocation, shared per-customer quotas, and edge limits before authentication. A per-process IP-keyed rate limiter now protects discovery, x402 and remote MCP (see "Security, observability and billing boundary" above); still needed: a distributed limiter for multi-instance deployments, and per-customer quotas for the x402/remote-MCP route families specifically.
  2. Durable usage and billing: x402 pay-per-call is implemented and live on both Base Sepolia and Base mainnet; UsageRepository now has a durable PostgreSQL-backed implementation (USAGE_REPOSITORY=postgres) alongside console/memory. Still needed: x402 receipt reconciliation, and a second billing adapter (e.g. credits/subscriptions) for customers who prefer not to pay per call in crypto, plus monetizing remote MCP itself (it is currently a free, unauthenticated channel).
  3. Deployment and operations: HTTPS deployment, secret management, CI checks, dependency scanning, metrics/alerts, load testing, backups and recovery drills.
  4. Authenticated, distributed remote MCP: the Streamable HTTP transport now exists at /mcp (stateless, sharing the exact same registry/service layer as stdio — see "MCP usage" above) with basic IP rate limiting and usage recording. Still needed: durable identity/authorization for remote callers, origin/host validation appropriate for a public (not localhost-bound) deployment, and a distributed rate limiter for multi-instance production traffic.
  5. Trusted property intelligence: analyze_oman_property now has a production-ready data architecture: async capability execution throughout REST/x402/MCP, a PropertyMarketRepository abstraction with a PostgreSQL-backed implementation and its own migration ledger, a validated CSV/JSON import pipeline (npm run market:import), configurable provider modes (OMAN_PROPERTY_DATA_MODE=manual|database|composite), per-response data freshness/staleness reporting, dynamic (not hard-coded) demo-data risk flagging, an optional comparable cache, a real (though not yet live-verified) integration with NCSI's official statistics API for governorate-level market context (officialMarketContext), and — as of this phase — a production-ready Partner Data Feed layer (authenticated CSV/JSON/HTTP ingestion, per-partner tokens, idempotent upsert, deterministic non-LLM data-quality scoring, partner-feed health/staleness, and internal aggregate-status reporting, entirely outside the agent-capability registry) that finally gives a real Oman real-estate company, broker or property manager a documented, authorized path to supply market data — see "Oman property analysis (Muscat)", "Production Oman market data", "Partner Data Feed" and "Official market context (NCSI)" above. Comparable selection, outlier removal and confidence scoring are unchanged regardless of data source, and NCSI context is kept structurally separate from comparable-based figures. Still needed: NCSI's live catalog endpoint to actually respond (it returned HTTP 500 for every request during development — see "Official market context (NCSI)" for the full finding) so a real NCSI_REAL_ESTATE_DATASET_ID/NCSI_FIELD_MAP_JSON can be verified and this integration exercised against real data; actually onboarding a first real (not fictional) Oman real-estate data partner through the new Partner Data Feed layer (ListingDataProvider remains a real, wired-in, but always-empty seam pending that); broader Muscat/Oman area coverage; and calibrated maintenance assumptions with provenance/freshness/formula versions for estimate_maintenance before adding further ROI, service-charge, lease or facility tools.
  6. Rafid Agent Intelligence, Phase 2/3: Phase 1 (research_company, find_companies, analyze_company_risk — see "Rafid Agent Intelligence" above) is implemented, tested and inert-by-default. Still needed: Phase 2 (research_web, deep_research) and Phase 3 (extract_document, analyze_document, architecture prep only) from the same expansion spec; this README's REST API table above still does not list the four pre-existing Oman business capabilities (search_oman_company, get_oman_company_profile, analyze_oman_company, due_diligence_oman_company) either — a pre-existing documentation gap, not introduced by this phase, worth closing in the same pass as Phase 2.

See implementation review for the initial findings, exact scope and verification.

Postman API Network

Explore the Rafid Agent API public Postman workspace for runnable developer requests:

  • Discover available capabilities: GET /api/v1/capabilities
  • Inspect supported payment methods: GET /api/v1/payment-methods
  • Check x402 availability: GET /api/v1/x402/status
  • Preview company due diligence: POST /api/v1/preview/company_due_diligence

API base URL: https://api.rafidsystem.com. Preview and discovery endpoints do not require payment; full paid capabilities follow the payment methods advertised by the API. Do not paste credentials, payment signatures, or private keys into public workspaces.

Frequently asked questions

What is Rafid Intelligence Network?

Paid intelligence tools for AI agents across risk, finance, property, logistics and more.

How do I install Rafid Intelligence Network?

It runs as a remote server. In Claude Code: claude mcp add --transport http rafid-intelligence-network 'https://api.rafidsystem.com/mcp'. For other clients, paste the JSON configuration from this page into their MCP settings.

Does Rafid Intelligence Network need an API key?

Its registry entry lists no required key or secret. Some features may still need an account with the service it connects to.

Is Rafid Intelligence Network open source?

Its code is public on GitHub, but the repository does not declare a standard license.

Who maintains Rafid Intelligence Network?

It is published by iabdullahm. AI.tn lists it but does not maintain or endorse it.

From the official MCP Registry. Listing is not an endorsement: review a server's code and permissions before giving it access to your data.