EaglesNest
Certificate-based OAuth 2.0 API — Developer Guide
Version 1.0 · .NET 8 · Microsoft Entra ID · FluentFTP · Dapper
Overview
EaglesNest is an ASP.NET Core Web API that acts as a secure gateway between CargoWise (or any eAdapter-compatible sender) and downstream systems. It provides:
- Certificate-based OAuth 2.0 — RS256 client assertions, Microsoft Entra ID tokens
- XML receive & routing — validates, saves, and forwards XML to FTP destinations
- Message filter gate — per-sender allow/block via
tblMessageConfig - Stockwell financial reports — AP/AR invoices and trial balance from Odyssey SQL
- User management — local accounts with salted password hashing and session tokens
Prerequisites
| Item | Details |
|---|---|
| Azure AD Tenant | Tenant ID + Client ID configured in appsettings.json → AzureAd |
| SQL Server | Connection strings: eAdapterNext (main DB) and Stockwell (Odyssey) |
| FTP Server | Required only for XML routing — configure URI/credentials in tblRoutes |
| Log directory | C:\eAdapterNext\Logs\ (configurable via eAdapterSettings:Logs) |
| XML directory | C:\eAdapterNext\XmlFolder\ (configurable via eAdapterSettings:XMLFolder) |
Base URL & Common Headers
https://localhost:7196 # Development
https://your-server/api # Production
Common Request Headers
| Header | Value | Required on |
|---|---|---|
Authorization | Bearer {token} | All protected endpoints |
Content-Type | application/json | POST / PUT / PATCH bodies |
Content-Type | application/xml or text/xml | POST /api/cargowisedata/receive |
eAdaptor-RecipientID | Your recipient identifier | POST /api/cargowisedata/receive |
eAdaptor-EDIClientName | Your EDI client name | POST /api/cargowisedata/receive |
traceparent | W3C trace ID (optional — auto-generated) | POST /api/cargowisedata/receive |
Error Responses
All endpoints return a consistent JSON envelope:
{
"success": false,
"message": "Unauthorized",
"reason": "Token expired at 2025-04-10 12:00:00Z. Request a new token.",
"hint": "Obtain a valid token via POST /api/auth/token or from Microsoft Entra ID.",
"errors": []
}
| HTTP Status | Meaning |
|---|---|
200 | Success |
201 | Created — check Location header |
400 | Bad request — validation error, malformed XML, or duplicate |
401 | Unauthorized — missing, expired, or invalid Bearer token. Response body contains reason. |
403 | Forbidden — sender not registered in tblMessageConfig or config disabled |
404 | Resource not found |
500 | Internal server error — check system log at C:\eAdapterNext\Logs\system-*.log |
Authentication
Option A — Swagger UI (Interactive)
Best for exploring the API during development.
oauth2, then click Authorize.Option B — Postman / Client Credentials
Best for testing with Postman or any HTTP client using a client secret.
POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={your-client-id}
&client_secret={your-client-secret}
&scope=api://ef770885-965b-4152-90c8-ddb4e78110d6/.default
Use the returned access_token as Authorization: Bearer {token}.
Option C — Certificate M2M (CargoWise / Production)
Automated service-to-service flow using RS256 certificate-based client assertions.
POST /api/auth/register
Leave csrPem blank to let the server generate the key pair.
{
"clientName": "My CargoWise App",
"azureClientId": "your-azure-app-client-id"
}
Save the response values:
| Field | Use |
|---|---|
certificatePem | Steps 2, 3, and upload to Azure AD |
privateKeyPem | Step 2 only — keep secret, not stored server-side |
certificatePem from Step 1.
POST /api/auth/generate-assertion
{
"certificatePem": "-----BEGIN CERTIFICATE-----\n...",
"privateKeyPem": "-----BEGIN RSA PRIVATE KEY-----\n..."
}
Returns a signed JWT valid for 10 minutes.
POST /api/auth/token
{
"certificatePem": "-----BEGIN CERTIFICATE-----\n...",
"clientAssertion": "eyJhbGciOiJS..."
}
Returns an accessToken — use as Authorization: Bearer {token}.
"Key not found" → Certificate not uploaded to Azure AD → upload
certificatePem in the portal."Key found, signature failed" → Private key doesn't match the certificate → re-register and generate a fresh matching pair.
Option D — Basic Authentication
Use Basic Auth when your client cannot obtain a Microsoft Entra ID token.
Credentials are validated against the local tblUsers table.
The scheme is auto-selected by the API when the Authorization header starts with Basic.
How it works
- Create a local user via
POST /api/user(or use an existing account). - Base64-encode
username:password. - Pass the result in the
Authorizationheader on every request.
Header format
Authorization: Basic {base64(username:password)}
Encoding example
| Step | Value |
|---|---|
| Plain credentials | john.doe:P@ssw0rd! |
| Base64-encoded | am9obi5kb2U6UEBzc3cwcmQh |
| Final header | Authorization: Basic am9obi5kb2U6UEBzc3cwcmQh |
Generate the Base64 value
Use any of the following:
# PowerShell
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("john.doe:P@ssw0rd!"))
# Linux / macOS
echo -n "john.doe:P@ssw0rd!" | base64
# Online
# https://www.base64encode.org/
Full request example
POST /api/cargowisedata/receive HTTP/1.1
Authorization: Basic am9obi5kb2U6UEBzc3cwcmQh
Content-Type: application/xml
eAdaptor-RecipientID: MYRECIPIENT
eAdaptor-EDIClientName: CargoWiseClient
<?xml version="1.0"?>
<UniversalShipment>...</UniversalShipment>
Validation logic
The BasicAuthenticationService performs the following checks on every request:
Authorizationheader present and scheme isBasic- Header value is valid Base64 with exactly one
:separator - Username found in
tblUsers - Password matches:
SHA-256(salt + password)equals stored hash - Account
Status = true(active)
Switching between Basic and Bearer
The API uses a dynamic scheme selector — you can mix Basic and Bearer in the same
deployment. The scheme is chosen automatically based on the Authorization header prefix:
| Header prefix | Scheme used |
|---|---|
Bearer ... | Microsoft Entra ID JWT Bearer |
Basic ... | Local tblUsers Basic Auth |
Create a user (prerequisite)
POST /api/user
Content-Type: application/json
{
"username": "john.doe",
"password": "P@ssw0rd!"
}
POST /api/user/login endpoint.
Common errors
| Response | Cause | Fix |
|---|---|---|
401 Missing Authorization Header | No Authorization header sent | Add Authorization: Basic ... |
401 Invalid Authorization Scheme | Header uses Bearer instead of Basic | Change scheme to Basic |
401 Invalid Authorization Header | Base64 value is malformed or missing : | Re-encode username:password correctly |
401 Username not found! | Username doesn't exist in tblUsers | Create the user via POST /api/user |
401 Invalid password! | Password doesn't match the stored hash | Check for typos or reset via PUT /api/user/{id} |
Message Configuration Setup
Every sender (eAdaptor-EDIClientName + eAdaptor-RecipientID pair) must be
registered in tblMessageConfig before XML can be received. The message filter controls
whether routed delivery to FTP is enabled.
POST /api/messageconfig
{
"ediClientName": "CargoWiseClient",
"recipientId": "MYRECIPIENT",
"procedureId": 1,
"status": true,
"enableRouting": true
}
| Field | Effect |
|---|---|
status: true | Inbound messages from this sender are accepted |
status: false | All messages rejected with 403 |
enableRouting: true | Messages forwarded to FTP after saving to disk |
enableRouting: false | Messages saved to disk only — no FTP delivery |
id (GUID) from the response — you'll need it in Step 2.
POST /api/messageconfig/{id}/routes
{
"type": "FTP",
"uri": "ftp://ftp.example.com/incoming/cargowise/",
"username": "ftpuser",
"password": "P@ssw0rd",
"senderId": "SYD"
}
Multiple routes can be added. All active routes receive the file concurrently on each delivery.
| Type | Status |
|---|---|
FTP | ✅ Implemented — FluentFTP, PASV, auto-FTPS |
SFTP | 🔜 Planned |
HTTP / HTTPS | 🔜 Planned |
OUTBOUND / OUTBOUNDNEXT | 🔜 Planned |
SOAP / ENVELOP / CUSTOM | 🔜 Planned |
PATCH /api/messageconfig/{id}/filter
{ "routingEnabled": false }
Pauses FTP delivery without deleting routes or config. Messages continue to be saved to disk.
GET /api/messageconfig/{id}/counts
Returns per-route delivery totals from tblMessageCount. Incremented automatically on each successful FTP upload.
Receiving CargoWise XML
Endpoint: POST /api/cargowisedata/receive
Required Headers
Authorization: Bearer {access_token}
Content-Type: application/xml
eAdaptor-RecipientID: MYRECIPIENT
eAdaptor-EDIClientName: CargoWiseClient
traceparent: 4bf92f3577b34da6a3ce929d0e0e4736 (optional)
Body
<?xml version="1.0" encoding="utf-8"?>
<UniversalShipment xmlns="http://www.cargowise.com/Schemas/Universal/2011/11">
<Shipment>...</Shipment>
</UniversalShipment>
Validation Pipeline (in order)
- Required headers present (
eAdaptor-RecipientID,eAdaptor-EDIClientName) - Message filter gate — sender must be registered and active in
tblMessageConfig - Content-Type is
application/xmlortext/xml - Payload size within configured limit (default 10 MB)
- Well-formed XML document
- Root element in allowed list (if configured)
On Success
- XML saved to
C:\eAdapterNext\XmlFolder\{RecipientID}\{EdiClientName}\{timestamp}_{traceId}.xml - If routing is enabled → file uploaded to all configured FTP routes (background, non-blocking)
- Per-route delivery counter incremented in
tblMessageCount - Per-client log entry written to
C:\eAdapterNext\Logs\{RecipientID}\{EdiClientName}\
Success Response
{
"success": true,
"message": "XML received, validated and saved successfully.",
"data": {
"traceParent": "4bf92f3577b34da6a3ce929d0e0e4736",
"recipientId": "MYRECIPIENT",
"ediClientName": "CargoWiseClient",
"receivedAt": "2025-04-10T03:00:00Z",
"rootElement": "UniversalShipment",
"elementCount": 1,
"callerAppId": "ef770885-965b-4152-90c8-ddb4e78110d6",
"callerName": "CargoWise Integration",
"savedFileName": "20250410030000000_-_CargoWiseClient_-_MYRECIPIENT_4bf92f35.xml"
}
}
Sending XML to CargoWise
Endpoint: POST
/api/cargowisedata/send
Authenticates via Basic Auth. The authenticated username is used as the
partitionKey — no route parameter required.
The API fetches a Bearer token from eAdapterNext.Token using the username,
then forwards the raw XML body to the CargoWise eAdaptorNext endpoint.
An admin alert email is sent automatically (throttled — 1 per username per hour) for any of these failures:
no Azure Table record for the username, CargoWise returning a non-2xx response, or a network/unexpected error.
Flow
POST /api/cargowisedata/send
Authorization: Basic {base64(username:password)}
Content-Type: application/xml
<?xml version="1.0" encoding="utf-8"?>
<UniversalTransaction xmlns="http://www.cargowise.com/Schemas/Universal/2011/11">
<Header>
<SenderID>SIPSYD</SenderID>
...
</Header>
</UniversalTransaction>
The username must be at least 6 characters and must match a row in the eAdapterNext.Token Azure Table that holds the OAuth2 credentials for the target CargoWise environment.
The SenderID is the first 6 characters of the authenticated username:
var senderId = username[..6]; // e.g. "SIPSYD" from "SIPSYDclient"
Target URL is built as:
https://{senderId}services.wisegrid.net/eAdaptorNext
Example: username = "SIPSYDclient" → senderId = "SIPSYD" → https://SIPSYDservices.wisegrid.net/eAdaptorNext
POST {TokenApiBaseUrl}/api/token
Content-Type: application/json
{ "partitionKey": "{username}" }
The token is served from cache (Azure Table Storage) when still valid, or freshly acquired via OAuth2. If no row exists for the username, HTTP 502 is returned and an admin alert email is sent.
POST https://{senderId}services.wisegrid.net/eAdaptorNext
Authorization: Bearer {token}
Content-Type: application/xml
<!-- original XML body -->
Success Response
{
"success": true,
"message": "XML forwarded to CargoWise — HTTP 200.",
"data": {
"traceParent": "a3f1c2d4e5b6...",
"sentAt": "2025-04-20T06:30:00Z",
"cwStatusCode": 200,
"cwResponse": "<!-- CargoWise response body -->",
"tokenSource": "Cached",
"targetUrl": "https://SIPSYDservices.wisegrid.net/eAdaptorNext"
}
}
Error cases
| Status | Cause |
|---|---|
400 | Username shorter than 6 characters, or empty request body |
401 | Missing or invalid Basic Auth credentials |
500 | Failed to read the HTTP request body — admin alert sent |
502 | No Azure Table config for username, Token API unreachable, or network error to CargoWise — admin alert sent |
200 + success=false | CargoWise returned non-2xx (rejected the XML) — admin alert sent. Check cwResponse in the response body for detail. |
- No Azure Table row exists for the username in eAdapterNext.Token
- CargoWise returns a non-2xx response (XML rejected)
- Network error or unexpected exception during the send
ClientId, TenantId, certificate PEM, and private key.
FTP Routing Detail
URI Formats Accepted
ftp://ftp.example.com/incoming/cargowise/
ftps://secure.example.com/drop/
ftp.example.com/incoming/ (ftp:// assumed)
ftp.example.com (uploads to root)
Behaviour
- Passive mode (PASV) — works through NAT and most firewalls
- Auto-upgrades to FTPS if the server advertises AUTH TLS
- Remote directories are created automatically if missing
- All routes for a config are dispatched concurrently
- Each successful delivery increments
tblMessageCountfor that route
Log Locations
| Log | Path |
|---|---|
| System / FTP events | C:\eAdapterNext\Logs\system-{date}.log |
| Per-client receive log | C:\eAdapterNext\Logs\{RecipientID}\{EdiClientName}\{date}.log |
Stockwell — AP/AR Invoice Report
GET
/api/stockwell/AP?token={your-token}
Returns all open (unpaid) AP and AR invoices from the Odyssey AccTransactionHeader table.
StockwellReport.cs
and cannot be changed via query parameters today:
- Company code:
SYD - Ledger scope: both
ARandAP - Transaction type:
INVonly - Outstanding filter: unpaid items only (
OSTotal > 0.01)
Authentication
Pass the API token as a query parameter:
GET /api/stockwell/AP?token=eyJhbGci...
Token validation can be disabled by setting eAdapterSettings:activatedstokwelltoken = false.
Response Fields
| Field | Source | Description |
|---|---|---|
Ledger | AH_Ledger | AR or AP |
CreditorDebtor | OrgHeader.OH_Code | Creditor/Debtor code |
TransactionType | AH_TransactionType | Always INV |
TransactionNum | AH_TransactionNum | Unique reference number |
ReferenceDescription | AH_ConsolidatedInvoiceRef / AH_Desc | AR: invoice ref — AP: description |
PostDate | AH_PostDate | Date posted |
InvoiceDate | AH_InvoiceDate | Invoice date |
DueDate | AH_DueDate | Payment due date |
Currency | RefCurrency.RX_Code | ISO 4217 currency code |
ExchangeRate | AH_ExchangeRate | Rate to base currency (default 1) |
TransactionAmount | AH_InvoiceAmount + AH_GSTAmount | Invoice + GST |
OutstandingAmount | AH_OSTotal | Unpaid balance |
Category | OrgCompanyData.OB_ARCategory | AR category (null for AP) |
PaymentRequisitionDate | AH_RequisitionDate | Requisition raised date |
PaymentRequisitionStatus | AH_RequisitionStatus | Requisition status |
Stockwell — Trial Balance
GET
/api/stockwell/trialbalance?token={your-token}
Returns period-level GL movements from cvw_ProfitandLossMovement, grouped by account, period, branch, and department.
- Company code:
SYD - Period range:
202501–202612(Jan 2025 – Dec 2026) - Account types:
P&L,BSH,NTEonly - Excluded:
BUD(Budget) andFOR(Forecast) transaction categories - AR and AP control accounts automatically excluded
Response Fields
| Field | Source | Description |
|---|---|---|
Company | GlbCompany.GC_Code | Company code |
Account | AccGLHeader.AG_AccountNum | GL account number |
Type | AG_AccountType | P&L, BSH, or NTE |
Description | AG_Description | Account description |
Br | GLBBranch.GB_Code | Branch code |
Dept | GLBDepartment.GE_Code | Department code |
Direction | Decoded from dept attributes | Import or Export |
Mode | Decoded from dept attributes | Air, Sea, or Road |
Year | Extracted from AA_Period | Four-digit fiscal year |
Period | AA_Period | Accounting period YYYYMM |
AmountInPeriod | SUM(AA_AmountSum) | Net movement for the period |
Currency | RefCurrency.RX_Code | Company local currency |
User Management
Local user accounts with salted SHA-256 password hashing and session tokens (stored in tblUsers).
Create a user
POST /api/user
Content-Type: application/json
{
"username": "john.doe",
"password": "P@ssw0rd!"
}
Login
POST /api/user/login
Content-Type: application/json
{
"username": "john.doe",
"password": "P@ssw0rd!"
}
{
"success": true,
"data": {
"user": { "userId": "...", "username": "john.doe", "status": true },
"token": "base64-session-token",
"refreshToken": "base64-refresh-token"
}
}
Refresh token
POST /api/user/refresh
{ "refreshToken": "base64-refresh-token" }
Logout
POST /api/user/logout
{ "token": "base64-session-token" }
PUT /api/user/{id} automatically invalidates all existing session tokens.
Full Endpoint Reference
Auth
/api/auth/generate-keypairGenerate RSA key pair + CSR/api/auth/registerRegister client + sign certificate/api/auth/generate-assertionGenerate signed client assertion JWT/api/auth/tokenExchange assertion for access tokenCargoWise Data
/api/cargowisedata/receiveReceive XML — validate, save, route/api/cargowisedata/send/{partitionKey}Send XML to CargoWise — token from eAdapterNext.TokenMessage Configuration
/api/messageconfigList all configs (summary)/api/messageconfig/{id}Full config + routes + counts/api/messageconfigRegister new sender/api/messageconfig/{id}Update config fields/api/messageconfig/{id}Hard-delete config + routes + counts/api/messageconfig/{id}/filterEnable / disable FTP routing/api/messageconfig/{id}/routesList routes + per-route counts/api/messageconfig/{id}/routesAdd delivery route/api/messageconfig/{id}/routes/{routeId}Update route/api/messageconfig/{id}/routes/{routeId}Remove route + count/api/messageconfig/{id}/countsDelivery count breakdownStockwell Reports
/api/stockwell/AP?token=Open AP/AR invoices/api/stockwell/trialbalance?token=Period-level GL movementsUser Management
/api/userList all active users/api/user/{id}Get user by ID/api/userCreate user/api/user/{id}Update username / password / status/api/user/{id}Deactivate user (soft delete)/api/user/loginAuthenticate → returns session token/api/user/refreshExchange refresh token for new pair/api/user/logoutInvalidate session token