SAA-v1.1

Full Document

v1.1Latest

Service API Architecture: Implementation Guide

Comprehensive guide for implementing the Instruction-Based Service API Architecture as standalone, independent servers.

Document

v1.1

Updated

Dec 8, 2025

Reference

nuvion-core

Status

Production Ready

Revision Notes: v1.1 incorporates architectural clarifications, idempotency implementation, transaction consistency strategy, webhook/callback server documentation, dynamic provider loading, and updated request payload structure with unique_reference field.

01

Executive Summary

This document provides a comprehensive guide for implementing the Instruction-Based Service API Architecture as standalone, independent servers. Each Service API (Account Issuing, Payouts, Card Issuing, etc.) is a separate Node.js application that follows the nuvion-core structural patterns.

The architecture decouples the Core Service from third-party provider implementations (Flutterwave, Stripe, Fuse, etc.) through a unified, versioned instruction protocol.

Provider Independence

Switch providers without changing Core Service code

Service Isolation

Each Service API is an independent server with its own lifecycle

Provider-Specific Validation

Each provider has custom VSL specs for their requirements

Version Management

Clients can specify instruction versions (v1, v2) for backward compatibility

Scalability

Easy addition of new providers and services

Type Safety

VSL-based validation ensures data integrity

Critical Architectural Points

  • 1One Service API = One Server: Each Service API (Account Issuing, Payouts, Cards) runs as a separate Node.js application on its own port
  • 2Provider-Specific Specs: Each provider has different VSL specs for the same instruction (FLW's create.account ≠ STR's create.account)
  • 3Versioned Instructions: Instructions include version (v1, v2) to support multiple spec versions over time

Structural Template

Each Service API is a standalone Node.js application modeled after the nuvion-core structure:

Repository Pattern@app-core/repository-factory
Service Layerservices/ directory
ValidationCustom VSL validator
Error Handling@app-core/errors
Logging@app-core/logger
EndpointscreateHandler() pattern
02

Architecture Overview

The instruction protocol provides a uniform interface for all Service APIs. Instead of exposing multiple endpoints, each Service API exposes a single /instructions endpoint.

{
  "instruction": "create.account",
  "version": "v1",
  "unique_reference": "client-generated-uuid-12345",
  "provider": {
    "id": "FLW",
    "meta": {
      "region": "NG",
      "currency": "NGN"
    }
  },
  "payload": {
    "entity_id": "01HQWXYZ1234567890ENTITY",
    "customer_name": "Merchant One",
    "kyc_reference": "REF-12345",
    "currency": "NGN"
  }
}
instruction

The action to perform (e.g., create.account, list.accounts)

version

API version for the instruction spec (e.g., v1, v2)

unique_referenceNEW

Client-generated unique identifier for idempotency

provider.id

3-character provider code (e.g., FLW, STR, THR)

provider.meta

Optional routing hints (region, currency, etc.)

payload

Provider-specific data validated against spec

03

Service API as Standalone Server

Each Service API is a separate Node.js application, not a module within nuvion-core.

Deployment Architecture

Core Service (nuvion-core)

Entity Management, Authentication, Orchestration

Port 3000
HTTP/gRPC
Port 3001

Account Issuing Service API

/instructions endpoint

FLW, STR, THR providers

Port 3002

Payouts Service API

/instructions endpoint

FLW, STR, PSP providers

Port 3003

Card Issuing Service API

/instructions endpoint

FLW, STR, UNI providers

Port 3004

Blockchain Service API

/instructions endpoint

BTC, ETH, SOL providers

Repository Structure

Each Service API has its own Git repository and follows the nuvion-core structure:

nuvion-coreCore Service
nuvion-account-issuing-apiService API
nuvion-payouts-apiService API
nuvion-card-issuing-apiService API
nuvion-blockchain-apiService API
04

Directory Structure

Each Service API is a standalone application with this structure (modeled after nuvion-core):

nuvion-account-issuing-api
package.json
app.js
bootstrap.js
core/
errors/
logger/
validator/
mongoose/
http-request/
repository-factory/
server/
security/
endpoints/
instructions/
services/
orchestrator/
providers/
models/
index.js
account-base.js
FLW-account.js
STR-account.js
repository/
middlewares/
messages/

