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:
| Code | HTTP | System Message | Description / When Used |
|---|---|---|---|
| 1000–1999: General | |||
| 1000 | 400 | Validation failed | Request body or query parameter validation failed |
| 1001 | 400 | Invalid request format | Malformed JSON, missing Content-Type, etc. |
| 1002 | 400 | Invalid query parameter | Unrecognized or malformed query parameter |
| 1003 | 400 | Invalid sort field | Sort requested on non-sortable field |
| 1004 | 400 | Page out of range | Requested page exceeds total pages |
| 1010 | 401 | Authentication required | Missing or invalid bearer token |
| 1011 | 401 | Token expired | Bearer token has expired |
| 1020 | 403 | Insufficient permissions | User lacks required role/permission |
| 1030 | 404 | Resource not found | Entity does not exist or is soft-deleted |
| 1040 | 405 | Method not allowed | HTTP method not supported on endpoint |
| 1050 | 409 | State conflict | Operation invalid for current entity state |
| 1051 | 409 | Dependency conflict | Operation blocked by dependency constraint |
| 1052 | 409 | Duplicate resource | Resource with same unique key already exists |
| 1060 | 429 | Rate limit exceeded | Too many requests |
| 1070 | 422 | Unprocessable entity | Syntactically valid but semantically invalid |
| 1099 | 500 | Internal server error | Unexpected server error |
| 2000–2999: Job System | |||
| 2000 | 409 | Job not enabled | Cannot execute a disabled job |
| 2001 | 429 | Concurrency limit reached | Global job execution limit exceeded |
| 2002 | 409 | Execution not cancellable | Execution is not in a cancellable state |
| 2003 | 409 | Execution not pausable | Execution is not in a pausable state |
| 2004 | 409 | Execution not resumable | Execution is not in a resumable state |
| 2005 | 409 | Circular dependency detected | Job dependency would create a cycle |
| 2006 | 409 | Self dependency | Job cannot depend on itself |
| 2007 | 409 | Duplicate dependency | Dependency between these jobs already exists |
| 2010 | 400 | Invalid step type | Step type key is not registered |
| 2011 | 400 | Invalid step configuration | Step configuration doesn't match type schema |
| 2012 | 400 | Invalid cron expression | Cron expression cannot be parsed |
| 2020 | 409 | Chain not enabled | Cannot execute a disabled chain |
| 2021 | 409 | Chain member conflict | Job referenced by chain member not found or deleted |
| 3000–3999: Connections | |||
| 3000 | 422 | Connection test failed | Test connection could not connect |
| 3001 | 422 | Authentication failed | Connection test: credentials rejected |
| 3002 | 422 | Host key mismatch | SFTP host key doesn't match stored fingerprint |
| 3003 | 422 | Host unreachable | Connection test: host refused or timed out |
| 3004 | 403 | Insecure host key policy requires admin | Only Admin role can set host_key_policy to always_trust |
| 3005 | 403 | Insecure host key policy not allowed in FIPS mode | AlwaysTrust blocked when FIPS mode enabled |
| 3006 | 403 | Insecure TLS policy requires admin | Only Admin role can set tls_cert_policy to insecure |
| 3007 | 403 | Insecure TLS policy not allowed in FIPS mode | Insecure cert policy blocked when FIPS mode enabled |
| 3008 | 403 | Insecure trust policy blocked in production | AlwaysTrust or Insecure cert policy blocked by production setting |
| 3010 | 409 | Connection in use | Cannot delete connection referenced by active jobs/monitors |
| 3011 | 400 | Invalid protocol configuration | SSH-specific config on FTP connection or vice versa |
| 3012 | 403 | FIPS override requires admin | Only Admin role can enable fips_override on connections |
| 4000–4999: Key Store | |||
| 4000 | 400 | Key import failed | Key file could not be parsed or is corrupt |
| 4001 | 400 | Invalid passphrase | Passphrase does not unlock private key |
| 4002 | 409 | Key fingerprint exists | Key with this fingerprint already imported |
| 4003 | 409 | Key not active | Operation requires key in Active status |
| 4004 | 409 | Key already revoked | Cannot change status of a revoked key |
| 4005 | 409 | Key in use | Cannot delete key referenced by active jobs |
| 4010 | 403 | Private key export denied | Private key export requires explicit confirmation |
| 4011 | 400 | Algorithm not available in FIPS mode | Key generation requested with non-FIPS algorithm while FIPS enabled |
| 4012 | 403 | Share links disabled | Public key share links are not enabled in system settings |
| 4013 | 404 | Share link expired or revoked | The share link token is invalid, expired, or has been revoked |
| 5000–5999: File Monitors | |||
| 5000 | 409 | Monitor not active | Operation requires monitor in Active state |
| 5001 | 409 | Monitor not in error | Acknowledge requires monitor in Error state |
| 5002 | 409 | Monitor already active | Activate called on already-active monitor |
| 5003 | 400 | Invalid watch target | Remote watch target references invalid connection |
| 5004 | 400 | Invalid polling interval | Polling interval below minimum (30 seconds) |
| 6000–6999: Tags & Cross-cutting | |||
| 6000 | 409 | Duplicate tag name | Tag with this name already exists |
| 6001 | 400 | Invalid entity type | Entity type not in taggable entity list |
| 6002 | 404 | Tag assignment not found | Tag is not assigned to the specified entity |
| 7000–7999: File Operations | |||
| 7000 | 422 | Compression failed | 7z/ZIP operation failed — see error details |
| 7001 | 422 | Unsafe filename in archive operation | Filename contains path traversal, shell metacharacters, or control characters |
| 7002 | 422 | Archive path escapes sandbox | Extracted file would resolve outside the job's temp directory |
| 7003 | 408 | Archive operation timed out | 7z process exceeded step timeout and was killed |
| 7004 | 422 | Decompression bomb detected | Archive exceeds uncompressed size, file count, or compression ratio limits |
| 7005 | 422 | Symlink escapes sandbox | Archive 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)
| Parameter | Type | Description |
|---|---|---|
search | string | Name/description substring search |
isEnabled | bool | Filter by enabled state |
tag | string | Filter by tag name (repeatable for OR) |
stepType | string | Filter 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)
| Parameter | Type | Description |
|---|---|---|
search | string | Name/host substring search |
protocol | string | Filter by protocol (sftp, ftp, ftps) |
group | string | Filter by group name |
status | string | Filter by status (active, disabled) |
tag | string | Filter 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)
| Parameter | Type | Description |
|---|---|---|
search | string | Name/fingerprint substring search |
status | string | Filter by status (active, expiring, retired, revoked) |
keyType | string | Filter by type (public_only, key_pair) |
algorithm | string | Filter by algorithm |
tag | string | Filter 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:
| Field | Type | Description |
|---|---|---|
file | file | .asc, .pgp, .gpg, or .kbx file |
name | string | Human-readable label |
passphrase | string | Passphrase for private key (if applicable) |
purpose | string | Optional 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)
| Parameter | Type | Description |
|---|---|---|
search | string | Name/description substring search |
state | string | Filter by state (active, paused, disabled, error) |
targetType | string | Filter by watch target type (local, remote) |
tag | string | Filter 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)
| Parameter | Type | Description |
|---|---|---|
search | string | Name substring search |
category | string | Filter 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)
| Parameter | Type | Description |
|---|---|---|
entityType | string | Filter by entity type |
entityId | string | Filter by specific entity ID |
operation | string | Filter by operation name |
performedBy | string | Filter by user |
from | datetime | Start of time range (inclusive) |
to | datetime | End 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):
| Code | Name | HTTP Status |
|---|---|---|
| 10000 | InvalidCredentials | 401 |
| 10001 | AccountLocked | 423 |
| 10002 | AccountDisabled | 403 |
| 10003 | InvalidRefreshToken | 401 |
| 10004 | RefreshTokenExpired | 401 |
| 10007 | Unauthorized | 401 |
| 10008 | Forbidden | 403 |
| 10012 | WeakPassword | 400 |
| 10013 | InvalidCurrentPassword | 400 |
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:
| Code | Name | HTTP Status |
|---|---|---|
| 10005 | SetupNotCompleted | 503 |
| 10006 | SetupAlreadyCompleted | 409 |
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:
| Code | Name | HTTP Status |
|---|---|---|
| 10009 | DuplicateUsername | 409 |
| 10010 | CannotDeleteSelf | 400 |
| 10011 | CannotDemoteLastAdmin | 400 |
| 10014 | UserNotFound | 404 |
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.