Courier MFT

API Design

RESTful API for managing jobs, connections, keys, and system configuration.

Courier exposes a RESTful API with an OpenAPI/Swagger specification generated via Swashbuckle. All endpoints are versioned under /api/v1/ and return JSON. The API is the sole interface between the Next.js frontend and the .NET backend — there are no server-rendered views or direct database access from the frontend.

10.1 General Conventions

Base URL: /api/v1

Authentication: All endpoints require a valid Azure AD/Entra ID bearer token in the Authorization header. See Section 12 (Security) for details.

Content type: application/json for all request and response bodies.

Standard Response Model: Every API response — without exception — is wrapped in a standard envelope. There are no raw JSON objects, bare arrays, or 204 No Content responses. Every endpoint returns a body that conforms to one of the generic response types below.

C# response types:

// Base envelope — present on every response
public record ApiResponse
{
    public ApiError? Error { get; init; }
    public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
    public bool Success => Error is null;
}

// Single item response (GET by ID, POST create, PUT update, action endpoints)
public record ApiResponse<T> : ApiResponse
{
    public T? Data { get; init; }
}

// Paginated list response (GET list endpoints)
public record PagedApiResponse<T> : ApiResponse
{
    public IReadOnlyList<T> Data { get; init; } = [];
    public PaginationMeta Pagination { get; init; } = default!;
}

public record PaginationMeta(
    int Page,
    int PageSize,
    int TotalCount,
    int TotalPages);

// Error detail
public record ApiError(
    int Code,                                 // Numeric error code (see Error Code Catalog)
    string SystemMessage,                     // Standardized message for this code (always the same)
    string Message,                           // Human-readable, context-specific message
    IReadOnlyList<FieldError>? Details = null);

public record FieldError(
    string Field,
    string Message);

Error code design: The code is a numeric identifier. The systemMessage is the canonical, standardized description for that code — it never varies. The message is a human-readable explanation specific to the current occurrence. Frontend consumers can switch on code for programmatic handling and display message to users. Developers and logging systems use systemMessage for consistent categorization.

Error Code Catalog:

CodeHTTPSystem MessageDescription / When Used
1000–1999: General
1000400Validation failedRequest body or query parameter validation failed
1001400Invalid request formatMalformed JSON, missing Content-Type, etc.
1002400Invalid query parameterUnrecognized or malformed query parameter
1003400Invalid sort fieldSort requested on non-sortable field
1004400Page out of rangeRequested page exceeds total pages
1010401Authentication requiredMissing or invalid bearer token
1011401Token expiredBearer token has expired
1020403Insufficient permissionsUser lacks required role/permission
1030404Resource not foundEntity does not exist or is soft-deleted
1040405Method not allowedHTTP method not supported on endpoint
1050409State conflictOperation invalid for current entity state
1051409Dependency conflictOperation blocked by dependency constraint
1052409Duplicate resourceResource with same unique key already exists
1060429Rate limit exceededToo many requests
1070422Unprocessable entitySyntactically valid but semantically invalid
1099500Internal server errorUnexpected server error
2000–2999: Job System
2000409Job not enabledCannot execute a disabled job
2001429Concurrency limit reachedGlobal job execution limit exceeded
2002409Execution not cancellableExecution is not in a cancellable state
2003409Execution not pausableExecution is not in a pausable state
2004409Execution not resumableExecution is not in a resumable state
2005409Circular dependency detectedJob dependency would create a cycle
2006409Self dependencyJob cannot depend on itself
2007409Duplicate dependencyDependency between these jobs already exists
2010400Invalid step typeStep type key is not registered
2011400Invalid step configurationStep configuration doesn't match type schema
2012400Invalid cron expressionCron expression cannot be parsed
2020409Chain not enabledCannot execute a disabled chain
2021409Chain member conflictJob referenced by chain member not found or deleted
3000–3999: Connections
3000422Connection test failedTest connection could not connect
3001422Authentication failedConnection test: credentials rejected
3002422Host key mismatchSFTP host key doesn't match stored fingerprint
3003422Host unreachableConnection test: host refused or timed out
3004403Insecure host key policy requires adminOnly Admin role can set host_key_policy to always_trust
3005403Insecure host key policy not allowed in FIPS modeAlwaysTrust blocked when FIPS mode enabled
3006403Insecure TLS policy requires adminOnly Admin role can set tls_cert_policy to insecure
3007403Insecure TLS policy not allowed in FIPS modeInsecure cert policy blocked when FIPS mode enabled
3008403Insecure trust policy blocked in productionAlwaysTrust or Insecure cert policy blocked by production setting
3010409Connection in useCannot delete connection referenced by active jobs/monitors
3011400Invalid protocol configurationSSH-specific config on FTP connection or vice versa
3012403FIPS override requires adminOnly Admin role can enable fips_override on connections
4000–4999: Key Store
4000400Key import failedKey file could not be parsed or is corrupt
4001400Invalid passphrasePassphrase does not unlock private key
4002409Key fingerprint existsKey with this fingerprint already imported
4003409Key not activeOperation requires key in Active status
4004409Key already revokedCannot change status of a revoked key
4005409Key in useCannot delete key referenced by active jobs
4010403Private key export deniedPrivate key export requires explicit confirmation
4011400Algorithm not available in FIPS modeKey generation requested with non-FIPS algorithm while FIPS enabled
4012403Share links disabledPublic key share links are not enabled in system settings
4013404Share link expired or revokedThe share link token is invalid, expired, or has been revoked
5000–5999: File Monitors
5000409Monitor not activeOperation requires monitor in Active state
5001409Monitor not in errorAcknowledge requires monitor in Error state
5002409Monitor already activeActivate called on already-active monitor
5003400Invalid watch targetRemote watch target references invalid connection
5004400Invalid polling intervalPolling interval below minimum (30 seconds)
6000–6999: Tags & Cross-cutting
6000409Duplicate tag nameTag with this name already exists
6001400Invalid entity typeEntity type not in taggable entity list
6002404Tag assignment not foundTag is not assigned to the specified entity
7000–7999: File Operations
7000422Compression failed7z/ZIP operation failed — see error details
7001422Unsafe filename in archive operationFilename contains path traversal, shell metacharacters, or control characters
7002422Archive path escapes sandboxExtracted file would resolve outside the job's temp directory
7003408Archive operation timed out7z process exceeded step timeout and was killed
7004422Decompression bomb detectedArchive exceeds uncompressed size, file count, or compression ratio limits
7005422Symlink escapes sandboxArchive contains a symlink pointing outside the extraction directory

