HTTP: native API (reference)
The service's native API under /api/v1/—reading, writing, calling procedures, running custom statements—for
programs that don't speak OData. Clients that speak OData v4 (Access via the driver, Excel, Power BI)
use the OData interface.
Syntax
| Method | Path | Meaning |
|---|---|---|
GET | /api/v1/cf/{alias}/{kind}/{name}.json | read; kind: tables, views, or functions |
POST | /api/v1/cf/{alias}/procedures/{name}.json | JSON body with named arguments |
POST | /api/v1/cf/{alias}/sql.json | body: custom statement (must be enabled) |
GET | /api/v1/cf/{alias}/tables/{name}.json?{key}={value} | read one record |
POST | /api/v1/cf/{alias}/tables/{name}.json | create; body: fields; response 201 + record |
PATCH | /api/v1/cf/{alias}/tables/{name}.json?{key}={value} | update; body: fields; response 200 + record |
DELETE | /api/v1/cf/{alias}/tables/{name}.json?{key}={value} | delete; response 204 |
Without sign-in, for monitoring and distribution:
| Method | Path | Meaning |
|---|---|---|
GET | /health/live | process is alive (200, text “Healthy”) |
GET | /health/ready | 200; “Healthy” with at least one alias, otherwise “Degraded” (also 200) |
GET | /api/v1/health | {"status":"ok","configuredAliases":<number>} |
GET | /download/ | installation packages (see below) |
With Windows sign-in, for the WKL Bootstrapper:
| Method | Path | Meaning |
|---|---|---|
GET | /schema-state/{alias}/{product} | schema state of a versioned database (see below) |
Parameters
The extension determines the representation: .json or .xml, at each of these addresses and for both
success and error. See Representations.
You can sign in with any method that the configuration enables: service key in the X-Api-Key header,
personal key (in the same header or as the password of HTTP Basic), Windows account (Negotiate) or SQL
Server login (HTTP Basic). All methods, their responses and the second factor are described in
HTTP: sign-in; they apply unchanged to this contract.
Writing to tables
Writing is as dynamic as reading: method, table, key and fields come from the request, and the service generates the SQL. The caller sends no SQL here. The caller determines which record (via the key) and which values (via the fields), never a statement, a condition or a column the catalog doesn't know.
PATCH /api/v1/cf/fachdaten/tables/APPARAT.json?nummer=4711
X-Api-Key: …
If-Match: "AAAAAAAQgpE="
Content-Type: application/json
{ "benennung": "Kühler Nord", "preis": 12.50 }
The body of a state-changing request carries Content-Type: application/json, an uploaded file
application/octet-stream. The service answers a body without a type, or with a type a website can send without a
preflight (text/plain, application/x-www-form-urlencoded, multipart/form-data), with 400 and the code
BAD_REQUEST before it reads the body. The exception is the query options of a read in $query, which arrive as
text/plain.
Principles and reasoning: Writing and write conflicts.
| Rule | Why |
|---|---|
PATCH sets only the named fields | The others keep their value; nobody resets fifty fields to the state of an older copy |
PUT isn't offered (405) | “Replace the record” would have to empty every column that isn't named |
| The key can't be changed | The record would move away from under its own address |
| Identity, computed and version columns aren't writable | The database sets them |
| Unknown fields or fields written twice are rejected | Which of two spellings won would depend on the order in the body |
| On create, all required fields and the key must be present | Except a key that the database assigns—as an identity or by default (NEWSEQUENTIALID(), NEWID(), sequence); the statement reads the assigned value during the insert (OUTPUT … INTO, also for tables with triggers), and the stored record is read back via it |
How values are bound: Timestamps, numbers, and names.
| Response | Meaning |
|---|---|
201 / 200 + record | created / updated; the record is the stored state |
204 | deleted |
400 | key incomplete, field unknown or not writable, value doesn't match the type |
404 | no record with this key |
409 | key already taken on create |
412 | row version outdated |
422 | refused by the database (required value, foreign key, check constraint); the SQL Server messages are in details (see “SQL Server messages” under error format); if a trigger raises its own message, its text is in message (see “Exception: application messages”) |
To write, a caller needs a rule with the resource type tables and the verbs POST, PATCH, DELETE.
The read-only flag blocks all three.
Query options when reading: $select, $filter, $orderby, $top, $skip, $count. All of them are
translated into SQL and evaluated by the database.
What $filter accepts
| Element | Accepted notations |
|---|---|
| Comparisons | eq ne gt ge lt le, check for a missing value with eq null / ne null |
| Field against field | ParentId eq Id, Stufe lt Grenze: a field name on the right instead of a value; both fields must belong to the set, an empty field matches neither the comparison nor its negation. SQL Server refuses text against number (422)—since contract 0.4.0 (GatewayFilter.FieldComparison) |
| Logical operators | and, or, not, parentheses |
| Text search | startswith(feld,'x'), endswith(feld,'x'), contains(feld,'x'), substringof('x',feld) |
| Numbers | 5, 12.5, with a width suffix 5L, 12.5m, 12.5f, 12.5d |
| Timestamps | datetime'2023-01-01T00:00:00', datetimeoffset'…' and without a type name 2023-01-01T00:00:00Z |
Identifiers (uniqueidentifier) | guid'6f9619ff-8b86-d011-b42d-00cf4fc964ff' and without a type name 6f9619ff-8b86-d011-b42d-00cf4fc964ff (that's how the ODBC driver writes them) |
| Other types | time'…', binary'…' |
Both notations of the protocol are accepted, because the consumers don't all belong to one generation:
substringof and datetime'…' come from the older version, contains, the timestamp and the identifier
without a type name from the newer one. Which of them arrives is decided by the client, not by the request.
A rejected literal is more expensive than it looks. A spreadsheet or reporting client doesn't pass the
400on—it drops the condition and reads the whole object instead. A targeted query thus turns into a full scan of the table, which can abort on large tables. That's why the literals of the protocol are part of the contract and not a convenience.
Which query options are supported
Supported: $select, $filter, $orderby, $top, $skip, $count, $inlinecount, $format (json only). The total
count is requested in both notations—$count=true and $inlinecount=allpages—because the older and the
newer version of the protocol name it differently and the client decides which one arrives.
Rejected with 400:
| Rejected | Message |
|---|---|
$expand, $apply, $search, $compute, $skiptoken, $levels, $ref, $value, $id | “wird von diesem Dienst nicht ausgeliefert” (isn't supported by this service) ($skiptoken and $apply are accepted only by the OData interface, see next pages and grouping there) |
any other option with $ | “ist keine bekannte Abfrageoption” (isn't a known query option) |
a value that is neither true nor false ($count=yes) | “erwartet 'true' oder 'false'” (expects 'true' or 'false') |
a representation other than JSON ($format=xml) | “wird nicht ausgeliefert” (isn't supported) |
A skipped option is worse than a rejected one. An option is a statement about which rows or columns are wanted; dropping it doesn't produce an error but a different result that looks like an answer. Without this check,
$fillter=Typ eq 'S'—one letter too many—would be answered with200and the whole object instead of the rows wanted, and nothing in the response would say so.
The distinction between “not supported” and “unknown” is intentional: whoever sends $expand has written
something correct that isn't served here; whoever sends $fillter has made a typo. The two need different
corrections, and a shared message would send the first one looking for a typo that doesn't exist.
Response format
{ "data": [ … ], "rowCount": 100, "count": 100, "window": 100, "totalCount": 250, "nextLink": "/api/v1/cf/fachdaten/views/liste.json?$skip=100&wkl-page=…", "executionTimeMs": 0 }
| Field | Meaning |
|---|---|
rowCount | rows in this response. Always present, never negative |
count | mirror of rowCount. Older notation of the predecessor service; it stays because existing callers read it |
window | window size actually used for reading. Not present for a procedure call |
totalCount | rows that the same filter matches in total. Present only with $count=true |
affectedRowCount | rows changed by a procedure call—only if it returned no result |
truncated | present only if the row limit applied |
nextLink | link to the next window, path-absolute. Present only if the service chose and filled the window—at the end the field is missing |
windowandnextLinkare the additions compared with the older versions. Withoutwindow, a caller can't tell a full window from a short one—and that's exactly how a caller without$countrecognizes that it has reached the end. The requested size isn't enough for this: it's capped to the configured maximum, and anyone who doesn't name one gets the configured default, which they don't know. A consumer that doesn't know the field skips it.
nextLinksays the same thing in the form that a tool without knowledge of this contract reacts to on its own: a link that it follows until it's missing.windowis a convention that only a caller written specifically for it evaluates—every other one stops after the first window and takes it for the whole result.
When a next link is offered
| Request | Response |
|---|---|
no $top, window full | link with $skip + window size, without $top |
$top larger than MaxTop, window full | link with the remainder as $top—whoever requests 25,000 gets exactly 25,000, spread over several windows |
$top not larger than MaxTop | no link—the caller got what they asked for |
| window short or empty | no link—the result is complete |
The link carries all other options—filter, projection, sorting, function arguments—unchanged, so that
every window is a window of the same result. Only the count ($count, $inlinecount) is dropped: it's
the same for all windows of one read, and repeating it per window repeats the most expensive part for a
known answer. A full window doesn't prove that further rows exist; if the result ends exactly at its edge,
the link leads to an empty window. That's a correct answer and cheaper than a count on every read.
The link is path-absolute (/api/v1/cf/…?…) and doesn't name a host: behind an upstream proxy, the host
that this process sees isn't the one the caller used.
totalCountis missing if nobody counted. That isn't the same as0. Whoever simplifies the distinction to a zero tells a list there's nothing to load—about data that demonstrably exists and without an error message anywhere.
Error format
Notes “since contract 0.x” name the version of the contract package Wkl.Data.Gateway.Contracts from which
an error kind or a field is recognized; without a note, it applies since 0.1.0. A program that sticks to an
older version reads the newer kinds by their status code, as described below for each kind.
{ "error": { "code": "NOT_FOUND", "message": "…", "correlationId": "…" } }
Codes: BAD_REQUEST (400), UNAUTHENTICATED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409),
PRECONDITION_FAILED (412), LIMIT_EXCEEDED (429), DATABASE_REJECTED (422), UNAVAILABLE (503),
DATABASE_UNREADABLE (503), TIMEOUT (504), INTERNAL (500).
UNAVAILABLE stands not only for an unreachable database but for every unexpected error in the service.
After a write request (POST, PATCH, DELETE, commit of a transaction), 503 therefore means: whether
anything was written is unknown. A 5xx response isn't stored for Idempotency-Key; a repetition may
then write a second time. Read the record again first.
LIMIT_EXCEEDED stands for every limit of the service: requests per period, failed attempts, number of
bound values, size of the body (Limits:MaxBodyBytes), open transactions. For a body that is too large,
the contract thus deviates from
RFC 9110, section 15.5.14, which specifies
413 Content Too Large: a caller should recognize all limits by one kind. Whether waiting helps (rate
limiting) or only a smaller request (body, values) is stated in message.
TIMEOUT (since contract 0.3.0; previously UNAVAILABLE) means: the statement ran and exceeded
Limits:QueryTimeoutSeconds. The database was reachable; a database driver reports this as a timeout
(SQLSTATE HYT00), not as a lost connection. TIMEOUT and UNAVAILABLE count as transient
(IsTransient).
DATABASE_UNREADABLE (since contract 0.6.0; previously UNAVAILABLE) means: SQL Server was reachable but
can't read the alias's database. The database files can't be read (823, 824, 605), or the database is
offline (942), marked as suspect (926), its files can't be opened (945), it's still being recovered
(922) or its log is missing (9001). message names the alias, for example “Die Datenbank der Datenquelle
'lager' ist auf dem Server derzeit nicht lesbar. Bitte den Betreuer verstaendigen.” (The database of the data
source 'lager' currently can't be read on the server. Please notify the person responsible.); file names
and number are only in the log, details is missing. It doesn't count as transient: repeating helps only
once someone acts on the server. A caller that doesn't know the code reads the status code 503 and treats
it like UNAVAILABLE. The .NET client Wkl.Data.Gateway.Client reports it from version 0.2.0 as
GatewayProblemKind.DatabaseUnreadable (not transient); version 0.1.0 doesn't know the code and reports
GatewayProblemKind.Unavailable, that is, “transient”. A 503 never means “no data”. Conversely, the absence
of the code doesn't prove a readable database: it stands only for the error numbers listed above; a read
error for another reason comes as UNAVAILABLE.
After a brief loss of the drive holding the database files, this remains so until the “SQL Server” service on the database server is restarted; statements whose pages are still in SQL Server's memory continue to succeed during this time.
An unknown resource and a resource that the caller may not read both respond with NOT_FOUND.
Exceptions: a readable object that the caller may not change in this way responds FORBIDDEN; a checked
statement responds equally with FORBIDDEN for unknown and for hidden objects. The distinction would turn
the service into a directory: a caller could learn by comparing the responses which objects exist.
What an error response never contains: database name, connection string, statement text, the name of
an object that the caller may not see. This information is in the log, findable via the correlationId.
SQL Server messages (details, since contract 0.5.0). If the database refuses a statement, the response
carries the SQL Server messages, in the order in which SQL Server reports them—like a direct connection
via the SQL Server driver, so that code can branch on the number (for example 2627 “key taken” versus 547
“reference violated”):
{ "error": { "code": "DATABASE_REJECTED", "message": "Der Vorgang wurde von der Datenbank abgelehnt.",
"correlationId": "…",
"details": [ { "code": "547", "sqlState": "23000", "severity": 16, "released": true,
"message": "Die INSERT-Anweisung steht in Konflikt mit der CHECK-Einschränkung …" } ] } }
| Field | Content |
|---|---|
code | SQL Server message number, as text (OData form of details) |
sqlState | SQLSTATE that the SQL Server driver reports for this number: 23000 constraint, key, required value; 22001 value too long; 22018 conversion; 22007/22008 date or time; 22003 overflow; 22012 division by zero; 42S02/42S22 object or column unknown; 25000 transaction state; 40001 deadlock; 01000 informational; otherwise 42000 |
severity | severity: up to 10 an informational message accompanying the refusal, from 11 an error |
released | true: message is the SQL Server text. false: the text names an object that the caller may not see; message is then a text of the service, number and SQLSTATE are still correct |
message | text, as described above |
details carries at most ten messages. The caller gets the number for each of them. The text only if it
contains no name that the caller may not see: no table, view, routine, constraint, trigger or index of an
object that their rules don't reach, and neither the alias login nor the server name. The name is checked
as a whole word, regardless of quotation marks; this way the check also works in German messages that name
names without quotation marks (“in der lager-Datenbank”). The name of the database is replaced by the alias.
A hidden name that happens to be an ordinary word holds back a harmless text; the number still arrives.
Messages with a value. Conversion errors (245, 8114, date values 241 and 242), a duplicate key (2627,
- and a truncated value (2628, 8152) name a value (“… beim Konvertieren des Werts 'X' …” (… when
converting the value 'X' …), “Der doppelte Schlüsselwert ist (X)” (The duplicate key value is (X))). Whether
X was the caller's input or was read from the data can't be told from outside: the caller's filter, an own
statement (INSERT … SELECT, UPDATE … FROM, MERGE), a procedure or a trigger can deliberately bring a
value it has read into a unique or too short column. If the statement runs as the alias login, the service
therefore always holds back the text of these messages (released false); number, SQLSTATE and status code
(409 for a taken key) arrive. Reason: if the database hides rows per person (row-level security via the
SESSION_CONTEXT that the service sets), SQL Server may evaluate such expressions before or alongside the
security predicate, and the text would reveal a value from a hidden row; under the alias login, the
service's check is the only safeguard. If the statement runs as the person (identity switch or the person's own SQL
Server login), SQL Server itself decides what they may read, and the text comes as over a direct connection.
The rule depends on the identity, not on the form of the statement.
details is missing for a lost connection (503), an unreadable database (503 DATABASE_UNREADABLE) and
a timeout (504): these are statements about the connection, not responses of the database to the
statement. message and status code stay unchanged.
Exception: application messages. If the application in the database raises a message itself—THROW
with its own number or RAISERROR with a text, that is, error number from 50000 and severity 11 to 16—its
text is in the response as message (422 DATABASE_REJECTED), on every route: procedure call, writing to
tables (also from a trigger), OData and pass-through. Such a message is written for the person who triggered
the operation, and a direct connection would show it to them as well. What it names is decided by whoever
writes it. Messages from SQL Server itself (number below 50000) are only in details; message then
remains the text of the service.
Calling procedures
A stored procedure is called with POST on procedures/{name}; the body is a JSON object with the arguments
by name.
POST /api/v1/cf/fachdaten/procedures/auftrag_anlegen.json
X-Api-Key: …
Idempotency-Key: 6c1e0c7a-…
Content-Type: application/json
{ "kunde_id": 4711, "betreff": "Kühler Nord", "stand": "2026-10-10T17:00:00.123+02:00", "notiz": null }
From this, the service runs SET XACT_ABORT ON; EXEC [schema].[auftrag_anlegen] @kunde_id = @p0, ….
| Rule | Why |
|---|---|
Argument names without @; a name with @ is rejected as an invalid identifier (400) | The service sets the prefix. One sent along would give @@name, a name consisting only of @ an unnamed parameter—both would fail only in the database, with a message about the procedure instead of about the request |
| Call by name; an omitted argument takes the procedure's default value | A call by position can't skip an optional parameter |
JSON null becomes NULL | A missing value must be distinguishable from an empty text; as a query argument, an optional text would arrive as '' and overwrite a value that should remain |
| A procedure without parameters is called without a body | That's the ordinary call, not an error |
An object or a list as a value is rejected (400) | No procedure parameter accepts it; flattened into text, the procedure would get something it never declared |
No transaction of the service around the call; SET XACT_ABORT ON before it | The procedure manages its own. An outer transaction would nest: an inner ROLLBACK would also discard the outer one, an inner COMMIT would only count down. XACT_ABORT rolls back on a runtime error instead of handing an open transaction with its locks to the connection pool |
A call is exactly one EXEC | Whatever needs several steps is a procedure with several steps in one transaction; two calls in a row would leave a half-finished state behind if an error occurred in between |
Type mapping of the arguments. Values are bound without a type; SQL Server converts the value to the type of the parameter.
| JSON value | Bound as |
|---|---|
| Text | nvarchar |
Integer (4711) | bigint |
Decimal number without exponent (12.50) | decimal, exact |
Number with exponent (1E-300) | float |
true / false | bit |
null | NULL |
Consequence for timestamps: there's no JSON date. A timestamp goes as text, for example ISO 8601
(2026-10-10T17:00:00.123+02:00), and SQL Server converts it to the type of the parameter—also into a
varchar parameter that the procedure converts itself (CONVERT(…, 127)).
Response format.
{ "data": [ { "ergebnis": "angelegt", "auftrag_id": 815, "stand": "2026-10-10T17:00:00.123+02:00" } ], "rowCount": 1, "count": 1, "executionTimeMs": 12 }
| Field | Meaning |
|---|---|
data | the first result set of the procedure, as a list of objects (column name → value). Further result sets aren't read. Without a result set, an empty list |
rowCount, count | rows in data |
affectedRowCount | only if the procedure returned no result set: the number of changed rows; data is then an empty list |
truncated | the alias's row limit applied |
executionTimeMs | run time in the service |
There's no window and no nextLink: a procedure delivers the result set it was written for, and the caller
can't request part of it. Values of the result set are in the same form as when reading; a datetimeoffset
with its offset.
No OUTPUT parameters, no RETURN value on this route. This address reads neither one nor the other, and
it reads only the first result set. Whatever the caller should learn—the assigned ID, the new state, a
result word—the procedure returns here as a result set (SELECT … AS ergebnis, … AS id). If you need
output parameters, the return value or all result sets, call the procedure as a statement
(EXEC @rueckgabe = …), see
Output parameters, multiple result sets, describing.
Errors.
| Cause in the procedure | Response |
|---|---|
THROW with its own number, RAISERROR with text, from severity 11 | 422 DATABASE_REJECTED, message is the text of the message |
| An error from SQL Server itself (for example division by zero, foreign key) | 422 DATABASE_REJECTED, text “Der Vorgang wurde von der Datenbank abgelehnt.” (The operation was refused by the database.) |
RAISERROR up to severity 10 (informational) | not an error: 200 with the result; the informational text is lost |
Primary key or unique index violated (2627, 2601) | 409 CONFLICT |
Service timeout exceeded (Limits:QueryTimeoutSeconds) | 504 TIMEOUT |
| Database not reachable | 503 UNAVAILABLE |
Database not readable on the server (823, offline, suspect) | 503 DATABASE_UNREADABLE |
Missing permission (229, 230, 262, 297, 300, 916) | 403 FORBIDDEN if the statement runs as a person (identity switch or SQL Server login); otherwise 422—then the alias login lacks the permission, and that's a gap in the setup, not on the caller's side |
Since contract 0.5.0, the number, SQLSTATE and severity of each message are in details, the text only if
the service releases it (see Error format); previously, only the text of the application
message was in message. A caller that should act differently depending on the reason is still better off
getting the reason as a result row (ergebnis = 'gesperrt'): an application number from 50000 can be chosen
freely for each reason, but it's only a convention between procedure and caller. And a message below
severity 11 is informational, not an error: it doesn't abort anything and appears nowhere in the response.
Rights. A caller needs a rule with the resource type procedures and the verb POST; the read-only
flag blocks the call, because the service can't know whether a procedure writes. A procedure that the
alias's catalog doesn't know or that the caller may not call responds with 404. Repetitions after a
connection drop: the Idempotency-Key applies here too, see
Repeated write requests.
Long queries: options in the body
The query options of a read request are in the address, and the host in front of the service limits its
length: by default, IIS accepts at most 2,048 bytes of query string
(requestFiltering/requestLimits/@maxQueryString, Microsoft:
Request Limits),
Kestrel 8 KB of request line (KestrelServerLimits.MaxRequestLineSize). The IIS template of the
installation package raises the limit to 8,192 characters. A read request may therefore also carry its
options in the body—in two forms, on both interfaces:
POST /odata/v4/fachdaten/dbo/Artikel/$query Content-Type: text/plain POST /odata/v4/fachdaten/dbo/Artikel/$count/$query POST /api/v1/cf/fachdaten/tables/artikel.json/$query QUERY /odata/v4/fachdaten/dbo/Artikel (likewise at the other addresses) $filter=Nummer%20eq%201%20or%20Nummer%20eq%202&$select=Nummer,Bezeichnung&$count=true
The body is exactly the text that would otherwise follow the ?. POST …/$query is the form that OData 4.01
specifies for this
(OData Version 4.01 Part 1: Protocol,
section “Passing Query Options in the Request Body”), and it gets through every proxy that lets POST
through. QUERY is the method for safe read requests with a body from the IETF draft “The HTTP QUERY Method”
(draft-ietf-httpbis-safe-method-w-body); as it's a draft, the service accepts it in addition, without
committing to its final form. The service deliberately doesn't accept a body on GET: according to
RFC 9110, section 9.3.1, it has no defined meaning
there, proxies discard it or reject the request, and a cache that stores by address would hand out the
response for one filter for another.
| Rule | Reason |
|---|---|
| Checked, limited and logged as a read | A caller with read-only rights may do it; judged by the method, they would need the right to write |
The body is read only after sign-in and authorization, limited by MaxBodyBytes | Reading a body is work; an unauthorized request shouldn't trigger it |
Options in the address and the body: 400 | Nobody reading the request could tell which of two $filter applies |
Only text/plain (or application/x-www-form-urlencoded) | JSON is a different form of request; reading it as options would mean reading it wrong |
The next page points to the resource, not to $query | A client follows it with GET; if that's too long itself, it sends it again as $query |
From the moment of acceptance, both forms are the same read: the same translator, the same limits, the same paging. How many values a condition may name therefore doesn't depend on the route but on the form of the condition; see Long value lists.
Long value lists
A condition such as “one of these numbers” arrives as a chain of equalities:
Nummer eq 1 or Nummer eq 2 or …. The filter language of OData 4.0 has no in (only 4.01 introduces it,
URL Conventions 4.01);
the ODBC driver therefore writes an IN (…) from Access this way, and the typed client writes a membership
check the same way. Bound individually, every value would be a separate parameter, and SQL Server accepts at
most 2,098 of them in one statement: a call carries 2,100 parameters, two of which sp_executesql uses
itself.
The typed client allows at most 20,000 values in a membership check (since contract 0.2.0, previously 1,000); the contract package checks the limit before the request leaves the process.
The service therefore combines such a chain. More than 100 equalities on the same column within one or
use a single parameter, no matter how many there are: they travel as one XML document. The statement first
reads the document into a variable and compares the column with the values from it, each read in the type
its parameter would have had.
| Rule | Reason |
|---|---|
| Up to 100 values, each value stays a separate parameter | The optimizer then sees the values themselves, and the short key lists with which an application re-reads its records stay unchanged (Access queries ten keys at a time) |
| XML, not JSON and no table-valued parameter | The engine reads XML at every compatibility level. OPENJSON and STRING_SPLIT require level 130, a table-valued parameter requires a type that would first have to be created in the database |
| The document is read into a variable once | In the condition itself, the engine would convert the list again for every row checked; with long lists, the run time would grow with the number of values times the number of rows |
| The list matches exactly the records that the same values as parameters would match | Every value is in the document in a form that the engine reads back without loss (table below) |
| If a list can't achieve that, values are bound one by one | Mixed kinds of values, a timestamp outside the range of datetime for such a column, a time of day outside one day, text with characters that XML can't carry, decimal values whose longest integer part and largest scale together exceed 38 digits. The limit of 2,098 values then applies again |
| The list is explicitly declared free of missing values | With NOT IN, the engine otherwise has to check for every row whether the list contains a missing value—in which case the condition would match no row. With the hint, this check is skipped, and NOT IN over a long list stays fast |
Equalities under an and aren't combined | (a eq 1 and b eq 2) or …—such as the pairs of a composite key—isn't a list of one column |
Lossless per type. In every column, the list matches the same records as the individually bound values, including duplicates, empty texts and texts consisting only of spaces, line breaks, and characters outside the Unicode Basic Multilingual Plane.
| Type | Form in the document | Why this form |
|---|---|---|
| Integers | digits, read as bigint | — |
decimal, numeric | digits, read as decimal(38, s) with the largest scale of the list | Both comparisons are exact |
float, real | integer mantissa and power of two | As text, the engine reads some values as the neighboring value (subnormal and large integer values); in this form, none deviates |
datetime, smalldatetime | rounded to 1/300 s beforehand, as the driver rounds a parameter | Unrounded, some values wouldn't match the same records |
datetime2, date, time, datetimeoffset | with seven decimal places | — |
| Text | line endings and tabs as a character reference; an empty value as an element without text | A literal carriage return would turn into a line feed when the document is read. Comparison uses the column's collation, whether case-sensitive or not |
| Binary values | Base64 | — |
bit, uniqueidentifier | ordinary notation | — |
What limits the length of a list: the request body. As a guideline, the default of 1 MB for
MaxBodyBytes holds about 36,000 values with a short column name and short numeric values; longer column
names and values shorten the list accordingly. The limit of 2,098 individual values remains for
everything that isn't a list—many different columns, comparisons with gt or lt, text search, and
chains of at most 100 values per column that add up to more. Such a request is rejected with 429 (“Die
Anfrage bindet zu viele Einzelwerte.” (The request binds too many individual values.)) before it reaches
the database.
Representations: JSON and XML
Every address of the native API ends in .json or .xml; the extension decides, not an Accept header.
That's intentional: several spreadsheet and reporting tools offer no way to set a header, but every one of
them can point to an address. An extension that names neither representation is rejected with 400 and not
silently answered as JSON.
Success and error come in the requested representation. A client that requests XML and would get a JSON object in the error case can't read precisely the response it needs most urgently.
The XML form follows a fixed row convention, because JSON and XML aren't symmetrical: a list has no element name of its own, since its entries don't carry a key.
<?xml version="1.0" encoding="utf-8"?> <results xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <data> <row> <nummer>4711</nummer> <GeändertAm>2026-09-22T21:16:00</GeändertAm> <bemerkung xsi:nil="true" /> <field name="Menge (kg)">12.5</field> </row> </data> <rowCount>1</rowCount> </results>
| Rule | Why |
|---|---|
Every entry of a list becomes <row> | A list entry has no key from which an element name could be formed |
| A column name becomes an element | Even with an umlaut: valid XML names aren't restricted to ASCII |
A name that isn't an XML name becomes <field name="…"> | Spaces, parentheses, leading digit. Neither renamed nor omitted—the caller must find the column under its real name. In a request, they name it encoded, see Timestamps, numbers, and names |
A missing value is an empty element with xsi:nil="true" | An empty element alone can't be distinguished from the empty string, and for a nullable text column these are two different facts |
| Values are culture-independent and as in JSON | decimal separator period, timestamp according to ISO 8601, binary value as Base64 |
| Characters that XML forbids (control characters except tab and line break) can't be represented | The response then fails with 503; read such data via JSON |
Custom statements (pass-through)
Must be enabled, see
the one exception.
A caller without the sql right whose rules name the alias reaches the same address with checked
statements; then the section Checked statements also applies. The body carries
the statement and, where it has placeholders, their values:
POST /api/v1/cf/fachdaten/sql.json
X-Api-Key: …
{
"sql": "SELECT b.name, COUNT(*) AS anzahl FROM apparat a JOIN betrieb b ON b.id = a.betrieb WHERE a.stand = @stand GROUP BY b.name",
"parameters": { "stand": 3 }
}
Values travel as parameters, never in the text. That isn't a protective measure here—the text belongs
to the caller anyway—but it prevents a value from being read as syntax, which is exactly what turns a date
or a decimal number into a different value depending on the session's language setting. Parameter names are
ordinary identifiers and are sent without @.
The response has the usual success format, extended by columns:
{
"columns": [
{ "name": "name", "type": "Edm.String", "storeType": "nvarchar", "nullable": true, "maxLength": 50 },
{ "name": "anzahl", "type": "Edm.Int32", "storeType": "int", "nullable": true }
],
"data": [ { "name": "Halle 1", "anzahl": 42 } ],
"rowCount": 1,
"executionTimeMs": 12
}
columns exists only here, and it isn't an extra: for an object of the catalog, a client can look up the
shape beforehand in the schema document; for a self-written statement, the shape comes into being only on
execution. A client that got only the rows would have to guess the types from the values that happen to be
in the first row—and an empty result would then have no shape at all. That's why columns is in the
response even when the statement returns no result: as an empty list that says “no columns”, instead of
a missing field that says nothing. The type names are the same as in the schema document of the OData
interface, with two differences: values of datetime, datetime2 and smalldatetime are given as wall-clock
time without Z or offset, although the type is called Edm.DateTimeOffset; a column of a type that
isn't offered carries "type": null. storeType names the database's type, like Store.Type in the schema
document: from the protocol type alone, a client can't tell that an Edm.Binary holds a geography or an
Edm.String a sql_variant. A column whose value the database assigns also carries "identity": true;
SQL Server reports this for an identity column taken unchanged from a table. Rows carry their values
under the column's name, so a column without a name or with a name already taken gets one of its own
(Column2, Nummer1) and carries the database's name in label: empty for an expression without a
name, the repeated name for the second column of that name. ODBC Driver 18 shows both as the database
reports them. An xml value appears in the form SqlClient writes it – an empty element as <r />
instead of <r/>; the document is the same.
More on the format:
| Field | Meaning on this route |
|---|---|
data | the first result with columns. Counts that a trigger outputs beforehand aren't the answer to the question asked. Further results are in results, see below |
affectedRowCount | sum of the changed rows over all statements of the text; missing if the statement doesn't produce such a count |
truncated | the alias's row limit applied; the response is truncated |
Every column has its own name. A column without a name (SELECT MAX(a), MIN(b)) is called Column with its
position, as in a DataTable; a duplicate name (SELECT a.id, b.id) gets the smallest number that makes it
unique (id1). The row carries its values under the name; without unique names, several columns would
arrive as one value.
Time and row limits come from Limits, as everywhere else. The key against which a rule under
Limits:Objects is checked is {alias}/sql/ here—a pattern such as fachdaten/sql/* thus sets the limits
of this route without touching those of the tables. This is where a long evaluation gets more time than a
datasheet.
Checked statements
For a caller without the sql right, the service accepts a statement at the same address if the alias
allows checked statements (AllowCheckedStatements, on by default) and one of the caller's rules names the
alias. The service parses the statement, checks every named object against the caller's rules and runs a
rewritten version in which every object is named with its schema. What is permitted is described under
Checked statements.
Body and response are the same as above; in addition:
| Case | Response |
|---|---|
| The text isn't T-SQL | 400 with the line and column of the location |
The GO separator line, a schema change, dynamic SQL, a name with database or server, a function or form that isn't permitted | 403; the message names the form, and with several statements also the number of the one affected (Anweisung 2 von 3: … (statement 2 of 3: …)) |
| An object is unknown or not permitted for what the statement does with it | 403; the message names the object as it was written, and the access. Unknown and forbidden get the same response |
The audit stream contains the rewritten version that was run.
Output parameters, multiple result sets, describing
Three additions at the same address cover what an ODBC application is used to from Microsoft's driver:
output and return parameters, SQLMoreResults, and the columns of a prepared statement before execution.
They apply equally to custom and checked statements; a data source whose provider can't do them responds with
400 before it contacts the database.
Output parameters (outputs). The body names the Edm type for each output parameter; for text and
binary data optionally with a length:
POST /api/v1/cf/fachdaten/sql.json
X-Api-Key: …
{
"sql": "EXEC @rueckgabe = dbo.auftrag_anlegen @kunde = @kunde, @nummer = @nummer OUTPUT",
"parameters": { "kunde": 4711 },
"outputs": { "rueckgabe": "Edm.Int32", "nummer": { "type": "Edm.String", "maxLength": 20 } }
}
| Rule | Why |
|---|---|
Permitted types: Edm.Boolean, Byte, Int16, Int32, Int64, Decimal, Single, Double, Date, TimeOfDay, DateTimeOffset, Guid, Binary, String; any other name → 400 | An output parameter needs its type before the statement runs; SQL Server can't report it afterward |
Without a length: text as nvarchar(max), binary data as varbinary(max); Edm.Decimal as decimal(38,10); Edm.DateTimeOffset as datetime2 | Wide text and binary types don't truncate anything. Losses: a decimal value with more than ten decimal places is rounded, a datetimeoffset loses its offset |
If the same name is also in parameters, the parameter is input and output | That's how Microsoft's driver binds an INPUT_OUTPUT parameter |
Names without @, ordinary identifiers | As with parameters |
The response carries the values by name, null for NULL:
{ "columns": [], "data": [], "rowCount": 0, "outputs": { "rueckgabe": 0, "nummer": "A-2026-0815" }, "executionTimeMs": 9 }
outputs is in the response only if the request named outputs. SQL Server sends the values after the last
result set; the service therefore reads all sets to the end before it fills them in.
Multiple result sets (results). If a statement returns more than one result set with columns, the first
is in columns/data as before, and every further one is in results, in this order:
{
"columns": [ { "name": "id", "type": "Edm.Int32", "storeType": "int", "nullable": false } ],
"data": [ { "id": 1 } ],
"rowCount": 1,
"results": [
{ "columns": [ { "name": "name", "type": "Edm.String", "storeType": "nvarchar", "nullable": true, "maxLength": 50 } ],
"data": [ { "name": "Halle 1" } ], "rowCount": 1 }
],
"executionTimeMs": 11
}
A client that knows only one set reads the same response as before. The row limit applies per set; a
truncated set carries "truncated": true. Results without columns (counts of a change) don't appear as a
separate set.
Describing without executing (describe). With "describe": true, the service runs nothing and responds
with the columns of the first result set:
{ "sql": "SELECT id, name FROM dbo.betrieb WHERE id = @id", "parameters": { "id": 1 }, "describe": true }
{ "columns": [ { "name": "id", "type": "Edm.Int32", "storeType": "int", "nullable": false }, { "name": "name", "type": "Edm.String", "storeType": "nvarchar", "nullable": true, "maxLength": 50 } ], "data": [], "rowCount": 0, "executionTimeMs": 3 }
The description runs under the same login and the same checks as the execution; a caller thus learns nothing
about objects they wouldn't be allowed to run. The values in parameters only determine the types of the
placeholders. A statement that inserts a row inserts none with describe.
Repeated write requests (Idempotency-Key)
A client on a poor connection sends a change, the service saves it, and the response is lost on the way
back. The client can't distinguish this from a request that never arrived. If it repeats it, a second record
would be created—or a write conflict (412) for a change that went through, because the row version has
moved on in the meantime.
If a write request (POST, PATCH, DELETE) carries the Idempotency-Key header (up to 128 visible ASCII
characters, chosen randomly by the client), the service answers every repetition with the stored first
response and the header Idempotent-Replayed: true, instead of writing a second time. The ODBC driver sends
it with every write. The header takes effect for every method except GET and HEAD and on every address,
including the procedure call and custom statements.
| Case | Response |
|---|---|
| Same key, same method, address, sign-in and same body | stored response (status, body, ETag, Location) |
| Repetition while the first one is still running | waits for the first one |
| Same key with a different body | 422 |
First response 401, 429 or 5xx | not stored; the repetition really runs—after 5xx possibly a second time |
The service stores successes and refusals for reasons of the request (400, 403, 404, 409, 412, 422), for
10 minutes. A 422 from a THROW in the procedure is included: a repetition with the same key gets the
stored response (with the same correlationId), even if the cause in the database has been fixed in
the meantime; a new attempt needs a new key. Storage is only in the process's memory: a restart ends the
window. Limits: responses over 1 MiB aren't stored, at most 10,000 entries (the oldest drop out); the body of
a request with Idempotency-Key is read completely before sign-in, without the MaxBodyBytes limit; this
check's own refusals (400, 422) come as text, not in the error format. Sign-in is counted via the key header
(ApiKeyHeader, default X-Api-Key) and Authorization. A stored response isn't
authorized again—a key revoked in the meantime still receives it until the ten minutes are up. A Windows sign-in
counts via its account, not via its handshake, which differs on every new connection; the client's address
doesn't count, because it can change with the broken connection.
Downloading installation packages
GET /download/ lists the installation packages from the folder Gateway:Downloads:Path (default
downloads, relative to the content root; empty turns downloading off)
with size, SHA-256, and—only for .exe files, from their version information—the version, as JSON,
and as a page for a browser. GET /download/{file} delivers the file. Only files directly in this folder
with the extension .msi or .exe are offered, without sign-in, because they contain no secret; any other
name, a subfolder or .. results in 404. Rate limiting applies per origin.
Schema state for the WKL Bootstrapper
GET /schema-state/{alias}/{product} returns the schema state of a product in the alias's database,
so the WKL Bootstrapper offers only the application versions that fit it:
{ "format": 1, "product": "Demo", "schema": 13, "applications": { "min": "2.4.0", "max": "" }, "updated": "2026-10-22T10:05:00Z" }
schema is the schema version, and applications is the range of application versions that work
with it (max empty: no upper bound). The service reads the state from wkl.Schemastand with the
data source's login, which must be a member of the database role wkl_leser. Users need no permissions in
the database.
| Response | Meaning |
|---|---|
200 | The state as JSON, with ETag |
304 | If-None-Match names the current ETag |
401 | Not signed in with Windows, and Gateway:SchemaState:Anonymous is off |
404 | Unknown alias, its provider doesn't apply database updates, or the product has no state |
503 | The database can't be read |
Sign-in is with Windows only; every account Windows signs in gets the answer, because it holds no data of the database. Without sign-in the service answers only when `Gateway:SchemaState:Anonymous` is on. The service keeps an answer for 30 seconds; a database that can't be read is asked again on the next call.
Reporting application and version (X-Wkl-Application)
An application sends the header X-Wkl-Application: <product>/<version> with every request, for
example Demo/2.4.0 (product: letters, digits, ., -, _, at most 64 characters; version: two to
four numbers). Per alias, the service remembers when each version last made a request. A database
update that breaks older applications waits as long as an older version made a request within the
session window. A request without a valid header counts as “unknown”; unknown applications don't
hold an update back. The value is the program's claim, not a security feature.
Remarks
- No outer transaction is placed around a procedure call. The write procedures of this product each contain their own; nesting would silently change their error handling. Instead,
SET XACT_ABORT ONruns before the call. If the client itself has opened a transaction, the call runs in it and withoutXACT_ABORT, as over a direct connection: aROLLBACKin the procedure then also ends the client's transaction. Around a generated write, however, there is one: it doesn't bring its own, and without it the guard couldn't roll back an operation that hit the wrong number of rows. - A table with a required column that can't be named doesn't accept a create. This concerns only names whose encoded form would be longer than 128 characters; special characters alone don't make a name unnameable. If such a column is required and has no default value, no caller can specify a new record completely; the service reports this in advance instead of waiting for the database's refusal. Update and delete aren't affected.
- An argument on a table is a key.
GET …/tables/X.json?Id=5reads exactly this record; an argument that doesn't belong to the key is rejected with400instead of being ignored; otherwise the request would return the first window of the whole table. - Counting happens in the statement, not in the driver. The driver counts rows that triggers change; a table with history triggers would then report several rows for every single change.
@@ROWCOUNTdirectly after the statement isn't affected by this.