sinq Lua API & Type Translation
sinq runs your Lua in a sandbox and translates values across the Go/Lua boundary. The API is scoped: some functions are only available inside specific lifecycle hooks, which keeps concurrent scenarios from leaking state into each other.
1. Global State & Environment
These variables and functions are available globally in all script blocks.
env
A table containing the environment variables configured for the current scenario merged with all the values passed via the -e / --env flags. Modifications made to this table from a user script persist for the lifetime of the scenario.
secrets
A table containing sensitive values passed to sinq via the -s / --secret / --secrets-file CLI flags.
req and res (Current Request Context)
Shorthands for the current request and response being processed.
req: Used in$PREto modify the outgoing request (e.g.,req.attach()).res: A direct reference tosinq.responses[%current%].
Flow Control
sinq.setNextRequest(index): Alters execution flow. The next request executed will be the one at the specifiedindex(1-based). Useful for building loops or conditional skips within a scenario.sinq.finishScenario(): Alters execution flow. Tellssinqto finish the scenario once the current request completes its life cycle. Useful for gracefully finishing loops or conditional scenario shutdowns.
Standard Output (print & io.write)
print (and io.write, if io is enabled via --unrestricted) works in every script. Because scenarios run in parallel, printing straight to the terminal would interleave badly, so this output is discarded by default. Pass -p / --print to buffer it and attach it to the scenario result instead.
2. Variable Scoping (Local vs. Global)
Control variable scoping carefully to avoid leaking state between requests.
- Temporary Math & Logic: Use the
localkeyword. This ensures the variable is garbage-collected immediately after the script block finishes. - Passing State Across Files: If you need a value from
01_login.sinqto be accessible in02_action.sinq, you must declare it globally (withoutlocal). It will be attached to the Lua sandbox for the lifespan of that specific scenario (_G).
3. Inline Scripts (Request Templating)
Aside from lifecycle hooks, you can use General/Inline scripts to dynamically build your HTTP requests. These are evaluated after $PRE but before the request is sent. The return value of these scripts is injected directly into the raw HTTP text.
You can name them (e.g., $MY_SCRIPT{...}) or leave them anonymous (${...}).
Single-line Interpolation:
If an inline script fails to compile, sinq automatically prepends return and retries. This allows for clean, single-line variable interpolation:
Note
Calling functions inside an inline script can make it ambiguous for the compiler to determine if a return should be prepended. In those cases, you must explicitly write return.
Multi-line Dynamic Generation: For complex logic, use explicit returns:
POST ${env.BASE_URL}/users
Content-Type: application/json
{
"email": "$GENERATE_EMAIL{
local random_num = math.random(1000, 9999)
return 'testuser_' .. random_num .. '@example.com'
}",
"role": "admin"
}
Note
Inline scripts must return a value. Returning nothing will fail the request materialization.
4. Lifecycle-Specific APIs
The following APIs are dynamically injected and destroyed depending on the execution phase of the request.
$PRE (Setup Phase)
Executes before the HTTP request is materialized. Used for file I/O operations.
req.attach(filepath string): Replaces the request body with the contents of the specified file. Note: Fails if a textual body is already defined in the.sinqfile.req.saveResponseTo(filepath string): Streams the upcoming response body directly to disk, bypassing the Lua memory buffer. Ideal for downloading large files. Automatically creates missing directories in the filepath. If used,bodyRawand JSON methods will not be available in subsequent hooks.req.cache(enable bool?): Turns on/off client-side request caching. The cache is based on the data sent over the wire and any attached filenames (attach, saveResponseTo). The parameter defaults totrueif omitted.req.skip(enable bool?): Marks the request to be skipped. Parameter defaults totrueif omitted. The$PREscript will finish executing, but the HTTP request will not be fired and subsequent hooks are bypassed. The request is marked asAbortedin the reporter without throwing a test failure.req.multipart(entry table, ...): Builds amultipart/form-datarequest body from one or more part definitions. See Multipart Bodies below.req.multipartBoundary(boundary string): Overrides the randomly generated multipart boundary. See Multipart Bodies.
Note
req.attach, req.saveResponseTo, and a multipart entry's source all expect a path relative to the current .sinq file. Passing an absolute path fails.
Multipart Bodies
req.multipart(entry table, ...) assembles a multipart/form-data body. Parts may be passed as positional arguments (req.multipart(a, b)) or as a single contiguous array table (req.multipart{ a, b }); both forms are equivalent. Repeated calls accumulate parts in order, and duplicate field names are allowed.
Each entry is a table with these keys (all optional unless noted):
| Key | Type | Meaning |
|---|---|---|
name |
string | Form field name. Required unless contentDisposition is set. |
data |
string | Literal part body. Mutually exclusive with source. |
source |
string | Path to a file whose contents become the part body. Mutually exclusive with data. Resolved relative to the current .sinq file; absolute paths and paths that do not resolve to a readable file are rejected. |
filename |
string | boolean | A string sets the advertised filename and makes this a file part. true uses basename(source). Omitted (or false) produces a plain field part with no filename parameter. |
contentType |
string | The part's Content-Type. If omitted and the part has a filename, it is sniffed from the filename extension, then the source extension, falling back to application/octet-stream. Field parts get no Content-Type unless you set one. |
contentDisposition |
string | Sets the raw Content-Disposition header verbatim. When present, name and filename are ignored. |
headers |
table | Extra per-part headers, e.g. { ["X-Chunk"] = "3" }. Keys are canonicalised; values may be string, number, or boolean. An explicit contentType / contentDisposition wins over a same-named headers entry. |
A non-string value for any of the string-typed keys is an error, as is providing both data and source, or an entry with neither name nor contentDisposition. A table that mixes array entries with named keys uses only the array entries.
name and filename only apply when sinq builds the Content-Disposition header for you. If you set contentDisposition yourself, it is used verbatim and name / filename on that entry are ignored entirely (not even type-checked).
req.multipartBoundary(boundary string) replaces the random boundary with a fixed one. It is validated (RFC 2046) when the body is assembled; an invalid value fails the request. It is a no-op without req.multipart, and you are responsible for ensuring the boundary does not occur in any part's content.
Note
req.multipart sets Content-Type: multipart/form-data; boundary=… on the request only if you have not already set a Content-Type header yourself. A header written in the .sinq file (even one with a deliberately mismatched boundary) is left untouched. req.multipart is mutually exclusive with req.attach and with a literal body in the .sinq file. The assembled body is buffered in memory, so it is meant for form fields and modestly sized files rather than multi-gigabyte uploads.
POST ${env.BASE_URL}/avatar
Authorization: Bearer ${AUTH_TOKEN}
$PRE{
req.multipart(
{ name = "caption", data = "vacation 2026" },
{ name = "photo", source = "fixtures/beach.jpg", filename = true },
{ name = "meta", data = sinq.json.serialize({ album = "trips" }), contentType = "application/json" }
)
}
$ASSERT{ sinq.assert.code(201) }
$RETRY (Polling Phase)
Executes after receiving a response. The script must return a number indicating how many milliseconds to wait before retrying, or a negative number to stop.
sinq.retry.stop: A constant (-1) indicating the retry loop should break immediately.sinq.retry.when(condition boolean, delay number?)- Retries if
conditionis true.delaydefaults to500ms.
- Retries if
sinq.retry.whenExponential(condition boolean, base number?, constant number?)- Retries if
conditionis true, using exponential backoff (base ^ attempt * constant). basedefaults to2(Max10).constantdefaults to500ms.
- Retries if
sinq.retry.withJitter(condition boolean, range number?, delegate function?, delegate_args any...)- Adds randomized jitter to a retry calculation to prevent thundering herd problems.
rangedefaults to50(±50ms jitter).delegatedefaults tosinq.retry.when, delegate will be passed condition and delegate_args when called.- Usage is:
sinq.retry.withJitter(res.code ~= 200, 100, sinq.retry.when, 2 * sinq.second)- jitter conditional retry with range of [-100:100]
$ASSERT (Validation Phase)
Executes after the retry loop finishes. Used to validate the final state of the response.
sinq.assert.fail(reason string): Marks the test as failed with the provided reason. Note: This does not halt Lua execution. The rest of the$ASSERTblock will continue to run, allowing you to collect multiple failure reasons for a single request.sinq.assert.code(expectedHttpCode number, message string?): Fails if the actual status code does not match.sinq.assert.equals(actual any, expected any, message string?): Fails ifactualdoes not equalexpected. When comparing tables, checks that every key-value pair inexpectedrecursively matches those inactual, but ignores pairs fromactualnot present inexpected. Withoutmessage, the failure text includes both values (and, for tables, the mismatching field). Passmessageto suppress that when comparing secrets.sinq.assert.contains(source string, substring string, message string?): Fails if the string does not contain the specified substring. Withoutmessage, the failure text includessourceandsubstring. Passmessageto suppress that when either may be sensitive.sinq.assert.isTrue(condition boolean, message string?): Fails if the condition resolves tofalseornil.sinq.assert.fileMatches(filepath string): Fails if the response previously saved usingreq.saveResponseTo()does not exactly match the contents offilepath. Fails immediately ifreq.saveResponseTo()was not called.
$POST (State Extraction Phase)
Executes after a successful $ASSERT phase. Typically used to parse the final response payload and store relevant data in the global sandbox for subsequent requests. No special scoped APIs are injected here.
5. The Responses Table (sinq.responses)
When an HTTP request completes, sinq parses the response and injects it into the sinq.responses table at the index corresponding to the request number. Lua is 1-indexed, meaning the response to the first request in your scenario is accessed via sinq.responses[1].
Note
A response object only exists after the request has been executed. Accessing sinq.responses[2] or the alias res during the $PRE hook of the second request will return nil.
Response Object Structure
attempt(number): The current execution attempt (useful during$RETRY).code(number): The HTTP status code (e.g.,200,404).oversized(boolean | nil):trueif the payload exceeded the scenario'smax_bodylimit and was safely truncated.
Body Access Methods
Note
These are only available if req.saveResponseTo() was NOT used in the $PRE hook.
bodyRaw(string): The raw string of the response payload.extractBodyJson()(function): Safely attempts to parsebodyRawinto a Lua table.- Returns:
(result table, error string)
- Returns:
json()(function): An unsafe convenience wrapper aroundextractBodyJson.- Returns:
tabledirectly. - Throws: Calls a fatal
error()if the body is not valid JSON, failing the scenario immediately.
- Returns:
HTTP Headers Translation
HTTP headers are complex because a single key can have multiple values. sinq handles this translation automatically.
- Single Value Headers: Translated to a standard Lua string.
- Multi-Value Headers: Translated to a 1-indexed Lua table (array) of strings.
JSON Blindspot (1-Indexed Arrays)
In Go and in general, arrays are 0-indexed. In Lua, tables are 1-indexed.
If your API returns a top-level JSON array, sinq translates it into a Lua table starting at index 1.
API Response:
Lua Assertion:
6. Extensions Quick Reference
sinq.time.ms/sinq.time.second/sinq.time.minute/sinq.time.hoursinq.time.now()sinq.time.fromString(str string, format string?)sinq.time.toString(ms number, format string?)sinq.time.sleep(ms number)sinq.encoding.base64Encode(source string)sinq.encoding.base64Decode(source string)sinq.encoding.base64UrlEncode(source string)sinq.encoding.base64UrlDecode(source string)sinq.encoding.hexEncode(source string)sinq.encoding.hexDecode(source string)sinq.encoding.urlEncode(source string)sinq.encoding.urlDecode(source string)sinq.crypto.md5(source string, encoding string?)sinq.crypto.sha1(source string, encoding string?)sinq.crypto.sha256(source string, encoding string?)sinq.crypto.sha512(source string, encoding string?)sinq.crypto.hmac(source string, algo string?, key string?, encoding string?)sinq.jwt.decode(token string)sinq.jwt.verify(token string, key string, algo string?)sinq.jwt.sign(claimsTable table, key string, method string?)sinq.json.parse(source string)sinq.json.serialize(tbl table, indent string?)
7. Time API (sinq.time.*)
Built-in constants and functions to make time-based logic and parsing possible.
Constants
sinq.time.ms(1)sinq.time.second(1000)sinq.time.minute(60000)sinq.time.hour(3600000)
Note
Lua uses float64 for numbers. When converting a timestamp from milliseconds to another unit (e.g., seconds) using division, use math.floor to ensure a clean integer: math.floor(sinq.time.now() / sinq.time.second).
Functions
sinq.time.now(): Returns the current UNIX timestamp.- Returns:
number(milliseconds since epoch).
- Returns:
sinq.time.fromString(str string, format string?): Parses a time string into a UNIX timestamp (milliseconds).- Returns:
(result number, error string) - Format Rules: Uses Go's time layout rules. If omitted, defaults to ISO8601 (
2006-01-02T15:04:05.000Z07:00).
- Returns:
sinq.time.toString(ms number, format string?): Formats a UNIX timestamp (milliseconds) into a time string.- Returns:
string - Format Rules: Uses Go's time layout rules. If omitted, defaults to ISO8601.
- Returns:
-
sinq.time.sleep(ms number): Blocks the current script formsmilliseconds, then returns nothing. Useful as an unconditional delay before a request fires (e.g.$PRE{ sinq.time.sleep(2 * sinq.time.second) }to wait out a fixed propagation delay).- The sleep is interrupted immediately by
Ctrl+Cor the scenariotimeout. - It counts against
script_timeoutlike any other script time. A sleep longer thanscript_timeoutwill time the script out (or, if it is the last statement in the block, be cut short at thescript_timeoutmark). Raisescript_timeoutin the.scenariofile for longer waits. - A worker running a sleep cannot pick up another scenario, exactly like a
$RETRYbackoff. - Prefer a
$RETRYpolicy (sinq.retry.when(condition, delay)) whenever the endpoint gives you a readiness signal; reach forsleeponly for genuinely unconditional waits.
Note
A non-numeric argument is an error. A negative or zero
msreturns immediately. - The sleep is interrupted immediately by
8. Encoding & Crypto APIs
sinq.encoding.* holds reversible, non-cryptographic transforms (Base64, hex, URL
escaping). sinq.crypto.* holds hashing and message authentication. They are
separate namespaces, and the encoding helpers are not reachable via sinq.crypto.
Encoding
sinq.encoding.*
sinq.encoding.base64Encode(source string): Encodes a string into standard (padded) Base64.- Returns:
string
- Returns:
sinq.encoding.base64Decode(source string): Decodes a standard Base64 string.- Returns:
(result string, error string)
- Returns:
sinq.encoding.base64UrlEncode(source string): Encodes a string into URL-safe Base64.- Returns:
string
- Returns:
sinq.encoding.base64UrlDecode(source string): Decodes a URL-safe Base64 string.- Returns:
(result string, error string)
- Returns:
sinq.encoding.hexEncode(source string): Encodes a string into a hexadecimal representation.- Returns:
string
- Returns:
sinq.encoding.hexDecode(source string): Decodes a hexadecimal string.- Returns:
(result string, error string)
- Returns:
sinq.encoding.urlEncode(source string): Percent-encodes a string for use in a query string or anapplication/x-www-form-urlencodedbody. Spaces become+.- Returns:
string
- Returns:
sinq.encoding.urlDecode(source string): ReversesurlEncode(+is decoded back to a space).- Returns:
(result string, error string). The error is set for malformed percent escapes.
- Returns:
Building an application/x-www-form-urlencoded body
There is no dedicated form helper, so assemble the body yourself and set the header:
Hashing
sinq.crypto.*
-
sinq.crypto.md5(source string, encoding string?),sinq.crypto.sha1(source string, encoding string?),sinq.crypto.sha256(source string, encoding string?),sinq.crypto.sha512(source string, encoding string?): Computes the cryptographic hash of the input string.- Returns:
(result string, error string) - Parameters:
encodingstring defaults to"hex". Supported values are"hex","base64","base64url", and"raw".
Note
Since it defaults to
"hex", the output is safe to print and transmit. If"raw"is used, the function returns the raw bytes. - Returns:
-
sinq.crypto.hmac(source string, algo string?, key string?, encoding string?): Computes the HMAC of the source string.- Returns:
(result string, error string) - Parameters:
algostring defaults to"sha256". Supported values are"sha256","sha1","sha512", and"md5".keystring defaults to"".encodingstring defaults to"hex". Supported values are"hex","base64","base64url", and"raw".
- Returns:
9. JWT API (sinq.jwt.*)
Allows for generation, decoding, and validation of JSON Web Tokens natively within your scenario flow.
sinq.jwt.decode(token string): Decodes a JWT token without validating its signature.- Returns:
(result table, error string) - Table Structure: Contains
header(table),claims(table),signature(string), andmethod(string).
- Returns:
-
sinq.jwt.verify(token string, key string, algo string?): Verifies the token using the provided key and optional algorithm constraint.- Returns:
(result table, error string)
Note
Symmetric algorithms (
HS*) use raw string keys. Asymmetric algorithms (RS*,ES*,EdDSA) require PEM-encoded public keys. - Returns:
-
sinq.jwt.sign(claimsTable table, key string, method string?): Creates a signed JWT string.- Returns:
(result string, error string) claimsTable: A Lua table representing the JWT payload.keystring: The signing key string.methodstring?: The signing algorithm. Defaults toHS256.
Note
The
claimsTablemust have strictly string keys. Mixing list-style (integer) indices with string keys in Lua will cause parsing to fail and return an error. Asymmetric algorithms require PEM-encoded private keys.Warning
Passing a cyclic table as the
claimsTablewill result in a serialization error being returned as a second return value (nil, "Failed to serialize..."). It is safe and will not crash the runner, but the token will not be generated. - Returns:
10. JSON Utilities (sinq.json.*)
The sinq.json table provides explicit methods to parse and serialize JSON data from Lua.
sinq.json.parse(source string): Parses a JSON string into a Lua table.- Returns:
(result table, error string)
- Returns:
-
sinq.json.serialize(tbl table, indent string?): Serializes a Lua table into a JSON string.- Returns:
(result string, error string) indentstring?: Optional string used for formatting (e.g.," "). If omitted, produces compact JSON. If present, also introduces newlines between object and array entries.
Note
Passing a cyclic table will immediately return an error (
"Cycle detected, unable to serialize"). - Returns:
-
sinq.json.null: A special constant representing a JSONnullvalue, allowing Lua tables to explicitly serializenullproperties (since standard Lua dropsniltable keys). Tables, parsed from JSON will also include this constant to represent explicitnull. Can be compared with standard==operator (sinq.assert.isTrue(res.json().myNull == sinq.json.null))
11. Fake Data Generation (sinq.fake.*)
The sinq.fake table exposes deterministic fake data generators. All generators respect the current seed.
Primitives & Core Data
sinq.fake.uuid()(alias:sinq.fake.uuidv4()): Returns a random UUIDv4 string.sinq.fake.int(min?, max?): Returns a random integer.sinq.fake.float(min?, max?): Returns a random float.sinq.fake.shakespeare(): Returns a random boolean (trueorfalse).sinq.fake.oneOf(array): Accepts a Lua array (table with integer keys) and returns a random element.
Networking & Web
sinq.fake.email(): Returns a random email address.sinq.fake.ipv4(): Returns a random IPv4 address.sinq.fake.ipv6(): Returns a random IPv6 address.sinq.fake.url(): Returns a random URL string.sinq.fake.userAgent(): Returns a random User-Agent string.sinq.fake.trace(): Returns a random W3C traceparent header string.sinq.fake.username(): Returns a random username.sinq.fake.password(): Returns a random password.
Identity & Text
sinq.fake.name(): Returns a full name.sinq.fake.firstName(): Returns a first name.sinq.fake.lastName(): Returns a last name.sinq.fake.phone(): Returns a random phone number.sinq.fake.address(): Returns a full address.sinq.fake.company(): Returns a company name.sinq.fake.word(): Returns a single random word.
Time & Configuration
sinq.fake.timestamp(fromMs, toMs?): Returns a random UNIX timestamp (integer milliseconds) betweenfromMsandtoMs. IftoMsis omitted, it defaults to the current time.sinq.fake.setSeed(int64): Seeds the fake data generator to ensure deterministic output across runs.
Additional Randomness
math.random(max?),math.random(min, max): Lua's standard way of generating pseudo-random data is present insinqand always available.
12. Libraries
sinq does not load two common core Lua libraries - io and os - by default, so that a .sinq script cannot touch the process, spawn a shell, or reach the filesystem outside its scenario workspace.
--unrestricted disables the sandbox
Passing --unrestricted (-u) loads the full os and io libraries. Scripts then gain os.execute (run arbitrary shell commands), os.exit (terminate sinq immediately, skipping all reporting and cleanup), os.remove / os.rename / os.setenv, and io.open (read and write any path the sinq process can, not just files under the scenario directory). Only pass -u for scenario files you fully trust - treat it the way you would treat piping a script straight into your shell.
sinq allows you to import external Lua packages. To make them accessible via require("package"), you must provide the directory paths containing those packages.
You can do this using the SINQ_LUA_PATH environment variable or the --plugins CLI flag (which takes precedence). Multiple paths should be separated by a colon (:) on macOS/Linux, or a semicolon (;) on Windows. You can also pass the --plugins flag multiple times to aggregate paths.
Note
All paths passed to sinq as positional arguments and the current working directory also get appended to the end of path for the purposes of searching for Lua plugins. So if you run sinq from a directory containing a file my-module.lua, require("my-module") will work for all .sinq files.