virtual-printer.online API — endpoint reference

The HTTP/REST contract for the virtual-printer.online API: endpoints, request/response shapes, the error format, and the end-to-end print-and-verify flow.

This page is the served copy. The canonical source is doc/api-endpoints.md in the repository (used by integration/autotests) — keep the two in sync.

Scope — two protocols. Managing workspaces, printers and reading documents is HTTP/REST (this document + Swagger). Printing itself is raw TCP: a client opens a socket to the printer's listener (Settings.TcpListenPort / Settings.PublicHost) and writes ESC/POS or EPL bytes. The printed document then becomes readable over HTTP. The TCP step is not in OpenAPI — see End-to-end scenario.

There is also a live OpenAPI spec generated from the controllers (covers the HTTP surface only): /swagger (UI) and /swagger/v1/swagger.json (raw), enabled in every environment, Production included.


Conventions

  • Base path: all endpoints are under /api (except the SSE notes below, which are also under /api).
  • JSON casing: responses are camelCasetoken, accessToken, id. No naming policy is configured, so the ASP.NET Core default applies. Request bodies are matched case-insensitively, so Token and token are both accepted on the way in - it is only what comes back that is camelCase.
  • Null omission: properties that are null are omitted from responses (DefaultIgnoreCondition = WhenWritingNull). The exceptions are the timestamps printer.lastDocumentReceivedAt, printer.lastApiReadAt, printer.lastWebReadAt, workspace.lastApiReadAt and workspace.lastWebReadAt, which are always emitted (may be null).
  • Auth header: Authorization: Bearer <AccessToken> on every [Authorize] endpoint. Get the token from POST /api/auth/login.
  • Last-read stamps: reading a printer's documents (GET …/documents/canvas, and each document delivered by GET …/documents/canvas/stream) records the UTC time of the read on the printer and on its workspace: lastApiReadAt for API clients, lastWebReadAt for the bundled web UI, which marks its calls with the X-Client: web header. Any request without that header is an API read. An admin workspace reading another workspace's printer records nothing. Reading printer metadata (GET /api/printers, GET /api/printers/{id}) is not a read.
  • IDs are client-supplied GUIDs. Create requests for workspaces and printers carry the Id — the caller generates it. This makes creates idempotent-ish and easy to assert against in tests.

Error format

All unhandled exceptions are converted by ExceptionHandlingMiddleware to application/problem+json:

{ "Status": 404, "Detail": "Printer not found.", "Instance": "/api/printers/3f2a…" }
Exception HTTP status
AuthenticationFailedException 401
ForbiddenException 403
PrinterNotFoundException 404
BadRequestException, ArgumentException, ValidationException 400
PrinterListenerStartFailedException 500
any other 500

OperationCanceledException and StreamDisconnectedException produce no response body (expected client/SSE disconnects).


End-to-end scenario

The canonical flow every printing test follows: register → login → create printer → print over TCP → verify the parsed document. (This is the flow encoded in ProtocolTestsBase / PrintersControllerTests.Documents.)

 1. POST /api/workspaces            ── HTTP ─▶  { Id, Name, Token }
 2. POST /api/auth/login (Token)    ── HTTP ─▶  { AccessToken }       → Authorization: Bearer
 3. POST /api/printers              ── HTTP ─▶  { …, Settings: { TcpListenPort, PublicHost } }
 4. connect PublicHost:TcpListenPort── TCP  ─▶  write ESC/POS bytes, then close (or idle-timeout)
 5. GET  …/{id}/documents/canvas    ── HTTP ─▶  CanvasDocumentListResponseDto  (assert here)

1. Register a workspace (anonymous)

curl -sX POST http://localhost:8080/api/workspaces \
  -H 'Content-Type: application/json' \
  -d '{ "Id": "11111111-1111-1111-1111-111111111111", "WorkspaceName": "autotest" }'
{ "id": "11111111-1111-1111-1111-111111111111", "name": "autotest",
  "token": "brave-tiger-1042-a1b2c3d4e5f60718" }

The Token is shown once, at creation. Persist it for step 2.

2. Login → access token (anonymous)

curl -sX POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{ "Token": "brave-tiger-1042-a1b2c3d4e5f60718" }'
{ "accessToken": "eyJhbGciOi…", "tokenType": "Bearer", "expiresInSeconds": 86400,
  "workspace": { "id": "1111…", "name": "autotest", "role": "User",
                 "documentRetentionDays": 90, "tcpWhitelistEnabled": false,
                 "tcpWhitelistEntries": "", "createdAt": "2026-06-15T10:00:00+00:00" } }