Key Differences from nuvion-core

  • 1.Single Endpoint: Only /instructions endpoint
  • 2.Provider-Specific Specs: Each provider has own specs folder
  • 3.Service-Focused: Handles one service domain only
  • 4.No Identity Management: Auth delegated to Core Service

Highlighted Folders

The endpoints/ and services/ folders contain the core implementation logic. Click on folders in the tree to expand and explore the structure.

05

Implementation Layers

Instruction Handler (Endpoint Layer)

Implemented as a standard endpoint using createHandler().

Key Responsibilities:

  • • Envelope Validation: Validates instruction envelope (instruction, version, provider, payload)
  • • Client Authentication: Verifies service-to-service authentication
  • • Context Augmentation: Adds request context from Core Service
  • • Delegation: Passes validated data to orchestrator
  • • Response Formatting: Returns standardized response

endpoints/instructions.js

module.exports = createHandler({
  method: 'post',
  path: '/instructions',
  middlewares: [verifyClient],
  props: {
    allowedAuthMethods: { jwt: true, sk: true },
    requireServiceKey: true,
  },
  async handler(rc, helpers) {
    // Validate instruction envelope structure
    const data = validator.validate(rc.body, parsedEnvelopeSpec);

    // Add request context from Core Service
    data.context = {
      client_id: rc.meta.client?.id,
      request_id: rc.headers['x-request-id'],
      timestamp: Date.now(),
    };

    // Pass to orchestrator
    const response = await orchestrator(data);

    return {
      status: helpers.http_statuses.HTTP_200_OK,
      message: 'Instruction executed successfully',
      data: response,
    };
  },
});

Note: The payload is NOT validated here—it's validated in the orchestrator against provider-specific specs.

06

Provider-Specific Specs & Versioning

Each provider has different requirements for the same instruction. This is why specs are provider-specific, not universal.

Why Provider-Specific Specs?

Spec Organization

Each provider has its own specs/ folder with versioned subdirectories:

services/providers/FLW/specs/
v1/
create-account.vsl.js
list-accounts.vsl.js
get-account.vsl.js
v2/
create-account.vsl.jsUpdated requirements
services/providers/STR/specs/
v1/
create-account.vsl.jsDifferent from FLW!
list-accounts.vsl.js
services/providers/THR/specs/
v1/
create-account.vsl.jsDifferent from both!
list-accounts.vsl.js

Pre-Parsed VSL Spec Examples

IMPORTANT: Specs are pre-parsed at module load time, not on each request. This improves performance since VSL specs are immutable once deployed.

services/providers/FLW/specs/v1/create-account.js

const validator = require('@app-core/validator');

// Flutterwave-specific create.account spec (v1)
const vslSpec = `root { // FLW create account v1
  entity_id string<length:26>
  customer_name string<trim|minLength:1|maxLength:255>
  bvn string<length:11>
  email string<trim|lowercase|isEmail>
  mobilenumber string<minLength:10|maxLength:15>
  phonenumber string<minLength:10|maxLength:15>
  currency string(NGN|KES|GHS|ZAR|USD)
  type string(virtual|physical)
  nin? string<length:11>
  meta? object
}`;

// Parse spec once at module load
const parsedSpec = validator.parse(vslSpec);

// Export the pre-parsed spec
module.exports = parsedSpec;

Key Points: Specs are parsed once when the module loads, not on every request. Each spec file exports the pre-parsed spec object. This is more efficient since VSL specs don't change at runtime.

Provider Dictionary Tree Structure

The providers/index.js file is the single source of truth - it exports all providers in a dictionary tree structure using dynamic loading.

services/providers/index.js

const fs = require('fs');
const path = require('path');
const { appLogger } = require('@app-core/logger');

// Dynamically load all providers from directory
const providers = {};
const providersPath = __dirname;

// Read all directories in providers folder
const providerDirs = fs
  .readdirSync(providersPath, { withFileTypes: true })
  .filter((dirent) => dirent.isDirectory())
  .map((dirent) => dirent.name)
  .filter((name) => name.length === 3 && name === name.toUpperCase());