C# error code constants:

public static class ErrorCodes
{
    // General
    public const int ValidationFailed = 1000;
    public const int InvalidRequestFormat = 1001;
    public const int InvalidQueryParameter = 1002;
    public const int InvalidSortField = 1003;
    public const int PageOutOfRange = 1004;
    public const int AuthenticationRequired = 1010;
    public const int TokenExpired = 1011;
    public const int InsufficientPermissions = 1020;
    public const int ResourceNotFound = 1030;
    public const int MethodNotAllowed = 1040;
    public const int StateConflict = 1050;
    public const int DependencyConflict = 1051;
    public const int DuplicateResource = 1052;
    public const int RateLimitExceeded = 1060;
    public const int UnprocessableEntity = 1070;
    public const int InternalServerError = 1099;

    // Job System
    public const int JobNotEnabled = 2000;
    public const int ConcurrencyLimitReached = 2001;
    public const int ExecutionNotCancellable = 2002;
    public const int ExecutionNotPausable = 2003;
    public const int ExecutionNotResumable = 2004;
    public const int CircularDependency = 2005;
    public const int SelfDependency = 2006;
    public const int DuplicateDependency = 2007;
    public const int InvalidStepType = 2010;
    public const int InvalidStepConfiguration = 2011;
    public const int InvalidCronExpression = 2012;
    public const int ChainNotEnabled = 2020;
    public const int ChainMemberConflict = 2021;

    // Connections
    public const int ConnectionTestFailed = 3000;
    public const int ConnectionAuthFailed = 3001;
    public const int HostKeyMismatch = 3002;
    public const int HostUnreachable = 3003;
    public const int ConnectionInUse = 3010;
    public const int InvalidProtocolConfig = 3011;
    public const int FipsOverrideRequiresAdmin = 3012;
    public const int InsecureHostKeyPolicyRequiresAdmin = 3004;
    public const int InsecureHostKeyPolicyBlockedByFips = 3005;
    public const int InsecureTlsPolicyRequiresAdmin = 3006;
    public const int InsecureTlsPolicyBlockedByFips = 3007;
    public const int InsecureTrustPolicyBlockedInProd = 3008;

    // Key Store
    public const int KeyImportFailed = 4000;
    public const int InvalidPassphrase = 4001;
    public const int KeyFingerprintExists = 4002;
    public const int KeyNotActive = 4003;
    public const int KeyAlreadyRevoked = 4004;
    public const int KeyInUse = 4005;
    public const int PrivateKeyExportDenied = 4010;
    public const int AlgorithmNotFipsApproved = 4011;
    public const int ShareLinksDisabled = 4012;
    public const int ShareLinkExpiredOrRevoked = 4013;

    // File Monitors
    public const int MonitorNotActive = 5000;
    public const int MonitorNotInError = 5001;
    public const int MonitorAlreadyActive = 5002;
    public const int InvalidWatchTarget = 5003;
    public const int InvalidPollingInterval = 5004;