Use AccessToken as Authorization: Bearer … for all subsequent calls.

3. Create a printer

curl -sX POST http://localhost:8080/api/printers \
  -H 'Authorization: Bearer eyJhbGciOi…' -H 'Content-Type: application/json' \
  -d '{ "Printer":  { "Id": "22222222-2222-2222-2222-222222222222", "DisplayName": "T-88" },
        "Settings": { "Protocol": "EscPos", "WidthInDots": 512, "HeightInDots": null,
                      "EmulateBufferCapacity": false, "BufferDrainRate": null,
                      "BufferMaxCapacity": null } }'

The request carries no port: PrinterSettingsDto has no TcpListenPort field, and a port sent anyway is ignored. The server assigns one — you cannot ask for 9100.

Response (PrinterResponseDto) — note Settings.TcpListenPort and Settings.PublicHost: that is where you print.

{ "printer":  { "id": "2222…", "displayName": "T-88", "ownerWorkspaceId": "1111…",
                "ownerWorkspaceName": "autotest", "isPinned": false,
                "lastViewedDocumentId": null, "lastDocumentReceivedAt": null,
                "lastApiReadAt": null, "lastWebReadAt": null },
  "settings": { "protocol": "EscPos", "widthInDots": 512, "heightInDots": null,
                "tcpListenPort": 9101, "emulateBufferCapacity": false,
                "bufferDrainRate": null, "bufferMaxCapacity": null,
                "publicHost": "localhost" },
  "operationalFlags": { "printerId": "2222…", "targetState": "Started", … },
  "runtimeStatus":    { "printerId": "2222…", "state": "Started", … } }

Port assignment. Ports come from the standard printer block that starts at 9100 — the port a physical network thermal printer listens on. 9100 itself is the anchor of the range and is never handed out; the server takes the highest port already assigned and returns the next one up. On a fresh instance that is 9101, as above. On the public virtual-printer.online deployment the low ports are held by printers registered earlier, so a create today comes back with the next free port above them. The port is permanent for the life of the printer and is never recycled, so a client can persist PublicHost:TcpListenPort and reuse it.

Open a socket to PublicHost:TcpListenPort and write the print bytes. A document is finalized when the connection closes or after the listener idle-timeout.

printf 'Hello, world\n\n\n' | nc localhost 9101       # local instance

PORT=...   # Settings.TcpListenPort from step 3
printf 'Hello, world\n\n\n' | nc virtual-printer.online "$PORT"

In C# tests this is short-circuited via TestPrinterListenerFactory / TestPrinterChannel.SendToServerAsync(bytes) instead of a real socket — a real external integration test must open the actual TCP socket on TcpListenPort.

5. Verify the parsed document (HTTP)

curl -s 'http://localhost:8080/api/printers/2222…/documents/canvas?limit=10' \
  -H 'Authorization: Bearer eyJhbGciOi…'

Returns CanvasDocumentListResponseDto — assert against Result.Items[].Canvases[].Items (the rendered elements). To wait for the document instead of polling, subscribe to the SSE stream GET …/{id}/documents/canvas/stream (see SSE).


Endpoint reference

A = requires Authorization: Bearer. = anonymous.

Auth — /api/auth

POST /api/auth/login

· Exchange a workspace token for a JWT. → 200 LoginResponseDto; 401 if the token is unknown.

Request : LoginRequestDto(string Token)
Response: LoginResponseDto(string AccessToken, string TokenType, long ExpiresInSeconds, WorkspaceDto Workspace)

POST /api/auth/logout

A · No-op placeholder (JWT is stateless). → 200.

Workspaces — /api/workspaces

POST /api/workspaces

· Create a workspace. → 200 WorkspaceResponseDto (includes the one-time Token).

Request : CreateWorkspaceRequestDto(Guid Id, string WorkspaceName)
Response: WorkspaceResponseDto(Guid Id, string Name, string Token)

GET /api/workspaces

A · Current workspace. → 200 WorkspaceDto (no Token).

WorkspaceDto(Guid Id, string Name, DateTimeOffset CreatedAt, string Role,
             int DocumentRetentionDays, bool TcpWhitelistEnabled, string TcpWhitelistEntries,
             DateTimeOffset? LastApiReadAt, DateTimeOffset? LastWebReadAt)

