Have you ever felt the pain of wiring up a bunch of independent requests into one stateful flow from start to finish? I have, and while manageable, one thing always bugged me. Why do we treat end-to-end API tests as a bunch of independent requests that we have to somehow bundle together, when they actually are much closer to stateful workflows? That's why this tool was born. It is designed to do one job - execute sequences of requests to walk through different workflow scenarios. Instead of maintaining large request collections or YAML-heavy test definitions, I wanted to describe real user workflows directly in files and let sinq compile them into executable scenarios. And make it natively parallel, why not? So I present to you:
sinq is a concurrent integration and end-to-end http testing tool that treats your filesystem as a workflow definition.
Write requests as near-raw HTTP, add Lua where logic is needed, and organize files into directory trees. Every leaf directory becomes an isolated execution scenario with its own state, configuration, and request chain.
- Why sinq?
- Installation & Usage
- File & Directory Structure
- The .sinq Format
- Lua API
- Configuration & Environment
- Secrets & Security
- Usage
- When To Choose Sinq
- Versioning & Compatibility
- Useful Links
- Acknowledgments & Credits
- License
Most API testing tools treat a test as a collection of independent requests that you stitch together after the fact. sinq treats it as a workflow: an ordered run of requests that pass state from one to the next, the way a real session does. Log in, hold the token, create a record, poll until it settles, check the result.
That workflow is your directory layout. A folder is a flow and the files inside it are its steps, in natural-sorted order. Setup that several flows share, like authentication, goes in a parent folder and everything beneath it inherits it. Each leaf folder is one scenario with its own Lua state and its own cookie jar, so nothing bleeds between them. Since scenarios share nothing, sinq runs them all at once by default, limited only by the worker count and your network.
A .sinq file is a plain HTTP request with Lua wherever you need logic. Nothing sits between what you write and what goes on the wire. Four hooks cover the rest of the lifecycle: $PRE for setup and file I/O, $RETRY for polling, $ASSERT for checks, and $POST for pulling values out of a response so later requests can use them.
It ships as one static binary or a small container with nothing else to install, and it writes JUnit XML for CI.
Here is what a simple one-off request looks like in sinq.
healthcheck.sinq
GET ${env.BASE_URL}/health
$ASSERT{ sinq.assert.code(200, "Healthcheck failed") }
Here is what a complete authentication, execution, and polling chain looks like in sinq.
01_login.sinq
POST ${env.BASE_URL}/login
Accept: application/json
$ASSERT{ sinq.assert.code(200, "Login failed") }
$POST{
-- Parse the body as json
local data = res.json()
-- Save only what matters to the global environment
AUTH_TOKEN = data.token
}
02_trigger_and_poll.sinq
POST ${env.BASE_URL}/jobs
Authorization: Bearer ${AUTH_TOKEN}
{ "action": "export" }
$RETRY{ return sinq.retry.when(res.json().status == "pending", 50 * sinq.time.ms) }
$ASSERT{ sinq.assert.isTrue(res.json().status == "complete", "Job never completed") }
Choose your preferred installation method below to get started.
🍺 Homebrew (macOS & Linux)
Available via the official Homebrew tap. You can choose to build from source (avoids macOS Gatekeeper warnings) or install the pre-compiled binary.
Option 1: Build from Source (Recommended for macOS) Builds natively on your machine. This automatically bypasses Apple's Gatekeeper "unverified developer" blocks.
brew install Veitangie/tap/sinqOption 2: Pre-compiled Binary (Cask) Installs instantly, but macOS users will need to manually remove the quarantine flag due to Gatekeeper policies.
brew install --cask Veitangie/tap/sinq-bin
# macOS only: Remove quarantine flag
xattr -d com.apple.quarantine $(which sinq)🪟 Windows
Pre-compiled .zip archives are generated for every release.
- Go to the Releases page and find the latest version.
- Download the
.zipfile for your architecture (x86_64orarm64). - Extract
sinq.exeand add the containing folder to your system%PATH%.
Using Scoop? If you use the Scoop package manager, you can install and stay updated automatically:
scoop bucket add veitangie https://github.com/Veitangie/scoop-bucket scoop install sinq
🐧 Debian / Ubuntu (.deb)
Pre-compiled .deb packages are generated for every release.
- Go to the Releases page and find the latest version.
- Download the
.debfile for your architecture (amd64orarm64). - Install it using
dpkg:
sudo dpkg -i sinq-*.deb🎩 Fedora / RHEL (.rpm)
Pre-compiled .rpm packages are generated for every release.
- Go to the Releases page and find the latest version.
- Download the
.rpmfile for your architecture (amd64orarm64). - Install it using
rpm:
sudo rpm -i sinq-*.rpm❄️ Nix & NixOS
Nix flakes are officially supported via the dedicated sinq-nix registry. You can run sinq directly without installing it:
nix run github:Veitangie/sinq-nixOr add it to your environment/configuration:
# flake.nix
inputs.sinq.url = "github:Veitangie/sinq-nix";
# In your configuration package list:
# inputs.sinq.packages.${system}.default📦 Arch Linux (AUR)
Available via the Arch User Repository. You can build from source (sinq) or install the pre-compiled binary (sinq-bin).
Using an AUR helper like yay or paru:
# Build from source natively
yay -S sinq
# Or install the pre-compiled binary instantly
yay -S sinq-binOr, you can build and install manually:
git clone https://aur.archlinux.org/sinq.git
cd sinq
makepkg -si🐳 Docker (Alpine Minimal)
Official multi-architecture images are hosted on the GitHub Container Registry. Mount your local test directory into the container to execute scenarios.
docker pull ghcr.io/veitangie/sinq:latest
docker run -v $(pwd):/tests ghcr.io/veitangie/sinq /tests⚡ Install Script (macOS & Linux)
curl -sL https://raw.githubusercontent.com/Veitangie/sinq/refs/heads/main/install.sh | bashNote: This script targets stable releases by default. To install a specific version (like a release candidate), pass the version tag as an argument:
curl -sL .../install.sh | bash -s v1.0.0-rc.3
🐹 Go Install (Requires Go 1.26.6+)
If you have a Go environment set up, you can compile and install directly from the module.
go install veitangie.dev/sinq/cmd/sinq@latestNote: Ensure your
$(go env GOPATH)/bindirectory is in your system$PATH.
🔧 Build From Source
git clone git@github.com:Veitangie/sinq.git
cd sinq
go build -ldflags="-w -s" -o sinq ./cmd/sinq/🪄 Setting up shell auto-completion
(Note: If you installed sinq via native Linux packages (.deb, .rpm, .apk), the AUR, or the Homebrew Formula (brew install Veitangie/tap/sinq), completions are installed automatically and you can skip this step. For all other methods like the Homebrew Cask (sinq-bin), scoop, the install script, or go install, you'll need to set them up manually).
sinq supports auto-completion for Bash, Zsh, Fish, and PowerShell.
To load completions dynamically in your current session:
- Bash/Zsh:
source <(sinq --completion) - Fish:
sinq --completion | source - PowerShell:
sinq --completion | Invoke-Expression
To install completions permanently, save the output to your shell's completions directory:
- Bash:
sinq --completion > ~/.local/share/bash-completion/completions/sinq - Zsh:
sinq --completion > ~/.zfunc/_sinq(and addfpath+=~/.zfuncbeforecompinitin.zshrc) - Fish:
sinq --completion > ~/.config/fish/completions/sinq.fish - PowerShell: Add
sinq --completion | Invoke-Expressionto your PowerShell profile.
🤖 GitHub Actions (CI/CD)
Integrate sinq natively into your GitHub Actions pipeline using the official action.
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run Sinq Integration Tests
uses: Veitangie/sinq-action@v1
with:
args: '-w 10 --secrets-file path/to/secrets.json tests/e2e'The basic unit of execution for sinq is a scenario. They are built from the filesystem roots passed to the tool.
Because sinq relies on directory hierarchy to build scenarios, you can define shared setup steps (like authentication) at the root, and branch off into specific test cases in subdirectories. Every leaf directory becomes one executable scenario. Parent files are inherited by all descendant scenarios. Scenario configurations are aggregated along the whole path, with the deeper nested ones taking precedence.
If you don't branch, sinq just keeps appending files until it hits the bottom.
flow/
├── 01_init.sinq
└── stage_one/
├── 00_process.sinq
└── stage_two/
└── 00_finalize.sinq
Because stage_two is the only directory with no subfolders, this resolves into exactly one scenario:
- Execution Order:
01_init.sinq➔00_process.sinq➔00_finalize.sinqNotice how the files in the subdirectories always follow the files from parent directories despite natural order globally being different. Natural ordering only applies within the same directory
tests/
├── 00_base.scenario (Sets "req_timeout": "5s", "env": {"host": "api.local"})
├── 01_auth.sinq (Logs in, saves AUTH_TOKEN to globals)
├── users/
│ ├── 02_create.sinq (Uses AUTH_TOKEN)
│ └── 03_delete.sinq (Uses AUTH_TOKEN)
└── payments/
├── payments.scenario (Overrides "req_timeout": "15s" only for the payments/ scenario)
├── 02_process.sinq (Uses AUTH_TOKEN)
└── 03_refund.sinq (Uses AUTH_TOKEN)
Running sinq ./tests identifies two leaf directories (users/ and payments/), resulting in two distinct scenarios that will run concurrently:
- Scenario A (The
usersleaf):- Config:
00_base.scenario - Execution Order:
01_auth.sinq➔02_create.sinq➔03_delete.sinq
- Config:
- Scenario B (The
paymentsleaf):- Config: Aggregation of
00_base.scenario+payments.scenario(Timeout is now 15s) - Execution Order:
01_auth.sinq➔02_process.sinq➔03_refund.sinq
- Config: Aggregation of
Notice how 01_auth.sinq is executed independently at the start of both scenarios. They do not share the same Lua VM instance; they just inherit the same structure.
More detailed explanation of the algorithm can be found in the Treewalker documentation
Currently, leaf directories are expected to contain at least one
.sinqor.scenariofile. Directories that contain neither.sinq/.scenariofiles nor subdirectories are not considered valid scenario definitions.
A .sinq file is a standard HTTP request with optional embedded Lua scripts. You can also define multiple requests in a single .sinq file by separating them with the ### delimiter. Text after ### on the same line names the request that follows (shown in --list output and reports) — a leading ### before any content names the file's first request the same way, even in a single-request file.
There are two categories of scripts within a .sinq file:
- General/Inline Scripts:
$MY_VAR,$(unnamed). These are evaluated to dynamically generate the outgoing HTTP request. The return value is injected directly into the request payload or headers. If a general script fails,sinqattempts to automatically prependreturnand retry, enabling simple string interpolations like${env.HOST}. Only the first returned value is used;nil, functions, channels and coroutines fail the request. Strings are inserted as is, numbers and booleans as text, and tables as compact JSON, so"address": ${env.pointA}sends a JSON object. - Lifecycle Scripts:
$PRE,$RETRY,$ASSERT,$POST. These strictly control the execution flow and state of the request. To prevent side-effect leaks and ensure thread safety, specific APIs are only available during specific lifecycle stages.
-
$PRE(Setup & File I/O): Executes immediately when a worker picks up the request, before it is materialized. This is the only scope where you can modify the filesystem interactions for the request. Current request body payload is inaccessible here.req.attach("path/file.txt")— Replaces the request body with the contents of a file. (Fails if a body is already defined in the request).req.saveResponseTo("path/download.bin")— Streams the incoming response body directly to disk, bypassing the Lua memory buffer.req.cache(bool?)/req.cache(key)— Opts-in to client-side request caching. Parameter defaults totrueif omitted. Without a key, the cache is based on the data sent over the wire, its cookies and any attached filenames. With a string key, every request using that key shares one cached response, so prefix keys yourself when requests must stay apart ("auth:" .. env.phone). The first request to fill a key decides what's stored, including asaveResponseTotarget. Retries are cached separately, so polling still works.req.skip(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 asAbortedwithout throwing a failure.req.multipart(entry, ...)— Builds amultipart/form-databody from one or more part tables ({ name, data | source, filename?, contentType?, contentDisposition?, headers? }), passed positionally or as one array table. Repeated calls accumulate. Mutually exclusive withreq.attachand a literal body.req.multipartBoundary(string)— Pins the multipart boundary instead of using a random one.
-
$RETRY(Retry Policies): Executes immediately after receiving the HTTP response. This is the only lifecycle script that must return a value. It must return a Lua number representing milliseconds to wait before retrying, or a negative number to stop retrying.- Scope-Exclusive API:
sinq.retry.when(),sinq.retry.whenExponential(),sinq.retry.withJitter().
- Scope-Exclusive API:
-
$ASSERT(Validation): Executes after the retry loop completes. Used to fail tests.- Scope-Exclusive API:
sinq.assert.code(),sinq.assert.equals(),sinq.assert.contains(),sinq.assert.isTrue(),sinq.assert.fileMatches(),sinq.assert.fail().
- Scope-Exclusive API:
-
$POST(State Extraction): Executes after assertions. Used to extract data from the response and save it to the global sandbox for subsequent requests. It will not execute if$ASSERTcallssinq.assert.failand the scenario settingfail_fastis true.
sinq exposes the following API and global variables to Lua scripts:
env— Table of environment variables configured for the scenario.secrets— Table of secrets passed via the-s, --secrets, --secrets-filearguments.sinq.setNextRequest(index)— Change execution flow to the n-th request (1-indexed). Allows loops or skipping requests. A request that runs several times is reported once, with the most severe status of its runs, the failures of all runs, and its attempts and timings added up.sinq.finishScenario()— Change execution flow to end after the current request.res— Shorthand for current request's response tablereq— Shorthand for current request table
sinq.time.ms(1)sinq.time.second(1000)sinq.time.minute(60000)sinq.time.hour(3600000)
Time API (sinq.time)
- Functions:
now(),fromString(str, format?),toString(ms, format?),sleep(ms)
Encoding API (sinq.encoding)
- Base64/hex:
base64Encode(str),base64Decode(str),base64UrlEncode(str),base64UrlDecode(str),hexEncode(str),hexDecode(str) - URL:
urlEncode(str),urlDecode(str)— for query strings andapplication/x-www-form-urlencodedbodies
Crypto API (sinq.crypto)
- Hashing:
md5(str, enc?),sha1(str, enc?),sha256(str, enc?),sha512(str, enc?),hmac(str, algo?, key?, enc?)
JWT API (sinq.jwt)
- Functions:
decode(token),verify(token, key, algo?),sign(claimsTable, key, method?)
JSON API (sinq.json)
- Functions:
parse(source),serialize(tbl, indent?),array(tbl?) sinq.json.null— a constant representing an explicit JSONnull, since Lua tables dropnilkeys.- Empty arrays: Lua can't tell an empty array from an empty object, so an empty table serializes as
{}. Arrays parsed from JSON stay arrays, andsinq.json.array(tbl?)marks a table (or returns a new one) so it serializes as[]when empty. - Arrays must be gapless:
serializereturns an error for{ 1, nil, 3 }. Usesinq.json.nullfor holes.
Fake Data API (sinq.fake)
- Functions: See the Lua API documentation for a comprehensive suite of fake data generators (
sinq.fake.uuid()/sinq.fake.uuidv4(),sinq.fake.email(),sinq.fake.company(), etc.) to generate random, realistic payloads for endpoints.
All completed responses are stored in the global sinq.responses table. Lua is 1-indexed, so the response to the first request is sinq.responses[1].
Each response table contains:
attempt(number) — The current retry iteration.code(number) — HTTP status code.headers(table) — Response headers (multiple headers with the same key are stored as nested array tables).oversized(boolean | nil) —trueif the payload exceededMaxBodySizeand was clamped.size(number | nil) — Total bytes written to disk (only present ifreq.saveResponseTo()was used in$PRE).bodyRaw(string | nil) — Raw response body bytes (only present ifreq.saveResponseTo()wasn't used in$PRE).bodyJson(table | nil) — The cached JSON table (initiallynil).
JSON Parsing Methods (Only Present If req.saveResponseTo() Wasn't Used In $PRE):
extractBodyJson()— Safely parsesbodyRaw, stores it inbodyJson, and returns(table, error).json()— Unsafe convenience parser. Returns the table directly, but callserror()and fails the scenario if the body is not valid JSON.
Note: The response table is populated during execution; a response index is only non-nil after the corresponding request has been sent.
sinq uses JSON-formatted .scenario files along the scenario path to manage environments, timeouts, and other configurations.
Default configuration that can be overridden in .scenario files:
{
"name": "path/to/dir/of/last/file",
"description": "",
"env": {},
"req_timeout": "5s",
"script_timeout": "5s",
"timeout": "10m",
"fail_fast": true,
"max_retries": 10,
"max_redirects": 5,
"max_body": "1MiB",
"env_matrix": [],
"tags": []
}name: The name of the scenario. If this particular.scenariofile is used in several scenarios - they will all have the same name.description: Description of the scenario.env: Object that will be parsed intosinq.envLua table, which will then be accessible from all Lua scripts.req_timeout: Timeout for any single request in the scenario.script_timeout: Timeout for any single script run in the scenario.timeout: Total timeout for the whole scenario.fail_fast: When true, scenarios will not be ran if any of them fails to compile for some reason, and the scenarios stop at the first failed assertion.max_retries: The maximum amount of times any request in the scenario can be retried upon retry script returning a valid non-negative number.max_redirects: The maximum amount of redirects the client will follow before returning the redirect as the actual response.max_body: Maximum size of response body that will be stored in memory during scenario execution. If a response exceeds this limit, it is safely truncated and the response'soversizedflag is set totrue.env_matrix: Data sets for the environment matrix mechanism -sinq's take on matrix/combinatorial/parametrized testing. For more information and examples please check out the documentation.tags: Tags or labels assigned to scenarios containing this.scenariofile. They get collected into one list for the resulting scenario. In JUnit reports they're written as atagsproperty of the scenario's<testsuite>.
Secrets are loaded from a JSON file provided through --secrets-file and overrides passed with --secret|-s flag (for multiple overrides use the flag multiple times).
To reduce the risk of accidental exposure, some error messages intentionally omit possibly sensitive data. Verbose mode (--verbose) as well as --log-level debug and --dump-on-failure may include additional debugging information and should be used carefully in CI environments where logs are retained. Assertion failure messages from sinq.assert.equals and sinq.assert.contains leave out the values they compare and report only types and field paths. Custom messages are shown as written, so don't put secrets in them.
--unrestricted (-u) loads the full Lua os and io libraries, which lets scenario scripts run shell commands, access the filesystem outside the scenario directory, and terminate sinq. Only use it with scenario files you fully trust. See Libraries for details.
Point sinq at a directory containing your .sinq files. sinq will automatically sort them in natural order, bundle them into scenarios and execute them concurrently. You can also point sinq to a single .sinq file to execute just that file as its own standalone scenario.
# Run a standard test suite
sinq ./tests/integration
# Run tests and output a JUnit report for your CI pipeline
sinq -f junit -o report.xml ./tests/integration
# Ignore self-signed TLS certificates and enable verbose debug logging
sinq -iV ./tests/local
# Run a single file as a standalone scenario with default configuration
sinq ./tests/integration/001-auth/01-login.sinq
# Override deeply nested environment keys using dot-notation and JSON values
sinq -e "API.TIMEOUT=500" -s 'DB.HOSTS=["10.0.0.1", "10.0.0.2"]' ./tests -v, --version Print the current sinq version and exit
-h, --help Print this help message and exit
-i, --insecure Disable SSL/TLS certificate verification
-V, --verbose Enable verbose reporting (reports each stage duration, only affects "std" format)
-l, --list Parse and list scenarios at specified directories
-u, --unrestricted Load Lua os/io modules - disables the script sandbox
-p, --print Capture lua output and show it in the report
-w, --workers int Number of concurrent workers (default 10)
-c, --count uint Number of launches for every scenario (default 1)
-s, --secret string Key=value pair overrides for scenario secrets
-e, --env string Key=value pair overrides for all scenario environments
-o, --out path Path to write the output file (prints to stdout if omitted)
-L, --log-level string Log level to use: debug, info, warn or error (default "warn")
-f, --format string Output format: std or junit (default "std")
-C, --color string Terminal colors: always, never, auto (default "auto")
-S, --show string Which results to show in the output: all, no-skip, failed, none (default "no-skip"); std format only, junit always shows everything
-t, --tag string Execute only scenarios that have at least one of passed tags
-n, --name string Execute only scenarios which names match at least one of passed regular expressions
--no-spinner Disable spinner animation
--dump-on-failure Print full request and response data on failed assertion
--secrets-file string Path to JSON-formatted secrets file
--no-tag string Do not execute scenarios that have the tag
--no-name string Do not execute scenarios which names match the regular expression
--plugins string Paths to lua plugin directory entries, joined with ':' on Linux and MacOS, ';' on Windows
--max-cache-size string Global maximum response size for cached requests, default 5MiB
--cache-timeout string Global timeout for the cached requests, default 10s
Combining filters:
-t/--tagand-n/--name(and their--no-tag/--no-namecounterparts) OR within the same flag type (any one of several-ttags is enough) but AND across types — a scenario must satisfy the tag filter (if any) and the name filter (if any) to be included.
Choose sinq when:
- You need stateful integration workflows with minimal setup.
- You prefer files over GUI collections.
- You don't need the whole JS runtime to make http requests.
- You run tests primarily in CI or CLI interfaces.
Choose another tool when:
- You need a graphical API explorer.
- You need browser automation.
- You need fully custom test frameworks written in a general-purpose language.
From v1.0.0 onward, sinq follows Semantic Versioning:
- Patch (
v1.0.x): bug fixes only, with no intended change to behavior or output. - Minor (
v1.x.0): new, backward-compatible functionality. New flags, new Lua APIs, or new config keys with safe defaults. - Major (
vX.0.0): any breaking change.
The compatibility promise covers:
- CLI flag names, their semantics and defaults, and process exit codes.
- The
.sinqfile format and the.scenarioconfiguration schema. - The Lua API surface exposed to scripts (globals, the
sinq.*namespace, and the lifecycle scopes). - The structure of the JUnit XML report.
It does not cover, and any of the following may change in any release:
- The exact text, layout, and colors of the human-readable
stdreporter. - Log message wording and formatting.
- Anything under
internal/(this module exposes no importable Go API). - Performance characteristics.
- Documentation
- Tree-sitter Grammar - syntax highlighting for tree-sitter compatible editors
- VSCode Extension - syntax highlighting for VSCode
This project includes natural sorting logic adapted from facette/natsort, which is distributed under the 3-Clause BSD License. Copyright (c) 2015, Vincent Batoufflet and Marc Falzon. All rights reserved.
Big thanks to yuin for his gopher-lua project.
Copyright (C) 2026 Veitangie. Distributed under the terms of the GNU General Public License v3 (GPLv3).