    // Tags
    public const int DuplicateTagName = 6000;
    public const int InvalidEntityType = 6001;
    public const int TagAssignmentNotFound = 6002;

    // File Operations
    public const int CompressionFailed = 7000;
    public const int UnsafeFilename = 7001;
    public const int ArchivePathEscapesSandbox = 7002;
    public const int ArchiveOperationTimedOut = 7003;
    public const int DecompressionBombDetected = 7004;
    public const int SymlinkEscapesSandbox = 7005;
}

// Lookup: code → system message (always the same)
public static class ErrorMessages
{
    private static readonly Dictionary<int, string> SystemMessages = new()
    {
        [1000] = "Validation failed",
        [1001] = "Invalid request format",
        [1010] = "Authentication required",
        [1030] = "Resource not found",
        [1050] = "State conflict",
        [1051] = "Dependency conflict",
        [1099] = "Internal server error",
        [2001] = "Concurrency limit reached",
        [2005] = "Circular dependency detected",
        // ... all codes registered
    };

    public static string GetSystemMessage(int code)
        => SystemMessages.TryGetValue(code, out var msg) ? msg : "Unknown error";

    public static ApiError Create(int code, string message, IReadOnlyList<FieldError>? details = null)
        => new(code, GetSystemMessage(code), message, details);
}

Every response pattern:

