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.mdin 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 camelCase —
token,accessToken,id. No naming policy is configured, so the ASP.NET Core default applies. Request bodies are matched case-insensitively, soTokenandtokenare both accepted on the way in - it is only what comes back that is camelCase. - Null omission: properties that are
nullare omitted from responses (DefaultIgnoreCondition = WhenWritingNull). The exceptions are the timestampsprinter.lastDocumentReceivedAt,printer.lastApiReadAt,printer.lastWebReadAt,workspace.lastApiReadAtandworkspace.lastWebReadAt, which are always emitted (may benull). - Auth header:
Authorization: Bearer <AccessToken>on every[Authorize]endpoint. Get the token fromPOST /api/auth/login. - Last-read stamps: reading a printer's documents (
GET …/documents/canvas, and each document delivered byGET …/documents/canvas/stream) records the UTC time of the read on the printer and on its workspace:lastApiReadAtfor API clients,lastWebReadAtfor the bundled web UI, which marks its calls with theX-Client: webheader. 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.
4. Print — raw TCP (NOT HTTP)
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
RetentionDaysOverrideof0deletes 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:SecretKeyis missing, shorter than 32 chars, or still theyour-secret-key…placeholder. Integration hosts must supply a valid secret (≥32 chars). - In-memory DB:
dotnet testswaps SQLite for a named in-memory database; a keeper connection inApiFactorykeeps 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.