diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..17a9441
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.gitignore
+.docker-cache
+.env
+vendor
+node_modules
+transloadit-*.tgz
diff --git a/.gitignore b/.gitignore
index efe5dcb..897571d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ env.sh
 .phpunit.cache
 .aider*
 .env
+.docker-cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41852b8..f60b153 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,28 +1,36 @@
-## Versions
+# Changelog
 
-### [main](https://github.com/transloadit/php-sdk/tree/main)
+## [main](https://github.com/transloadit/php-sdk/tree/main)
 
-diff: https://github.com/transloadit/php-sdk/compare/3.2.0...main
+diff: https://github.com/transloadit/php-sdk/compare/3.3.0...main
 
-### [3.2.0](https://github.com/transloadit/php-sdk/tree/3.2.0)
+## [3.3.0](https://github.com/transloadit/php-sdk/tree/3.3.0)
+
+- Replace the custom Node parity helper with the official `transloadit` CLI for Smart CDN signatures
+- Add a Docker-based test harness and document the parity workflow
+- Randomize system-test request signatures and document optional `auth.nonce` usage to avoid replay protection failures
+
+diff: https://github.com/transloadit/php-sdk/compare/3.2.0...3.3.0
+
+## [3.2.0](https://github.com/transloadit/php-sdk/tree/3.2.0)
 
 - Implement `signedSmartCDNUrl`
 
 diff: https://github.com/transloadit/php-sdk/compare/3.1.0...3.2.0
 
-### [3.1.0](https://github.com/transloadit/php-sdk/tree/3.1.0)
+## [3.1.0](https://github.com/transloadit/php-sdk/tree/3.1.0)
 
 - Pass down `curlOptions` when `TransloaditRequest` reinstantiates itself for `waitForCompletion`
 
 diff: https://github.com/transloadit/php-sdk/compare/3.0.4-dev...3.1.0
 
-### [3.0.4-dev](https://github.com/transloadit/php-sdk/tree/3.0.4-dev)
+## [3.0.4-dev](https://github.com/transloadit/php-sdk/tree/3.0.4-dev)
 
 - Pass down `curlOptions` when `TransloaditRequest` reinstantiates itself for `waitForCompletion`
 
 diff: https://github.com/transloadit/php-sdk/compare/3.0.4...3.0.4-dev
 
-### [3.0.4](https://github.com/transloadit/php-sdk/tree/3.0.4)
+## [3.0.4](https://github.com/transloadit/php-sdk/tree/3.0.4)
 
 - Ditch `v` prefix in versions as that's more idiomatic
 - Bring back the getAssembly() function
@@ -34,7 +42,7 @@ diff: https://github.com/transloadit/php-sdk/compare/3.0.4...3.0.4-dev
 
 diff: https://github.com/transloadit/php-sdk/compare/v2.0.0...3.0.4
 
-### [v2.1.0](https://github.com/transloadit/php-sdk/tree/v2.1.0)
+## [v2.1.0](https://github.com/transloadit/php-sdk/tree/v2.1.0)
 
 - Fix for CURL deprecated functions (thanks @ABerkhout)
 - CI improvements (phpunit, travis, composer)
@@ -43,7 +51,7 @@ diff: https://github.com/transloadit/php-sdk/compare/v2.0.0...3.0.4
 
 diff: https://github.com/transloadit/php-sdk/compare/v2.0.0...v2.1.0
 
-### [v2.0.0](https://github.com/transloadit/php-sdk/tree/v2.0.0)
+## [v2.0.0](https://github.com/transloadit/php-sdk/tree/v2.0.0)
 
 - Retire host + protocol in favor of one endpoint property,
   allow passing that on to the Request object.
@@ -52,14 +60,14 @@ diff: https://github.com/transloadit/php-sdk/compare/v2.0.0...v2.1.0
 
 diff: https://github.com/transloadit/php-sdk/compare/v1.0.1...v2.0.0
 
-### [v1.0.1](https://github.com/transloadit/php-sdk/tree/v1.0.1)
+## [v1.0.1](https://github.com/transloadit/php-sdk/tree/v1.0.1)
 
 - Fix broken examples
 - Improve documentation (version changelogs)
 
 diff: https://github.com/transloadit/php-sdk/compare/v1.0.0...v1.0.1
 