// Load each provider
for (const providerId of providerDirs) {
  try {
    const providerPath = path.join(providersPath, providerId);
    providers[providerId] = require(providerPath);
    
    appLogger.info(
      { 
        provider: providerId, 
        versions: Object.keys(providers[providerId].specs),
        instructions: Object.keys(providers[providerId].specs.v1 || {}),
      },
      'provider-loaded'
    );
  } catch (error) {
    appLogger.errorX({ error, provider: providerId }, 'provider-load-error');
  }
}

appLogger.info({ providers: Object.keys(providers) }, 'all-providers-loaded');

module.exports = providers;
ApproachProsCons
Dynamic Loading
  • • No manual updates needed
  • • Auto-discovers new providers
  • • Easier to scale
  • • No IDE autocomplete
  • • Slightly slower startup
  • • Harder to debug
Manual Imports
  • • Full IDE autocomplete
  • • Explicit and clear
  • • Compile-time checks
  • • Must update for each provider
  • • Easy to forget

Recommendation: Use dynamic loading for maintainability. The loss of IDE autocomplete is acceptable since provider IDs come from runtime data (API requests), not hardcoded values.

Resulting Dictionary Tree Structure

Each provider exports a dictionary tree with specs organized by version and instruction for O(1) lookups:

{
  FLW: {
    specs: {
      v1: {
        'create.account': <parsed spec object>,
        'list.accounts': <parsed spec object>,
        'get.account': <parsed spec object>,
        'update.account': <parsed spec object>
      },
      v2: {
        'create.account': <v2 parsed spec object>,
        'update.account': <v2 parsed spec object>
      }
    },
    metadata: {
      regions: ['NG', 'KE', 'GH', 'ZA'],
      currencies: ['NGN', 'KES', 'GHS', 'ZAR', 'USD']
    },
    execute: <function>
  },
  STR: {
    specs: {
      v1: {
        'create.account': <parsed spec object>,
        'list.accounts': <parsed spec object>,
        'get.account': <parsed spec object>
      }
    },
    metadata: {
      regions: ['US', 'EU', 'UK'],
      currencies: ['USD', 'EUR', 'GBP']
    },
    execute: <function>
  },
  THR: {
    specs: {
      v1: {
        'create.account': <parsed spec object>,
        'list.accounts': <parsed spec object>
      }
    },
    metadata: {
      regions: ['NG', 'GH'],
      currencies: ['NGN', 'GHS']
    },
    execute: <function>
  }
}

Cross-Provider Field Mapping

The Core Service sends a unified payload with kyc_reference, but each provider expects a different field:

Unified Field

kyc_reference

FLW

bvn

STR

ssn

THR

nin
07

Data Models & Repository Pattern

Models follow a base + provider-specific extension pattern. The base model defines the unified interface, while provider models add specific fields.

Base Model

Unified interface for all providers

_id, provider_id, provider_account_id, entity_id, account_number, account_name, bank_name, bank_code, currency, status, type, meta

FLW-Specific Fields

Extended from base schema

bvn_verified, flw_merchant_id, settlement_frequency, nuban, flw_reference

repository/FLW-account/index.js

const repositoryFactory = require('@app-core/repository-factory');

// Create repository for FLW accounts
// The model name must match the export from models/index.js
module.exports = repositoryFactory('FLW_accounts', {});
08

Service Layer Implementation

Services follow the existing pattern with additional organization for providers and versioning.

Service Conventions

Single Responsibility

Each service handles one instruction

Input Validation

Use VSL validator for all inputs

Error Handling

Use throwAppError with standardized messages

Logging

Use appLogger with consistent log keys

Single Exit Point

One return statement per function

No Console.log

Always use appLogger

09

Endpoint Layer Implementation

Each Service API has exactly ONE endpoint: /instructions

app.js (Service API configuration)

// IMPORTANT: Each Service API has exactly ONE endpoint
const ENDPOINT_CONFIGS = [
  { path: './endpoints/instructions/' }, // Single endpoint: /instructions
];