// 1. Single item (GET /api/v1/jobs/{id}, POST create, PUT update)
// HTTP 200 or 201
{
    "data": { "id": "a1b2c3d4-...", "name": "Daily Download", ... },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 2. Paginated list (GET /api/v1/jobs)
// HTTP 200
{
    "data": [ { ... }, { ... } ],
    "pagination": {
        "page": 1,
        "pageSize": 25,
        "totalCount": 142,
        "totalPages": 6
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 3. Action result (POST execute, POST cancel, POST pause, POST test, POST retire, etc.)
// HTTP 200
{
    "data": {
        "executionId": "f1e2d3c4-...",
        "state": "queued",
        "message": "Job execution queued successfully."
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 4. Delete confirmation (DELETE /api/v1/jobs/{id})
// HTTP 200
{
    "data": {
        "id": "a1b2c3d4-...",
        "deleted": true,
        "deletedAt": "2026-02-21T12:00:00Z"
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 5. Bulk operation result (POST /api/v1/tags/assign)
// HTTP 200
{
    "data": {
        "assignedCount": 3,
        "assignments": [
            { "tagId": "e5f6a7b8-...", "entityType": "job", "entityId": "a1b2c3d4-...", "assigned": true },
            { "tagId": "e5f6a7b8-...", "entityType": "connection", "entityId": "b2c3d4e5-...", "assigned": true },
            { "tagId": "f6a7b8c9-...", "entityType": "job", "entityId": "a1b2c3d4-...", "assigned": true }
        ]
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 6. Validation error (any endpoint)
// HTTP 400
{
    "data": null,
    "error": {
        "code": 1000,
        "systemMessage": "Validation failed",
        "message": "One or more validation errors occurred.",
        "details": [
            { "field": "name", "message": "Name must not be empty." },
            { "field": "steps", "message": "A job must have at least one step." }
        ]
    },
    "success": false,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 7. Not found (any endpoint)
// HTTP 404
{
    "data": null,
    "error": {
        "code": 1030,
        "systemMessage": "Resource not found",
        "message": "Job with ID 'a1b2c3d4-...' was not found."
    },
    "success": false,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 8. State conflict (e.g., cancelling an already-completed job)
// HTTP 409
{
    "data": null,
    "error": {
        "code": 2002,
        "systemMessage": "Execution not cancellable",
        "message": "Cannot cancel execution 'f1e2d3c4-...': current state is 'completed'."
    },
    "success": false,
    "timestamp": "2026-02-21T12:00:00Z"
}

// 9. Internal error (unexpected failures)
// HTTP 500
{
    "data": null,
    "error": {
        "code": 1099,
        "systemMessage": "Internal server error",
        "message": "An unexpected error occurred. Reference: err_a1b2c3d4"
    },
    "success": false,
    "timestamp": "2026-02-21T12:00:00Z"
}

Enforcement: A global ASP.NET result filter wraps all controller return values in the appropriate ApiResponse<T> or PagedApiResponse<T> envelope. Unhandled exceptions are caught by a global exception handler middleware that returns an ApiResponse with the error populated. This ensures no endpoint can accidentally return a raw object.

// Global exception handler — guarantees envelope even on unhandled errors
public class ApiExceptionMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            var reference = $"err_{Guid.NewGuid():N[..8]}";
            _logger.LogError(ex, "Unhandled exception. Reference: {Reference}", reference);

            context.Response.StatusCode = 500;
            await context.Response.WriteAsJsonAsync(new ApiResponse
            {
                Error = ErrorMessages.Create(
                    ErrorCodes.InternalServerError,
                    $"An unexpected error occurred. Reference: {reference}")
            });
        }
    }
}

Pagination: Offset-based on all list endpoints. Query parameters: page (default: 1), pageSize (default: 25, max: 100). Response includes totalCount and totalPages.

Sorting: Query parameter sort with format field:asc or field:desc (e.g., sort=name:asc). Default sort is created_at:desc on all list endpoints.

Filtering: Resource-specific query parameters documented per endpoint (e.g., state=running, protocol=sftp).

Soft delete behavior: Deleted resources are excluded from list responses. GET by ID returns 404 for soft-deleted resources. A GET /api/v1/\{resource\}?includeDeleted=true query parameter is available for admin recovery.

Error codes: All errors use numeric codes with standardized system messages. See the full Error Code Catalog in Section 10.1 above. Ranges: 1000–1999 (general), 2000–2999 (jobs), 3000–3999 (connections), 4000–4999 (keys), 5000–5999 (monitors), 6000–6999 (tags).

10.2 Jobs API

Endpoints

GET    /api/v1/jobs                          List jobs
POST   /api/v1/jobs                          Create a job
GET    /api/v1/jobs/{id}                     Get job details
PUT    /api/v1/jobs/{id}                     Update a job (creates new version)
DELETE /api/v1/jobs/{id}                     Soft-delete a job

GET    /api/v1/jobs/{id}/steps               List steps for a job
PUT    /api/v1/jobs/{id}/steps               Replace all steps (atomic)

GET    /api/v1/jobs/{id}/versions            List job versions
GET    /api/v1/jobs/{id}/versions/{version}  Get specific version snapshot

POST   /api/v1/jobs/{id}/execute             Trigger manual execution
GET    /api/v1/jobs/{id}/executions          List executions for a job
GET    /api/v1/jobs/{id}/executions/{execId} Get execution details with step results
POST   /api/v1/jobs/{id}/executions/{execId}/cancel   Cancel a running execution
POST   /api/v1/jobs/{id}/executions/{execId}/pause    Pause a running execution
POST   /api/v1/jobs/{id}/executions/{execId}/resume   Resume a paused execution

GET    /api/v1/jobs/{id}/schedules           List schedules for a job
POST   /api/v1/jobs/{id}/schedules           Add a schedule
PUT    /api/v1/jobs/{id}/schedules/{schedId} Update a schedule
DELETE /api/v1/jobs/{id}/schedules/{schedId} Delete a schedule

GET    /api/v1/jobs/{id}/dependencies        List upstream dependencies
POST   /api/v1/jobs/{id}/dependencies        Add a dependency
DELETE /api/v1/jobs/{id}/dependencies/{depId} Remove a dependency

Filters (GET /api/v1/jobs)

ParameterTypeDescription
searchstringName/description substring search
isEnabledboolFilter by enabled state
tagstringFilter by tag name (repeatable for OR)
stepTypestringFilter by step type key contained in job

Create/Update Request Body

{
    "name": "Daily Partner Invoice Download",
    "description": "Downloads encrypted invoices from Partner SFTP",
    "isEnabled": true,
    "failurePolicy": {
        "type": "retry_step",
        "maxRetries": 3,
        "backoffBaseSeconds": 2,
        "backoffMaxSeconds": 120
    },
    "steps": [
        {
            "name": "Download from Partner",
            "typeKey": "sftp.download",
            "configuration": {
                "connectionId": "a1b2c3d4-...",
                "remotePath": "/outbound/invoices/",
                "filePattern": "*.pgp",
                "localPath": "${job.temp_dir}"
            },
            "timeoutSeconds": 600
        },
        {
            "name": "Decrypt PGP files",
            "typeKey": "pgp.decrypt",
            "configuration": {
                "inputPath": "${steps[0].downloaded_files}",
                "keyId": "b2c3d4e5-...",
                "outputPath": "${job.temp_dir}/decrypted/"
            },
            "timeoutSeconds": 300
        }
    ]
}

Execute Response

{
    "data": {
        "executionId": "f1e2d3c4-...",
        "jobId": "a1b2c3d4-...",
        "state": "queued",
        "triggeredBy": "manual:[email protected]",
        "queuedAt": "2026-02-21T12:00:00Z"
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

10.3 Job Chains API

GET    /api/v1/chains                         List chains
POST   /api/v1/chains                         Create a chain
GET    /api/v1/chains/{id}                    Get chain details with members
PUT    /api/v1/chains/{id}                    Update a chain
DELETE /api/v1/chains/{id}                    Soft-delete a chain

PUT    /api/v1/chains/{id}/members            Replace all members (atomic)

POST   /api/v1/chains/{id}/execute            Trigger manual execution
GET    /api/v1/chains/{id}/executions         List chain executions
GET    /api/v1/chains/{id}/executions/{execId} Get chain execution with job results

GET    /api/v1/chains/{id}/schedules          List schedules
POST   /api/v1/chains/{id}/schedules          Add a schedule
PUT    /api/v1/chains/{id}/schedules/{schedId} Update a schedule
DELETE /api/v1/chains/{id}/schedules/{schedId} Delete a schedule

Create/Update Request Body

{
    "name": "Daily Partner Invoice Processing",
    "description": "Full pipeline: download, decrypt, decompress, archive",
    "isEnabled": true,
    "members": [
        {
            "jobId": "a1b2c3d4-...",
            "executionOrder": 0,
            "dependsOnMemberIndex": null,
            "runOnUpstreamFailure": false
        },
        {
            "jobId": "b2c3d4e5-...",
            "executionOrder": 1,
            "dependsOnMemberIndex": 0,
            "runOnUpstreamFailure": false
        }
    ]
}

Note: dependsOnMemberIndex references the index within the members array (not a database ID). The server resolves this to depends_on_member_id after persisting the members.

10.4 Connections API

GET    /api/v1/connections                    List connections
POST   /api/v1/connections                    Create a connection
GET    /api/v1/connections/{id}               Get connection details
PUT    /api/v1/connections/{id}               Update a connection
DELETE /api/v1/connections/{id}               Soft-delete a connection
POST   /api/v1/connections/{id}/test          Test connection (connect, auth, list root)

Filters (GET /api/v1/connections)

ParameterTypeDescription
searchstringName/host substring search
protocolstringFilter by protocol (sftp, ftp, ftps)
groupstringFilter by group name
statusstringFilter by status (active, disabled)
tagstringFilter by tag name

Create/Update Request Body

{
    "name": "Partner SFTP - Production",
    "group": "Partner Integrations",
    "protocol": "sftp",
    "host": "sftp.partner.com",
    "port": 22,
    "authMethod": "password_and_ssh_key",
    "username": "courier_svc",
    "password": "s3cret",
    "sshKeyId": "c3d4e5f6-...",
    "hostKeyPolicy": "trust_on_first_use",
    "connectTimeoutSec": 30,
    "operationTimeoutSec": 300,
    "keepaliveIntervalSec": 60,
    "transportRetries": 2,
    "fipsOverride": false,
    "notes": "Contact: [email protected]"
}

Security note: The password field is accepted on create/update but never returned in GET responses. GET responses include hasPassword: true/false instead.

Test Connection Response

{
    "data": {
        "connected": true,
        "latencyMs": 142,
        "serverBanner": "OpenSSH_8.9p1 Ubuntu-3ubuntu0.4",
        "supportedAlgorithms": {
            "keyExchange": ["curve25519-sha256", "ecdh-sha2-nistp256"],
            "encryption": ["[email protected]", "[email protected]"],
            "mac": ["[email protected]"],
            "hostKey": ["ssh-ed25519", "rsa-sha2-512"]
        },
        "testedAt": "2026-02-21T12:00:00Z"
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

10.5 PGP Keys API

GET    /api/v1/pgp-keys                       List PGP keys
POST   /api/v1/pgp-keys/generate              Generate a new key pair
POST   /api/v1/pgp-keys/import                Import a key (multipart/form-data)
GET    /api/v1/pgp-keys/{id}                  Get key metadata
PUT    /api/v1/pgp-keys/{id}                  Update key metadata (name, purpose)
DELETE /api/v1/pgp-keys/{id}                  Soft-delete (purges key material)

GET    /api/v1/pgp-keys/{id}/export/public    Export public key (armored or binary)
POST   /api/v1/pgp-keys/{id}/export/private   Export private key (requires confirmation)

POST   /api/v1/pgp-keys/{id}/share            Generate shareable public key link (Admin only)
DELETE /api/v1/pgp-keys/{id}/share/{token}     Revoke a shareable link (Admin only)
GET    /api/v1/pgp-keys/shared/{token}         Download public key via shareable link (no auth)

POST   /api/v1/pgp-keys/{id}/retire           Retire a key
POST   /api/v1/pgp-keys/{id}/revoke           Revoke a key (terminal)
POST   /api/v1/pgp-keys/{id}/activate         Re-activate a retired key

Filters (GET /api/v1/pgp-keys)

ParameterTypeDescription
searchstringName/fingerprint substring search
statusstringFilter by status (active, expiring, retired, revoked)
keyTypestringFilter by type (public_only, key_pair)
algorithmstringFilter by algorithm
tagstringFilter by tag name

Generate Request Body

{
    "name": "Partner A - Encryption Key 2026",
    "algorithm": "rsa_4096",
    "userId": "[email protected]",
    "passphrase": "optional-passphrase",
    "expiresAt": "2027-02-21T00:00:00Z",
    "purpose": "Encrypting outbound files to Partner A"
}

Import Request

POST /api/v1/pgp-keys/import with multipart/form-data:

FieldTypeDescription
filefile.asc, .pgp, .gpg, or .kbx file
namestringHuman-readable label
passphrasestringPassphrase for private key (if applicable)
purposestringOptional description

Key Response (GET)

{
    "data": {
        "id": "b2c3d4e5-...",
        "name": "Partner A - Encryption Key 2026",
        "fingerprint": "A1B2C3D4E5F6...",
        "shortKeyId": "E5F6A1B2C3D4E5F6",
        "algorithm": "rsa_4096",
        "keyType": "key_pair",
        "status": "active",
        "hasPrivateKey": true,
        "hasPassphrase": true,
        "expiresAt": "2027-02-21T00:00:00Z",
        "successorKeyId": null,
        "purpose": "Encrypting outbound files to Partner A",
        "createdBy": "[email protected]",
        "createdAt": "2026-02-21T12:00:00Z",
        "tags": [{"name": "partner-a", "color": "#FF5733"}]
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

10.6 SSH Keys API

GET    /api/v1/ssh-keys                       List SSH keys
POST   /api/v1/ssh-keys/generate              Generate a new key pair
POST   /api/v1/ssh-keys/import                Import a key (multipart/form-data)
GET    /api/v1/ssh-keys/{id}                  Get key metadata
PUT    /api/v1/ssh-keys/{id}                  Update key metadata
DELETE /api/v1/ssh-keys/{id}                  Soft-delete

GET    /api/v1/ssh-keys/{id}/export/public    Export public key (OpenSSH format)

POST   /api/v1/ssh-keys/{id}/share            Generate shareable public key link (Admin only)
DELETE /api/v1/ssh-keys/{id}/share/{token}     Revoke a shareable link (Admin only)
GET    /api/v1/ssh-keys/shared/{token}         Download public key via shareable link (no auth)

POST   /api/v1/ssh-keys/{id}/retire           Retire a key
POST   /api/v1/ssh-keys/{id}/activate         Re-activate a retired key

The SSH Keys API mirrors the PGP Keys API in structure. Key differences: no private key export endpoint (SSH private keys are never exported from Courier), and the generate endpoint supports rsa_2048, rsa_4096, and ed25519 key types.

10.7 File Monitors API

GET    /api/v1/monitors                       List monitors
POST   /api/v1/monitors                       Create a monitor
GET    /api/v1/monitors/{id}                  Get monitor details
PUT    /api/v1/monitors/{id}                  Update a monitor
DELETE /api/v1/monitors/{id}                  Soft-delete a monitor

POST   /api/v1/monitors/{id}/activate         Activate / resume
POST   /api/v1/monitors/{id}/pause            Pause
POST   /api/v1/monitors/{id}/disable          Disable
POST   /api/v1/monitors/{id}/acknowledge      Acknowledge error and resume
POST   /api/v1/monitors/{id}/reset-watcher     Re-enable FileSystemWatcher after auto-disable (Admin)

GET    /api/v1/monitors/{id}/file-log         List triggered files (paginated)
GET    /api/v1/monitors/{id}/executions       List job executions triggered by this monitor

Filters (GET /api/v1/monitors)

ParameterTypeDescription
searchstringName/description substring search
statestringFilter by state (active, paused, disabled, error)
targetTypestringFilter by watch target type (local, remote)
tagstringFilter by tag name

Create/Update Request Body

{
    "name": "Partner Invoice Watch",
    "description": "Watches for new PGP files from Partner SFTP",
    "watchTarget": {
        "type": "remote",
        "path": "/outbound/invoices/",
        "connectionId": "a1b2c3d4-..."
    },
    "triggerEvents": ["FileCreated"],
    "filePatterns": ["*.pgp", "*.gpg"],
    "pollingIntervalSec": 60,
    "stabilityWindowSec": 10,
    "batchMode": false,
    "maxConsecutiveFailures": 5,
    "boundJobs": [
        { "jobId": "c3d4e5f6-..." }
    ],
    "boundChains": [
        { "chainId": "d4e5f6a7-..." }
    ]
}

10.8 Tags API

GET    /api/v1/tags                           List all tags
POST   /api/v1/tags                           Create a tag
GET    /api/v1/tags/{id}                      Get tag details
PUT    /api/v1/tags/{id}                      Update a tag
DELETE /api/v1/tags/{id}                      Soft-delete a tag

GET    /api/v1/tags/{id}/entities             List all entities with this tag
POST   /api/v1/tags/assign                    Assign tag(s) to entity/entities (bulk)
POST   /api/v1/tags/unassign                  Remove tag(s) from entity/entities (bulk)

Filters (GET /api/v1/tags)

ParameterTypeDescription
searchstringName substring search
categorystringFilter by category

Create Request Body

{
    "name": "partner-acme",
    "color": "#FF5733",
    "category": "Partner",
    "description": "Resources related to ACME Corp integration"
}

Bulk Assign Request Body

{
    "assignments": [
        { "tagId": "e5f6a7b8-...", "entityType": "job", "entityId": "a1b2c3d4-..." },
        { "tagId": "e5f6a7b8-...", "entityType": "connection", "entityId": "b2c3d4e5-..." },
        { "tagId": "f6a7b8c9-...", "entityType": "job", "entityId": "a1b2c3d4-..." }
    ]
}

10.9 Audit Log API

GET    /api/v1/audit-log                      Query audit log entries
GET    /api/v1/audit-log/entity/{type}/{id}   Get audit history for a specific entity

The audit log is read-only — entries are created internally by the system and cannot be modified or deleted via the API.

Filters (GET /api/v1/audit-log)

ParameterTypeDescription
entityTypestringFilter by entity type
entityIdstringFilter by specific entity ID
operationstringFilter by operation name
performedBystringFilter by user
fromdatetimeStart of time range (inclusive)
todatetimeEnd of time range (exclusive)

Audit Entry Response

{
    "data": [
        {
            "id": "a7b8c9d0-...",
            "entityType": "connection",
            "entityId": "a1b2c3d4-...",
            "operation": "Connected",
            "performedBy": "system",
            "performedAt": "2026-02-21T12:00:00Z",
            "details": {
                "host": "sftp.partner.com",
                "latencyMs": 142,
                "protocol": "sftp"
            }
        }
    ],
    "pagination": { "page": 1, "pageSize": 25, "totalCount": 89, "totalPages": 4 },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:05Z"
}

10.10 Authentication API

All auth endpoints use [AllowAnonymous] (login, refresh) or [Authorize] (me, logout, change-password).

POST   /api/v1/auth/login                     Authenticate with username/password
POST   /api/v1/auth/refresh                   Exchange refresh token for new token pair
POST   /api/v1/auth/logout                    Revoke refresh token
GET    /api/v1/auth/me                         Get current user profile
POST   /api/v1/auth/change-password           Change own password (requires current password)

Login response:

{
    "data": {
        "accessToken": "eyJhbGciOiJIUzI1NiIs...",
        "refreshToken": "base64-random-32-bytes",
        "expiresIn": 900,
        "user": {
            "id": "uuid",
            "username": "admin",
            "displayName": "Admin User",
            "email": "[email protected]",
            "role": "admin"
        }
    }
}

Error codes (10000–10014):

CodeNameHTTP Status
10000InvalidCredentials401
10001AccountLocked423
10002AccountDisabled403
10003InvalidRefreshToken401
10004RefreshTokenExpired401
10007Unauthorized401
10008Forbidden403
10012WeakPassword400
10013InvalidCurrentPassword400

10.11 Setup API

Both endpoints use [AllowAnonymous].

GET    /api/v1/setup/status                   Check if initial setup is completed
POST   /api/v1/setup/initialize               Create initial admin account

Error codes:

CodeNameHTTP Status
10005SetupNotCompleted503
10006SetupAlreadyCompleted409

10.12 Users API (Admin Only)

All endpoints require [Authorize(Roles = "admin")].

GET    /api/v1/users                          List users (paginated, searchable)
GET    /api/v1/users/{id}                     Get user by ID
POST   /api/v1/users                          Create user
PUT    /api/v1/users/{id}                     Update user (role, display name, active status)
DELETE /api/v1/users/{id}                     Soft-delete user
POST   /api/v1/users/{id}/reset-password      Reset user password (revokes all sessions)

Error codes:

CodeNameHTTP Status
10009DuplicateUsername409
10010CannotDeleteSelf400
10011CannotDemoteLastAdmin400
10014UserNotFound404

10.13 System Settings API

GET    /api/v1/settings/auth                  Get auth settings (admin only)
PUT    /api/v1/settings/auth                  Update auth settings (admin only)

Auth settings control session timeout, refresh token lifetime, password policy, and lockout configuration. Settings are seeded on first migration and cannot be created or deleted via the API.

10.14 Dashboard & Summary Endpoints

These endpoints support the frontend dashboard with aggregated data that would be expensive to assemble from individual resource endpoints.

GET    /api/v1/dashboard/summary              System-wide summary stats
GET    /api/v1/dashboard/recent-executions    Recent job executions across all jobs
GET    /api/v1/dashboard/active-monitors      Currently active monitors with status
GET    /api/v1/dashboard/key-expiry           Keys expiring within N days

Summary Response

{
    "data": {
        "jobs": { "total": 42, "enabled": 38, "disabled": 4 },
        "chains": { "total": 8, "enabled": 7, "disabled": 1 },
        "connections": { "total": 15, "active": 14, "disabled": 1 },
        "monitors": { "active": 6, "degraded": 0, "paused": 1, "error": 0 },
        "pgpKeys": { "active": 12, "expiring": 2, "retired": 5 },
        "sshKeys": { "active": 8, "retired": 2 },
        "recentExecutions": {
            "last24h": { "completed": 187, "failed": 3, "cancelled": 0 },
            "last7d": { "completed": 1247, "failed": 18, "cancelled": 2 }
        }
    },
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

10.15 Azure Functions API

On-demand trace retrieval for Azure Function executions. Traces are fetched live from Application Insights — nothing is stored in the Courier database.

GET    /api/v1/azure-functions/{connectionId}/traces/{invocationId}   Get execution traces

Response

{
    "data": [
        {
            "timestamp": "2026-02-28T15:30:01.123Z",
            "message": "Processing file abc-123...",
            "severityLevel": 1
        },
        {
            "timestamp": "2026-02-28T15:30:05.456Z",
            "message": "File processed successfully",
            "severityLevel": 1
        }
    ],
    "error": null,
    "success": true,
    "timestamp": "2026-02-28T15:31:00Z"
}

Severity levels follow Application Insights convention: 0=Verbose, 1=Information, 2=Warning, 3=Error, 4=Critical.

10.16 Step Type Registry API

A read-only endpoint that returns all available step types and their configuration schemas. Used by the frontend job builder to render step-specific configuration forms.

GET    /api/v1/step-types                     List all registered step types
GET    /api/v1/step-types/{typeKey}           Get configuration schema for a step type

Step Type Response

{
    "data": [
        {
            "typeKey": "sftp.download",
            "displayName": "SFTP Download",
            "category": "Transfer",
            "description": "Download files from a remote SFTP server",
            "configurationSchema": {
                "type": "object",
                "required": ["connectionId", "remotePath"],
                "properties": {
                    "connectionId": {
                        "type": "string",
                        "format": "uuid",
                        "description": "Connection to use",
                        "uiHint": "connection-picker"
                    },
                    "remotePath": {
                        "type": "string",
                        "description": "Remote directory path"
                    },
                    "filePattern": {
                        "type": "string",
                        "description": "Glob pattern for file matching",
                        "default": "*"
                    },
                    "localPath": {
                        "type": "string",
                        "description": "Local destination path",
                        "default": "${job.temp_dir}"
                    },
                    "deleteAfterDownload": {
                        "type": "boolean",
                        "description": "Remove file from server after download",
                        "default": false
                    }
                }
            }
        }
    ],
    "error": null,
    "success": true,
    "timestamp": "2026-02-21T12:00:00Z"
}

The configurationSchema follows JSON Schema with custom uiHint extensions that the frontend uses to render appropriate input controls (e.g., connection-picker renders a connection dropdown, key-picker renders a PGP key selector).

10.17 OpenAPI / Swagger Configuration

The API specification is generated at build time via Swashbuckle and served at /swagger in development and staging environments. Production exposes the spec at /api/v1/openapi.json but disables the Swagger UI.

builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Courier API",
        Version = "v1",
        Description = "Enterprise File Transfer & Job Management Platform"
    });

    // Bearer token auth
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "Azure AD / Entra ID bearer token"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });

    // Include XML docs for rich descriptions
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
});

10.18 Request Validation

All request bodies are validated using FluentValidation. Validators are auto-discovered from the assembly and wired into the ASP.NET pipeline via a validation filter. Validation errors return an HTTP 400 with error code 1000 (Validation failed) and field-level details.

public class CreateJobValidator : AbstractValidator<CreateJobRequest>
{
    public CreateJobValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Job name is required.")
            .MaximumLength(200).WithMessage("Job name must not exceed 200 characters.");

        RuleFor(x => x.Steps)
            .NotEmpty().WithMessage("A job must have at least one step.");

        RuleForEach(x => x.Steps).ChildRules(step =>
        {
            step.RuleFor(s => s.TypeKey)
                .NotEmpty().WithMessage("Step type is required.");
            step.RuleFor(s => s.TimeoutSeconds)
                .InclusiveBetween(1, 86400).WithMessage("Timeout must be between 1 second and 24 hours.");
        });

        RuleFor(x => x.FailurePolicy.MaxRetries)
            .InclusiveBetween(0, 10).WithMessage("Max retries must be between 0 and 10.");
    }
}

Deeper validations (e.g., checking that a referenced connectionId exists, or that a step type key is registered) are performed in the application service layer and return 400 or 404 as appropriate.