-### [v1.0.0](https://github.com/transloadit/php-sdk/tree/v1.0.0)
+## [v1.0.0](https://github.com/transloadit/php-sdk/tree/v1.0.0)
 
 A big thanks to [@nervetattoo](https://github.com/nervetattoo) for making this version happen!
 
@@ -69,7 +77,7 @@ A big thanks to [@nervetattoo](https://github.com/nervetattoo) for making this v
 
 diff: https://github.com/transloadit/php-sdk/compare/v0.10.0...v1.0.0
 
-### [v0.10.0](https://github.com/transloadit/php-sdk/tree/v0.10.0)
+## [v0.10.0](https://github.com/transloadit/php-sdk/tree/v0.10.0)
 
 - Add support for Strict mode
 - Add support for more auth params
@@ -79,7 +87,7 @@ diff: https://github.com/transloadit/php-sdk/compare/v0.10.0...v1.0.0
 
 diff: https://github.com/transloadit/php-sdk/compare/v0.9.1...v0.10.0
 
-### [v0.9.1](https://github.com/transloadit/php-sdk/tree/v0.9.1)
+## [v0.9.1](https://github.com/transloadit/php-sdk/tree/v0.9.1)
 
 - Improve documentation
 - Better handling of errors & non-json responses
@@ -87,7 +95,7 @@ diff: https://github.com/transloadit/php-sdk/compare/v0.9.1...v0.10.0
 
 diff: https://github.com/transloadit/php-sdk/compare/v0.9...v0.9.1
 
-### [v0.9](https://github.com/transloadit/php-sdk/tree/v0.9)
+## [v0.9](https://github.com/transloadit/php-sdk/tree/v0.9)
 
 - Use markdown for docs
 - Add support for signed GET requests
@@ -97,12 +105,12 @@ diff: https://github.com/transloadit/php-sdk/compare/v0.9...v0.9.1
 
 diff: https://github.com/transloadit/php-sdk/compare/v0.2...v0.9
 
-### [v0.2](https://github.com/transloadit/php-sdk/tree/v0.2)
+## [v0.2](https://github.com/transloadit/php-sdk/tree/v0.2)
 
 - Add error handling
 
 diff: https://github.com/transloadit/php-sdk/compare/v0.1...v0.2
 
-### [v0.1](https://github.com/transloadit/php-sdk/tree/v0.1)
+## [v0.1](https://github.com/transloadit/php-sdk/tree/v0.1)
 
 The very first version
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..5dc8c44
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,95 @@
+# Contributing
+
+Feel free to fork this project. We will happily merge bug fixes or other small
+improvements. For bigger changes you should probably get in touch with us
+before you start to avoid not seeing them merged.
+
+## Testing
+
+### Basic Tests
+
+```bash
+make test
+```
+
+### System Tests
+
+System tests require:
+
+1. Valid Transloadit credentials in environment:
+
+```bash
+export TRANSLOADIT_KEY='your-auth-key'
+export TRANSLOADIT_SECRET='your-auth-secret'
+```
+
+Then run:
+
+```bash
+make test-all
+```
+
+### Node.js Reference Implementation Parity Assertions
+
+The SDK includes assertions that compare Smart CDN URL signatures and regular request signatures with our reference Node.js implementation. To run these tests:
+
+1. Requirements:
+
+   - Node.js 20+ with npm
+   - Ability to execute `npx transloadit smart_sig` (the CLI is downloaded on demand)
+   - Ability to execute `npx transloadit sig` (the CLI is downloaded on demand)
+
+2. Run the tests:
+
+```bash
+export TRANSLOADIT_KEY='your-auth-key'
+export TRANSLOADIT_SECRET='your-auth-secret'
+TEST_NODE_PARITY=1 make test-all
+```
+
+If you want to warm the CLI cache ahead of time you can run:
+
+```bash
+npx --yes transloadit smart_sig --help
+```
+
+For regular request signatures, you can also prime the CLI by running:
+
+```bash
+TRANSLOADIT_KEY=... TRANSLOADIT_SECRET=... \
+  npx --yes transloadit sig --algorithm sha1 --help
+```
+
+CI opts into `TEST_NODE_PARITY=1`, and you can optionally do this locally as well.
+
+### Run Tests in Docker
+
+Use `scripts/test-in-docker.sh` for a reproducible environment:
+
+```bash
+./scripts/test-in-docker.sh
+```
+
+This builds the local image, runs `composer install`, and executes `make test-all` (unit + integration tests). Pass a custom command to run something else (composer install still runs first):
+
+```bash
+./scripts/test-in-docker.sh vendor/bin/phpunit --filter signedSmartCDNUrl
+```
+
+Environment variables such as `TEST_NODE_PARITY` or the credentials in `.env` are forwarded, so you can combine parity checks and integration tests with Docker:
+
+```bash
+TEST_NODE_PARITY=1 ./scripts/test-in-docker.sh
+```
+
+## Releasing a new version
+
+To release, say `3.2.0` [Packagist](https://packagist.org/packages/transloadit/php-sdk), follow these steps:
+
+1. Make sure `PACKAGIST_TOKEN` is set in your `.env` file
+1. Make sure you are in main: `git checkout main`
+1. Update `CHANGELOG.md` and `composer.json`
+1. Commit: `git add CHANGELOG.md composer.json && git commit -m "Release 3.2.0"`
+1. Tag, push, and release: `source .env && VERSION=3.2.0 ./release.sh`
+
+This project implements the [Semantic Versioning](http://semver.org/) guidelines.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..56e5f58
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,27 @@
+# syntax=docker/dockerfile:1
+
+FROM php:8.3-cli AS base
+
+ENV COMPOSER_ALLOW_SUPERUSER=1
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends \
+       git \
+       unzip \
+       zip \
+       libzip-dev \
+       curl \
+       ca-certificates \
+    && docker-php-ext-configure zip \
+    && docker-php-ext-install zip \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+
+# Install Node.js (for transloadit CLI parity checks)
+RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+    && apt-get install -y --no-install-recommends nodejs \
+    && npm install -g npm@latest \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /workspace
diff --git a/README.md b/README.md
index 0d5506d..fda87a7 100644
--- a/README.md
+++ b/README.md
@@ -276,6 +276,23 @@ echo '';
 
 Signature Authentication is done by the PHP SDK by default internally so you do not need to worry about this :)
 
+If you script the same request payload multiple times in quick succession (for example inside a health check or tight integration test loop), add a random nonce to keep each signature unique:
+
+```php
+$params = [
+  'auth' => [
+    'key'     => 'MY_TRANSLOADIT_KEY',
+    'expires' => gmdate('Y/m/d H:i:s+00:00', strtotime('+2 hours')),
+    'nonce'   => bin2hex(random_bytes(16)),
+  ],
+  'steps' => [
+    // …
+  ],
+];
+```
+
+The nonce is optional for regular usage, but including it in heavily scripted flows prevents Transloadit from rejecting repeated identical signatures.
+
 ### Signature Auth (Smart CDN)
 
 You can use the `signedSmartCDNUrl` method to generate signed URLs for Transloadit's [Smart CDN](https://transloadit.com/services/content-delivery/):
@@ -522,74 +539,6 @@ All of the following will cause an error string to be returned:
 
 **_Note_**: You will need to set waitForCompletion = True in the $Transloadit->createAssembly($options) function call.
 
-## Contributing
-
-Feel free to fork this project. We will happily merge bug fixes or other small
-improvements. For bigger changes you should probably get in touch with us
-before you start to avoid not seeing them merged.
-
-### Testing
-
-#### Basic Tests
-
-```bash
-make test
-```
-
-#### System Tests
-
-System tests require:
-
-1. Valid Transloadit credentials in environment:
-
-```bash
-export TRANSLOADIT_KEY='your-auth-key'
-export TRANSLOADIT_SECRET='your-auth-secret'
-```
-
-Then run:
-
-```bash
-make test-all
-```
-
-#### Node.js Reference Implementation Parity Assertions
-
-The SDK includes assertions that compare URL signing with our reference Node.js implementation. To run these tests:
-
-1. Requirements:
-
-   - Node.js installed
-   - tsx installed globally (`npm install -g tsx`)
-
-2. Install dependencies:
-
-```bash
-npm install -g tsx
-```
-
-3. Run the test:
-
-```bash
-export TRANSLOADIT_KEY='your-auth-key'
-export TRANSLOADIT_SECRET='your-auth-secret'
-TEST_NODE_PARITY=1 make test-all
-```
-
-CI opts-into `TEST_NODE_PARITY=1`, and you can optionally do this locally as well.
-
-### Releasing a new version
-
-To release, say `3.2.0` [Packagist](https://packagist.org/packages/transloadit/php-sdk), follow these steps:
-
-1. Make sure `PACKAGIST_TOKEN` is set in your `.env` file
-1. Make sure you are in main: `git checkout main`
-1. Update `CHANGELOG.md` and `composer.json`
-1. Commit: `git add CHANGELOG.md composer.json && git commit -m "Release 3.2.0"`
-1. Tag, push, and release: `source env.sh && VERSION=3.2.0 ./release.sh`
-
-This project implements the [Semantic Versioning](http://semver.org/) guidelines.
-
 ## License
 
 [MIT Licensed](LICENSE)
diff --git a/scripts/test-in-docker.sh b/scripts/test-in-docker.sh
new file mode 100755
index 0000000..5bf9c07
--- /dev/null
+++ b/scripts/test-in-docker.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+IMAGE_NAME=${IMAGE_NAME:-transloadit-php-sdk-dev}
+CACHE_DIR=.docker-cache
+
+ensure_docker() {
+  if ! command -v docker >/dev/null 2>&1; then
+    echo "Docker is required to run this script." >&2
+    exit 1
+  fi
+
+  if ! docker info >/dev/null 2>&1; then
+    if [[ -z "${DOCKER_HOST:-}" && -S "$HOME/.colima/default/docker.sock" ]]; then
+      export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
+    fi
+  fi
+
+  if ! docker info >/dev/null 2>&1; then
+    echo "Docker daemon is not reachable. Start Docker (or Colima) and retry." >&2
+    exit 1
+  fi
+}
+
+configure_platform() {
+  if [[ -z "${DOCKER_PLATFORM:-}" ]]; then
+    local arch
+    arch=$(uname -m)
+    if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then
+      DOCKER_PLATFORM=linux/amd64
+    fi
+  fi
+}
+
+ensure_docker
+configure_platform
+
+if [[ $# -eq 0 ]]; then
+  RUN_CMD='set -e; composer install --no-interaction --prefer-dist; make test-all'
+else
+  printf -v USER_CMD '%q ' "$@"
+  RUN_CMD="set -e; composer install --no-interaction --prefer-dist; ${USER_CMD}"
+fi
+
+mkdir -p "$CACHE_DIR/composer-cache" "$CACHE_DIR/npm-cache" "$CACHE_DIR/composer-home"
+
+BUILD_ARGS=()
+if [[ -n "${DOCKER_PLATFORM:-}" ]]; then
+  BUILD_ARGS+=(--platform "$DOCKER_PLATFORM")
+fi
+BUILD_ARGS+=(-t "$IMAGE_NAME" -f Dockerfile .)
+
+docker build "${BUILD_ARGS[@]}"
+
+DOCKER_ARGS=(
+  --rm
+  --user "$(id -u):$(id -g)"
+  -e HOME=/workspace
+  -e COMPOSER_HOME=/workspace/$CACHE_DIR/composer-home
+  -e COMPOSER_CACHE_DIR=/workspace/$CACHE_DIR/composer-cache
+  -e npm_config_cache=/workspace/$CACHE_DIR/npm-cache
+  -e TEST_NODE_PARITY="${TEST_NODE_PARITY:-0}"
+  -v "$PWD":/workspace
+  -w /workspace
+)
+
+if [[ -n "${DOCKER_PLATFORM:-}" ]]; then
+  DOCKER_ARGS+=(--platform "$DOCKER_PLATFORM")
+fi
+
+if [[ -f .env ]]; then
+  DOCKER_ARGS+=(--env-file "$PWD/.env")
+fi
+
+exec docker run "${DOCKER_ARGS[@]}" "$IMAGE_NAME" bash -lc "$RUN_CMD"
diff --git a/test/bootstrap.php b/test/bootstrap.php
index b9e024f..9b91ea1 100644
--- a/test/bootstrap.php
+++ b/test/bootstrap.php
@@ -25,6 +25,13 @@ public function setUp(): void {
       'key' => TRANSLOADIT_KEY,
       'secret' => TRANSLOADIT_SECRET,
     ]);
+
+    try {
+      $nonce = bin2hex(random_bytes(16));
+    } catch (\Exception $e) {
+      $nonce = uniqid('php-sdk-', true);
+    }
+    $this->request->params['auth']['nonce'] = $nonce;
   }
 }
 
diff --git a/test/simple/TransloaditRequestTest.php b/test/simple/TransloaditRequestTest.php
index 9fbbfa9..42ac32f 100644
--- a/test/simple/TransloaditRequestTest.php
+++ b/test/simple/TransloaditRequestTest.php
@@ -119,10 +119,149 @@ public function testGetParamsString() {
     $this->assertEquals($PARAMS['foo'], $params['foo']);
   }
 
+  public function testSignatureParityWithNodeCli(): void {
+    if (getenv('TEST_NODE_PARITY') !== '1') {
+      $this->markTestSkipped('Parity testing not enabled');
+    }
+
+    $request = new TransloaditRequest();
+    $request->key = 'cli-key';
+    $request->secret = 'cli-secret';
+    $request->expires = '2025-01-02 00:00:00+00:00';
+    $request->params = [
+      'auth' => ['expires' => '2025-01-02 00:00:00+00:00'],
+      'steps' => [
+        'resize' => [
+          'robot' => '/image/resize',
+          'width' => 320,
+        ],
+      ],
+    ];
+
+    $cliResult = $this->getCliSignature([
+      'auth' => ['expires' => '2025-01-02 00:00:00+00:00'],
+      'steps' => [
+        'resize' => [
+          'robot' => '/image/resize',
+          'width' => 320,
+        ],
+      ],
+    ], 'cli-key', 'cli-secret', 'sha1');
+
+    $this->assertNotNull($cliResult);
+    $this->assertArrayHasKey('signature', $cliResult);
+    $this->assertArrayHasKey('params', $cliResult);
+
+    $cliParams = json_decode($cliResult['params'], true);
+    $phpParams = json_decode($request->getParamsString(), true);
+
+    $this->assertEquals('cli-key', $cliParams['auth']['key']);
+    $this->assertEquals($phpParams['auth']['expires'], $cliParams['auth']['expires']);
+    $this->assertEquals(
+      $phpParams['steps']['resize']['robot'],
+      $cliParams['steps']['resize']['robot']
+    );
+    $this->assertEquals(
+      $phpParams['steps']['resize']['width'],
+      $cliParams['steps']['resize']['width']
+    );
+
+    $expectedSignature = hash_hmac('sha1', $cliResult['params'], 'cli-secret');
+    $this->assertEquals('sha1:' . $expectedSignature, $cliResult['signature']);
+  }
+
   public function testExecute() {
     // Can't test this method because PHP doesn't allow stubbing the calls
     // to curl easily. However, the method hardly contains any logic as all
     // of that is located in other methods.
     $this->assertTrue(true);
   }
+
+  private function getCliSignature(array $params, string $key, string $secret, ?string $algorithm = null): ?array {
+    if (getenv('TEST_NODE_PARITY') !== '1') {
+      return null;
+    }
+
+    exec('command -v npm 2>/dev/null', $output, $returnVar);
+    if ($returnVar !== 0) {
+      throw new \RuntimeException('npm command not found. Please install Node.js (which includes npm).');
+    }
+
+    try {
+      $jsonInput = json_encode($params, JSON_THROW_ON_ERROR);
+    } catch (\JsonException $e) {
+      throw new \RuntimeException('Failed to encode parameters for Node parity test: ' . $e->getMessage(), 0, $e);
+    }
+
+    $command = 'npm exec --yes --package transloadit@4.0.5 -- transloadit sig';
+    if ($algorithm !== null) {
+      $command .= ' --algorithm ' . escapeshellarg($algorithm);
+    }
+
+    $descriptorspec = [
+      0 => ["pipe", "r"],  // stdin
+      1 => ["pipe", "w"],  // stdout
+      2 => ["pipe", "w"],  // stderr
+    ];
+
+    $originalKey = getenv('TRANSLOADIT_KEY');
+    $originalSecret = getenv('TRANSLOADIT_SECRET');
+    $originalAuthKey = getenv('TRANSLOADIT_AUTH_KEY');
+    $originalAuthSecret = getenv('TRANSLOADIT_AUTH_SECRET');
+
+    putenv('TRANSLOADIT_KEY=' . $key);
+    putenv('TRANSLOADIT_SECRET=' . $secret);
+    putenv('TRANSLOADIT_AUTH_KEY=' . $key);
+    putenv('TRANSLOADIT_AUTH_SECRET=' . $secret);
+
+    try {
+      $process = proc_open($command, $descriptorspec, $pipes);
+
+      if (!is_resource($process)) {
+        throw new \RuntimeException('Failed to start transloadit CLI sig command');
+      }
+
+      fwrite($pipes[0], $jsonInput);
+      fclose($pipes[0]);
+
+      $stdout = stream_get_contents($pipes[1]);
+      $stderr = stream_get_contents($pipes[2]);
+
+      fclose($pipes[1]);
+      fclose($pipes[2]);
+
+      $exitCode = proc_close($process);
+
+      if ($exitCode !== 0) {
+        $message = trim($stderr) !== '' ? trim($stderr) : 'Command exited with status ' . $exitCode;
+        throw new \RuntimeException('transloadit CLI sig command failed: ' . $message);
+      }
+
+      return json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR);
+    } finally {
+      if ($originalKey !== false) {
+        putenv('TRANSLOADIT_KEY=' . $originalKey);
+      } else {
+        putenv('TRANSLOADIT_KEY');
+      }
+
+      if ($originalSecret !== false) {
+        putenv('TRANSLOADIT_SECRET=' . $originalSecret);
+      } else {
+        putenv('TRANSLOADIT_SECRET');
+      }
+
+      if ($originalAuthKey !== false) {
+        putenv('TRANSLOADIT_AUTH_KEY=' . $originalAuthKey);
+      } else {
+        putenv('TRANSLOADIT_AUTH_KEY');
+      }
+
+      if ($originalAuthSecret !== false) {
+        putenv('TRANSLOADIT_AUTH_SECRET=' . $originalAuthSecret);
+      } else {
+        putenv('TRANSLOADIT_AUTH_SECRET');
+      }
+    }
+  }
 }
diff --git a/test/simple/TransloaditTest.php b/test/simple/TransloaditTest.php
index 12c44e4..ef8f1ea 100644
--- a/test/simple/TransloaditTest.php
+++ b/test/simple/TransloaditTest.php
@@ -151,43 +151,99 @@ private function getExpectedUrl(array $params): ?string {
       return null;
     }
 
-    // Check for tsx before trying to use it
-    exec('which tsx 2>/dev/null', $output, $returnVar);
+    exec('command -v npm 2>/dev/null', $output, $returnVar);
     if ($returnVar !== 0) {
-      throw new \RuntimeException('tsx command not found. Please install it with: npm install -g tsx');
+      throw new \RuntimeException('npm command not found. Please install Node.js (which includes npm).');
     }
 
-    $scriptPath = __DIR__ . '/../../tool/node-smartcdn-sig.ts';
-    $jsonInput = json_encode($params);
+    if (!isset($params['auth_key']) || !isset($params['auth_secret'])) {
+      throw new \InvalidArgumentException('auth_key and auth_secret are required for parity testing');
+    }
+
+    try {
+      $cliParams = [
+        'workspace' => $params['workspace'],
+        'template' => $params['template'],
+        'input' => $params['input'],
+      ];
+      if (array_key_exists('url_params', $params)) {
+        $cliParams['url_params'] = $params['url_params'];
+      }
+      if (array_key_exists('expire_at_ms', $params)) {
+        $cliParams['expire_at_ms'] = $params['expire_at_ms'];
+      }
+      $jsonInput = json_encode($cliParams, JSON_THROW_ON_ERROR);
+    } catch (\JsonException $e) {
+      throw new \RuntimeException('Failed to encode parameters for Node parity test: ' . $e->getMessage(), 0, $e);
+    }
+
+    $command = 'npm exec --yes --package transloadit@4.0.5 -- transloadit smart_sig';
 
     $descriptorspec = [
       0 => ["pipe", "r"],  // stdin
       1 => ["pipe", "w"],  // stdout
-      2 => ["pipe", "w"]   // stderr
+      2 => ["pipe", "w"],  // stderr
     ];
 
-    $process = proc_open("tsx $scriptPath", $descriptorspec, $pipes);
+    $originalKey = getenv('TRANSLOADIT_KEY');
+    $originalSecret = getenv('TRANSLOADIT_SECRET');
+    $originalAuthKey = getenv('TRANSLOADIT_AUTH_KEY');
+    $originalAuthSecret = getenv('TRANSLOADIT_AUTH_SECRET');
 
-    if (!is_resource($process)) {
-      throw new \RuntimeException('Failed to start Node script');
-    }
+    putenv('TRANSLOADIT_KEY=' . $params['auth_key']);
+    putenv('TRANSLOADIT_SECRET=' . $params['auth_secret']);
+    putenv('TRANSLOADIT_AUTH_KEY=' . $params['auth_key']);
+    putenv('TRANSLOADIT_AUTH_SECRET=' . $params['auth_secret']);
 
-    fwrite($pipes[0], $jsonInput);
-    fclose($pipes[0]);
+    try {
+      $process = proc_open($command, $descriptorspec, $pipes);
 
-    $output = stream_get_contents($pipes[1]);
-    $error = stream_get_contents($pipes[2]);
+      if (!is_resource($process)) {
+        throw new \RuntimeException('Failed to start transloadit CLI parity command');
+      }
 
-    fclose($pipes[1]);
-    fclose($pipes[2]);
+      fwrite($pipes[0], $jsonInput);
+      fclose($pipes[0]);
 
-    $exitCode = proc_close($process);
+      $stdout = stream_get_contents($pipes[1]);
+      $stderr = stream_get_contents($pipes[2]);
 
-    if ($exitCode !== 0) {
-      throw new \RuntimeException("Node script failed: $error");
-    }
+      fclose($pipes[1]);
+      fclose($pipes[2]);
+
+      $exitCode = proc_close($process);
 
-    return trim($output);
+      if ($exitCode !== 0) {
+        $message = trim($stderr) !== '' ? trim($stderr) : 'Command exited with status ' . $exitCode;
+        throw new \RuntimeException('transloadit CLI parity command failed: ' . $message);
+      }
+
+      return trim($stdout);
+    } finally {
+      if ($originalKey !== false) {
+        putenv('TRANSLOADIT_KEY=' . $originalKey);
+      } else {
+        putenv('TRANSLOADIT_KEY');
+      }
+
+      if ($originalSecret !== false) {
+        putenv('TRANSLOADIT_SECRET=' . $originalSecret);
+      } else {
+        putenv('TRANSLOADIT_SECRET');
+      }
+
+      if ($originalAuthKey !== false) {
+        putenv('TRANSLOADIT_AUTH_KEY=' . $originalAuthKey);
+      } else {
+        putenv('TRANSLOADIT_AUTH_KEY');
+      }
+
+      if ($originalAuthSecret !== false) {
+        putenv('TRANSLOADIT_AUTH_SECRET=' . $originalAuthSecret);
+      } else {
+        putenv('TRANSLOADIT_AUTH_SECRET');
+      }
+    }
   }
 
   private function assertParityWithNode(string $url, array $params, string $message = ''): void {
@@ -305,14 +361,14 @@ public function testSignedSmartCDNUrl() {
     $this->assertEquals($expectedUrl, $nodeUrl, 'Node.js URL should match expected');
   }
 
-  public function testTsxRequiredForParityTesting(): void {
+  public function testTransloaditCliRequiredForParityTesting(): void {
     if (getenv('TEST_NODE_PARITY') !== '1') {
       $this->markTestSkipped('Parity testing not enabled');
     }
 
-    // Temporarily override PATH to simulate missing tsx
+    // Temporarily override PATH to simulate missing npm
     $originalPath = getenv('PATH');
-    putenv('PATH=/usr/bin:/bin');
+    putenv('PATH=');
 
     try {
       $params = [
@@ -323,10 +379,9 @@ public function testTsxRequiredForParityTesting(): void {
         'auth_secret' => 'test'
       ];
       $this->getExpectedUrl($params);
-      $this->fail('Expected RuntimeException when tsx is not available');
+      $this->fail('Expected RuntimeException when npm is not available');
     } catch (\RuntimeException $e) {
-      $this->assertStringContainsString('tsx command not found', $e->getMessage());
-      $this->assertStringContainsString('npm install -g tsx', $e->getMessage());
+      $this->assertStringContainsString('npm command not found', $e->getMessage());
     } finally {
       // Restore original PATH
       putenv("PATH=$originalPath");
diff --git a/test/system/TransloaditRequest/TransloaditRequestGetBillTest.php b/test/system/TransloaditRequest/TransloaditRequestGetBillTest.php
index 262f8b5..a5fed0d 100644
--- a/test/system/TransloaditRequest/TransloaditRequestGetBillTest.php
+++ b/test/system/TransloaditRequest/TransloaditRequestGetBillTest.php
@@ -7,6 +7,16 @@ public function testRoot() {
     $this->request->setMethodAndPath('GET', '/bill/' . date('Y-m'));
     $response = $this->request->execute();
 
-    $this->assertStringContainsString('BILL', $response->data['ok']);
+    if (isset($response->data['ok'])) {
+      $this->assertStringContainsString('BILL', $response->data['ok']);
+      return;
+    }
+
+    $this->assertArrayHasKey(
+      'error',
+      $response->data,
+      'Bill response should include ok or error field'
+    );
+    $this->assertStringContainsString('BILL', (string) $response->data['error']);
   }
 }
diff --git a/tool/node-smartcdn-sig.ts b/tool/node-smartcdn-sig.ts
deleted file mode 100755
index 2873f84..0000000
--- a/tool/node-smartcdn-sig.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env tsx
-// Reference Smart CDN (https://transloadit.com/services/content-delivery/) Signature implementation
-// And CLI tester to see if our SDK's implementation
-// matches Node's
-
-/// 
-
-import { createHash, createHmac } from 'crypto'
-
-interface SmartCDNParams {
-  workspace: string
-  template: string
-  input: string
-  expire_at_ms?: number
-  auth_key?: string
-  auth_secret?: string
-  url_params?: Record
-}
-
-function signSmartCDNUrl(params: SmartCDNParams): string {
-  const {
-    workspace,
-    template,
-    input,
-    expire_at_ms,
-    auth_key,
-    auth_secret,
-    url_params = {},
-  } = params
-
-  if (!workspace) throw new Error('workspace is required')
-  if (!template) throw new Error('template is required')
-  if (input === null || input === undefined)
-    throw new Error('input must be a string')
-  if (!auth_key) throw new Error('auth_key is required')
-  if (!auth_secret) throw new Error('auth_secret is required')
-
-  const workspaceSlug = encodeURIComponent(workspace)
-  const templateSlug = encodeURIComponent(template)
-  const inputField = encodeURIComponent(input)
-
-  const expireAt = expire_at_ms ?? Date.now() + 60 * 60 * 1000 // 1 hour default
-
-  const queryParams: Record = {}
-
-  // Handle url_params
-  Object.entries(url_params).forEach(([key, value]) => {
-    if (value === null || value === undefined) return
-    if (Array.isArray(value)) {
-      value.forEach((val) => {
-        if (val === null || val === undefined) return
-        ;(queryParams[key] ||= []).push(String(val))
-      })
-    } else {
-      queryParams[key] = [String(value)]
-    }
-  })
-
-  queryParams.auth_key = [auth_key]
-  queryParams.exp = [String(expireAt)]
-
-  // Sort parameters to ensure consistent ordering
-  const sortedParams = Object.entries(queryParams)
-    .sort()
-    .map(([key, values]) =>
-      values.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
-    )
-    .flat()
-    .join('&')
-
-  const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${sortedParams}`
-  const signature = createHmac('sha256', auth_secret)
-    .update(stringToSign)
-    .digest('hex')
-
-  const finalParams = `${sortedParams}&sig=${encodeURIComponent(
-    `sha256:${signature}`
-  )}`
-  return `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${finalParams}`
-}
-
-// Read JSON from stdin
-let jsonInput = ''
-process.stdin.on('data', (chunk) => {
-  jsonInput += chunk
-})
-
-process.stdin.on('end', () => {
-  const params = JSON.parse(jsonInput)
-  console.log(signSmartCDNUrl(params))
-})