Why only one endpoint?

  • • Each Service API is a standalone server (Account Issuing, Payouts, Cards)
  • • Each exposes only /instructions endpoint
  • • Different Service APIs are separate repositories and deployments

Example:

  • • nuvion-account-issuing-api → Port 3001 → /instructions
  • • nuvion-payouts-api → Port 3002 → /instructions
  • • nuvion-card-issuing-api → Port 3003 → /instructions
10

Security & Authentication

Client Authentication

  • JWT Tokens: For entity-level access (verifySession middleware)
  • Secret Keys: For service-to-service communication
  • Signature Validation: For webhook callbacks
11

Complete Implementation Example

End-to-end example for the Account Issuing Service API.

Request (Flutterwave v1)

POST /instructions
Content-Type: application/json
X-Service-Key: <service_key>

{
  "instruction": "create.account",
  "version": "v1",
  "unique_reference": "req-flw-12345",
  "provider": {
    "id": "FLW",
    "meta": { "region": "NG" }
  },
  "payload": {
    "entity_id": "01HQWXYZ...",
    "customer_name": "Test Merchant",
    "bvn": "12345678901",
    "email": "test@example.com",
    "currency": "NGN",
    "type": "virtual"
  }
}

Response

{
  "status": 200,
  "message": "Instruction executed",
  "data": {
    "account": {
      "_id": "01HQWXYZ...",
      "provider_id": "FLW",
      "account_number": "1234567890",
      "account_name": "Test Merchant",
      "bank_name": "Wema Bank",
      "currency": "NGN",
      "status": "active",
      "bvn_verified": true
    }
  }
}

Implementation Steps

1Create Repository from Template
2Configure for Service API
3Create Provider Specs
4Create Models & Repositories
5Create Provider Executors
6Create Orchestrator
7Create Endpoint
8Create Messages
9Test Implementation
12

Inter-Service Communication

Core Service to Service API

The Core Service communicates with Service APIs via HTTP using service keys.

Core Service

nuvion-core

HTTP + X-Service-Key

Service API

/instructions

Webhook Architecture

NEW in v1.1

Providers send webhooks for asynchronous events via a dedicated Callback Server with static IPs.

External Provider
Callback Server (EC2)
Service API

Static Elastic IPs whitelisted by providers → Routes to appropriate Service API

13

Best Practices & Conventions

Naming Conventions

ComponentConventionExample
Service API Reponuvion-[domain]-apinuvion-account-issuing-api
Instructionverb.nouncreate.account
Versionv[number]v1, v2
Provider ID3 uppercase lettersFLW, STR, THR
Model NamePROVIDER_resourceFLW_accounts
Spec File[instruction].vsl.jscreate-account.vsl.js
Log Keycomponent-actionorchestrator-dispatch

Code Organization

  • • One Service = One Repository
  • • Provider Isolation in providers/[ID]/ folders
  • • Version Isolation in v1/, v2/ folders
  • • Single Responsibility per file

Security Practices

  • • Service-to-Service Auth via X-Service-Key
  • • PII Encryption before storage
  • • Provider Credentials in env variables
  • • Request Validation at endpoint layer
14

Summary

The Service API Architecture provides a scalable, maintainable approach to integrating multiple third-party providers. Each Service API is a standalone Node.js application following the three-layer architecture.

Key Achievements

Service Isolation: Each Service API runs independently
Provider Independence: Switch providers without Core changes
Provider-Specific Validation: Custom VSL specs per provider
Version Management: Support multiple spec versions
Clear Separation: Three distinct layers
Type Safety: VSL-based validation everywhere
Observability: Comprehensive logging
Scalability: Easy to add providers and services

Implementation Roadmap

Phase 1Week 1-2

Create First Service API

Phase 2Week 3

Add Providers & Versions

Phase 3Week 4

Integrate with Core

Phase 4Week 5-8

Additional Services

Phase 5Week 9-12

Production Readiness

Success Criteria

  • Core Service can create accounts via multiple providers
  • Adding a new provider requires no Core Service changes
  • Switching providers is configuration-based
  • Multiple spec versions coexist without conflicts
  • Service APIs can be deployed independently