LastApiReadAt / LastWebReadAt are the UTC times documents of this workspace were last read through the API and from the web UI (see Last-read stamps in Conventions); null until the first read.

PATCH /api/workspaces

A · Partial update (all fields optional/nullable). → 200 WorkspaceDto; 400 on validation.

Request: UpdateWorkspaceRequestDto(string? Name, int? DocumentRetentionDays,
                                   bool? TcpWhitelistEnabled, string? TcpWhitelistEntries)

DELETE /api/workspaces

A · Delete the current workspace and its data. → 204.

GET /api/workspaces/summary

A · → 200 WorkspaceSummaryDto(int TotalPrinters, long TotalDocuments, long DocumentsLast24h, DateTimeOffset? LastDocumentAt, DateTimeOffset CreatedAt).

GET /api/workspaces/admin-statistics

A (admin) · → 200 AdminWorkspaceStatisticsDto; 403 for non-admin workspaces. Contains aggregate counts plus Workspaces: AdminWorkspaceStatisticsRowDto[] (per-workspace rows, each carrying the workspace's LastApiReadAt / LastWebReadAt). See AdminWorkspaceStatisticsDto.cs for the full field list.

GET /api/workspaces/greeting

· Localized greeting strings, cached 300 s. → 200 GreetingResponseDto(string? Morning, string? Afternoon, string? Evening, string General).

GET /api/workspaces/retention/cleanup-summary

A · Preview of what a retention cleanup would delete. → 200 DocumentRetentionCleanupSummaryDto(int ExpiredDocuments, int RetentionMediaFiles).

POST /api/workspaces/retention/cleanup

A · Run a cleanup. → 200 DocumentRetentionCleanupResultDto(int DeletedDocuments, int DeletedMedia).

Request: RunDocumentRetentionCleanupRequestDto(int MaxDocuments, int? RetentionDaysOverride)

Admin note: a RetentionDaysOverride of 0 deletes everything, across all workspaces. Use with care in shared test environments.

GET /api/workspaces/connections

A · Recent TCP connection attempts (for the whitelist UI). → 200 TcpConnectionEntryDto(string ClientIp, DateTimeOffset ConnectedAt, bool Allowed, string ConnectionType)[].

Printers — /api/printers

POST /api/printers

A · Create a printer (and start its TCP listener). → 200 PrinterResponseDto.

Request : CreatePrinterRequestDto(PrinterDto Printer, PrinterSettingsDto Settings)
  PrinterDto(Guid Id, string DisplayName)
  PrinterSettingsDto(string Protocol, int WidthInDots, int? HeightInDots,
                     bool EmulateBufferCapacity, decimal? BufferDrainRate, int? BufferMaxCapacity)
Response: PrinterResponseDto(PrinterDto Printer, PrinterSettingsDto Settings,
                            PrinterOperationalFlagsDto? OperationalFlags,
                            PrinterRuntimeStatusDto? RuntimeStatus)

Response DTOs:

PrinterDto         (Guid Id, string DisplayName, Guid OwnerWorkspaceId, string? OwnerWorkspaceName,
                    bool IsPinned, Guid? LastViewedDocumentId, DateTimeOffset? LastDocumentReceivedAt,
                    DateTimeOffset? LastApiReadAt, DateTimeOffset? LastWebReadAt)
PrinterSettingsDto (string Protocol, int WidthInDots, int? HeightInDots, int TcpListenPort,
                    bool EmulateBufferCapacity, decimal? BufferDrainRate, int? BufferMaxCapacity, string PublicHost)
PrinterOperationalFlagsDto(Guid PrinterId, string TargetState, DateTimeOffset UpdatedAt,
                    bool IsCoverOpen, bool IsPaperOut, bool IsOffline, bool HasError, bool IsPaperNearEnd)
PrinterRuntimeStatusDto(Guid PrinterId, string State, DateTimeOffset UpdatedAt,
                    int? BufferedBytes, int? BufferedBytesDeltaBps, string? Drawer1State, string? Drawer2State)

Protocol is EscPos or Epl. State/TargetState are Started / Stopped. LastApiReadAt / LastWebReadAt are the UTC times this printer's documents were last read through the API and from the web UI (see Last-read stamps in Conventions); null until the first read.

GET /api/printers

A · All printers for the workspace. → 200 PrinterResponseDto[].

GET /api/printers/

A · One printer. → 200 PrinterResponseDto; 404 if not in the workspace.

PUT /api/printers/

A · Replace printer + settings (same body shape as create). → 200 PrinterResponseDto.

Request: UpdatePrinterRequestDto(PrinterDto Printer, PrinterSettingsDto Settings)

DELETE /api/printers/

A · Soft-delete. → 204.

POST /api/printers/

A · Pin/unpin. → 200 PrinterResponseDto.

Request: PinPrinterRequestDto(bool IsPinned)

PATCH /api/printers/

A · Set emulated hardware flags and/or TargetState (Started/Stopped). → 200 PrinterOperationalFlagsDto.

Request: UpdatePrinterOperationalFlagsRequestDto(bool? IsCoverOpen, bool? IsPaperOut, bool? IsOffline,
                                                 bool? HasError, bool? IsPaperNearEnd, string? TargetState = null)

PATCH /api/printers/

A · Set emulated cash-drawer state. → 200 PrinterRuntimeStatusDto.

Request: UpdatePrinterDrawerStateRequestDto(string? Drawer1State, string? Drawer2State)

GET /api/printers/

A · Paged rendered documents (newest first). → 200 CanvasDocumentListResponseDto. Query: GetDocumentsRequestDto(int Limit = 20, Guid? BeforeId = null). Counts as a read: stamps lastApiReadAt (or lastWebReadAt with X-Client: web) on the printer and its workspace, unless an admin is reading another workspace's printer.

CanvasDocumentListResponseDto(PagedResult<RenderedDocumentDto> Result)
PagedResult<T>(IReadOnlyList<T> Items, bool HasMore, Guid? NextBeforeId, DateTimeOffset? NextBeforeCreatedAt)
RenderedDocumentDto(Guid Id, Guid PrintJobId, Guid PrinterId, DateTimeOffset Timestamp, string Protocol,
                    CanvasDto[] Canvases, string? ClientAddress, int BytesReceived, int BytesSent,
                    string[]? ErrorMessages)
CanvasDto(int WidthInDots, int? HeightInDots, IReadOnlyList<CanvasElementDto> Items)

CanvasElementDto is a polymorphic hierarchy — CanvasTextElementDto, CanvasImageElementDto, CanvasLineElementDto, CanvasBoxElementDto, CanvasDebugElementDto. See Canvas/Elements/CanvasElementDto.cs for each shape.

DELETE /api/printers/

A · Clear all documents for the printer. → 204.

POST /api/printers/

A · Import a raw print payload as if it had been printed. → 204; 400 with the reason when the payload cannot be decoded.

Request: ImportDocumentRequestDto(string Data, string? Format = null)

Data is the bytes as base64 or as a hex dump; whitespace is ignored in both. Format is "Hex" or "Base64" and is optional — omitted, the format is detected: bytes spaced apart are read as hex, a payload valid as only one form is read as that one, and a run-together payload valid as both is refused, because every hex digit is also a base64 character and the two readings differ. See Import a captured dump.

POST /api/printers/

A · Stub — returns 501 Not Implemented. SetLastViewedDocumentRequestDto(Guid DocumentId).

Media — /api/media

GET /api/media/

· Download media (image raster referenced by a canvas element). → 200 binary with ETag: "sha256:<checksum>"; 404 if unknown.


SSE (streaming) endpoints

text/event-stream; each event data: is the JSON of the noted DTO. These are not request/response and are poorly represented in OpenAPI — documented here instead. Cancel by closing the connection (server treats it as a normal disconnect).

Endpoint Auth Emits
GET /api/printers/sidebar/stream A PrinterSidebarSnapshotDto updates
GET /api/printers/{id}/runtime/stream A PrinterRuntimeStatusDto updates
GET /api/printers/{id}/documents/canvas/stream A RenderedDocumentDto on each completed print (404 if the printer isn't visible); every delivered document counts as a read (see Last-read stamps)

Test-harness notes

  • JWT secret guard: the app exits at startup if Jwt:SecretKey is missing, shorter than 32 chars, or still the your-secret-key… placeholder. Integration hosts must supply a valid secret (≥32 chars).
  • In-memory DB: dotnet test swaps SQLite for a named in-memory database; a keeper connection in ApiFactory keeps it alive for the test's lifetime.
  • Static files / docs are skipped when the environment is Test.
  • Document completion happens on TCP socket close or after the listener idle-timeout (PrinterConstants.ListenerIdleTimeoutMs) — give the document a moment, or use the SSE stream, before asserting on …/documents/canvas.
Next pagePrinter Status API →