knoxcall / sdk
Official PHP SDK for the KnoxCall API
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- ext-openssl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^10.5
Suggests
- php-http/discovery: Auto-discovers your PSR-17 factories and PSR-18 client for wrap()->httpClient(); otherwise pass response_factory/stream_factory/direct_client explicitly.
- psr/http-client-implementation: A PSR-18 client (e.g. guzzlehttp/guzzle ^7) — needed only for wrap httpClient route-around/direct calls.
- psr/http-factory-implementation: A PSR-17 factory (e.g. nyholm/psr7) — needed to build responses from wrap()->httpClient() unless you pass factories explicitly.
- psr/simple-cache-implementation: A PSR-16 cache (APCu, Redis, …) to share the intercept manifest across PHP processes via wrap()->httpClient(['cache' => $cache]); without one each process fetches it once per TTL.
Provides
None
Conflicts
None
Replaces
None
README
Official KnoxCall API client for PHP. Standards-based OAuth 2.1 under the hood — your code just calls methods. PHP >= 8.1, ext-curl + ext-json only, no other runtime dependencies.
Install
Not yet published to Packagist — install from the monorepo via a path repository ("repositories": [{"type": "path", "url": "../knoxcall/sdk/knoxcall-php"}] + composer require knoxcall/sdk:@dev) or a git checkout.
Quickstart
use KnoxCall\KnoxCall; // Credentials inline — no extra imports $client = new KnoxCall(['client_id' => 'tk_xxxxxxxx', 'client_secret' => '...']); // tenant auto-discovered // Or zero-arg with the environment configured // (KNOXCALL_TENANT, KNOXCALL_CLIENT_ID, KNOXCALL_CLIENT_SECRET) $client = new KnoxCall(); // Management API $page = $client->routes->list(); // ['data' => [...], 'meta' => ['total' => ..., ...]] foreach ($page['data'] as $route) { echo $route['name'], "\n"; } // Data plane: proxy a request through a route to your upstream $resp = $client->call('billing-stripe', ['path' => '/v1/customers']); // slug preferred $customers = json_decode($resp['body'], true);
For the Stripe-style isolated Test environment, pass 'sandbox' => true (uses https://sandbox.knoxcall.com + https://sandbox-{tenant}.knoxcall.com; requires a tk_test_… key).
Authentication
Pass credentials as plain constructor options:
| Option | Use |
|---|---|
client_id + client_secret |
OAuth client-credentials grant (recommended for servers) |
access_token / api_key |
a pre-acquired token or key — two spellings, one behavior; works with kc_… tokens and legacy tk_…/AKE… keys |
credentials |
advanced: a ClientCredentials, AccessToken, OIDCTokenExchange (workload identity via RFC 8693 token exchange), or StoredCredentials (knoxcall login file, explicit path/profile) instance |
With no explicit credentials, the SDK resolves them in this order (PHP has no cloud auto-detect, so it is strictly explicit > environment):
- explicit constructor credentials (flat options or a
credentialsobject) KNOXCALL_ACCESS_TOKEN/KNOXCALL_API_KEY- the
knoxcall logincredentials file (~/.knoxcall/credentials.json) KNOXCALL_CLIENT_ID+KNOXCALL_CLIENT_SECRET
Conflicting explicit options (e.g. api_key + client_id) throw at construction, before any environment fill.
A pre-acquired access_token / api_key is not auto-renewed — it has no refresh token, so the SDK sends it as-is until the server rejects it with a 401 (surfaced as an AuthenticationException). For durable, self-renewing auth use client_id + client_secret (client-credentials) or knoxcall login (the credentials file refreshes and rotates for you).
Log in once with the CLI
This package ships the knoxcall executable (Composer bin):
composer global require knoxcall/sdk # puts `knoxcall` on your PATH (Composer's global vendor/bin)
knoxcall login
or, inside a project that already requires the SDK:
vendor/bin/knoxcall login
knoxcall login opens your browser (authorization-code + PKCE; use --device or --no-browser on headless/SSH machines, --sandbox for the sandbox environment, --profile NAME for multiple accounts). knoxcall whoami shows the signed-in tenant; knoxcall logout revokes and removes the stored profile.
knoxcall init gets you started wrapping a provider SDK — it works against the tenant you are already signed in to and never provisions one. With no --provider it prints a two-step wrap quickstart. With --provider stripe --secret-name wrap-stripe --host api.stripe.com it moves a provider key into KnoxCall custody and prints the gateway base_url to point your SDK at; the key is read from the KNOXCALL_WRAP_SECRET environment variable (never a flag, so it stays out of your shell history) and is never echoed back.
Every KnoxCall SDK ships the same CLI, and every implementation writes the same file — ~/.knoxcall/credentials.json — so it doesn't matter which one you run login from: one sign-in covers PHP, Python, Node, Go, and Ruby on that machine. With no explicit credentials and no credential env vars, new KnoxCall() picks it up automatically — including the file's tenant and base_url, unless you set those yourself (constructor options, env vars, and 'sandbox' => true always win). A missing or malformed file/profile is skipped silently and resolution continues down the list above.
The stored access token is used as-is while it has more than 60s of validity. Past that, the SDK refreshes it under a cross-process file lock (credentials.json.lock) and atomically writes the rotated refresh token back — the server's refresh tokens are single-use, so never copy them out of the file. If the stored credentials were revoked or expired (invalid_grant), you get an AuthenticationException telling you to run knoxcall login again.
PHP-FPM note: each request is its own process, so the in-memory token cache lives for at most one request — with stored credentials the file's fresh-token fast path is the effective cross-request cache. A request re-reads the file (a local read, no HTTP) while the stored token is fresh and only performs a real refresh-token round trip when it is not, so no APCu/Redis store is needed for this credential type.
Environment variables
| Variable | Meaning |
|---|---|
KNOXCALL_TENANT |
tenant slug (optional — auto-discovered from the credential when unset) |
KNOXCALL_ENVIRONMENT |
default environment for data-plane calls |
KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET |
client-credentials grant |
KNOXCALL_ACCESS_TOKEN / KNOXCALL_API_KEY |
pre-acquired token (ACCESS_TOKEN wins) |
KNOXCALL_CREDENTIALS_FILE |
knoxcall login credentials-file path override (default ~/.knoxcall/credentials.json) |
KNOXCALL_PROFILE |
credentials-file profile (default default) |
KNOXCALL_BASE_URL |
management API base override (legacy KNOXCALL_API_BASE_URL still accepted) |
KNOXCALL_PROXY_BASE_URL |
data-plane base override |
Token caching: typical PHP-FPM deployments are per-request processes, so under client_credentials each request mints one token. If that matters, mint out of band, cache it yourself (APCu/Redis), and construct with ['access_token' => $cached]. Long-running workers (CLI, queues, Octane) get in-process caching with refresh-ahead and stale-but-valid fallback automatically.
Security warnings
The SDK emits one-time, non-blocking E_USER_WARNINGs (they never raise and never change behavior) for two misconfigurations:
- Plaintext transport — at construction, if the resolved management base URL or the data-plane proxy URL is
http://to a non-loopback host, the SDK warns that credentials and tokens will be sent unencrypted. Usehttps://. Plainhttp://tolocalhost(or127.0.0.0/8,::1,0.0.0.0,*.localhost) is the normal dev case and does not warn. - World-readable credentials file — on POSIX, when reading
~/.knoxcall/credentials.json, if the file is group/other-accessible the SDK warns you tochmod 600it (it holds a refresh token). The check is skipped on Windows, where the mode bits are advisory and confidentiality rests on the%USERPROFILE%ACL.
Data plane
call() — proxy through a route
Returns the raw upstream response (['status', 'headers', 'body']) — upstream HTTP errors are never thrown; they belong to you.
$resp = $client->call('billing-stripe', [ 'method' => 'POST', 'path' => '/v1/charges', 'body' => ['amount' => 2000, 'currency' => 'usd'], 'environment' => 'staging', ]);
path is the upstream path. On a KnoxCall cloud tenant host the data plane is served under /api (https://{tenant}.knoxcall.com/api/<path>); the SDK adds that prefix itself whenever the proxy base is a cloud tenant host with no path of its own, and uses any other base verbatim (self-hosted, or an override that already carries a path). So /api/v2/tickets reaches an upstream path that itself begins with /api.
Legacy (non-kc_) keys automatically travel as the x-knoxcall-key header instead of Authorization: Bearer.
Bound routes
State the route (and optional defaults) once, then use plain HTTP verbs:
$printnode = $client->route('printnode-api', ['environment' => 'production']); $computers = json_decode($printnode->get('/computers')['body'], true); $printnode->post('/printjobs', ['body' => ['printerId' => 1, 'title' => 'Invoice']]); $printnode->request('DELETE', '/printjobs/42'); // per-call options still override the bound defaults: $printnode->get('/computers', ['environment' => 'staging']);
ephemeral() — one-shot proxy
Proxies a single request via the KnoxCall Ephemeral Proxy, resolving {{ token: "..." }} expressions on the wire:
$resp = $client->ephemeral('https://api.stripe.com/v1/charges', [ 'method' => 'POST', 'body' => ['amount' => 2000, 'card' => '{{ token: "tok_vault_abc" }}'], ]);
Route-aware interception (preview)
Send an untouched third-party SDK's traffic through the Route that covers it — and through the ephemeral proxy where no Route does — with no per-SDK wiring:
$knox = new KnoxCall\KnoxCall(['api_key' => $apiKey]); $http = $knox->wrap->httpClient(['routes' => 'auto']); // a PSR-18 client, route-aware $http->ready(); // first manifest loaded $hubspot = HubSpot\Factory::createWithAccessToken('placeholder', new GuzzleHttp\Client(['handler' => $stack])); // …or any SDK that takes a PSR-18 client: every call to api.hubapi.com goes via the Route that covers it
Per request: the kill switch (KNOXCALL_INTERCEPT=off), KnoxCall's own hosts
and route-around rules go direct; a Route in the manifest covering host + path
goes through that Route (the Route injects the stored secret — no provider
credential travels); everything else goes through the ephemeral proxy — an
explicit transport treats every host as listed. Turn a Route's Intercept
toggle on and it takes effect on the next request after the 60 s TTL, or on
the next refusal, with no code change. Per-host options:
'hosts' => ['api.resend.com' => ['credential' => ['secret' => 'resend-key']]]
(escrow) or ['unavailable' => 'direct'] (transit only: send direct when
KnoxCall is unreachable; the default is fail closed). PHP processes are usually
per-request, so pass a PSR-16 cache ('cache' => $apcu) to share the manifest
across them; without one each process fetches it once per TTL.
For an SDK that builds its own Guzzle stack and lets you push middleware:
$stack = GuzzleHttp\HandlerStack::create(); $stack->push($knox->wrap->guzzleMiddleware(['hosts' => ['api.resend.com']]), 'knoxcall'); $guzzle = new GuzzleHttp\Client(['handler' => $stack]);
Only route-covered or listed hosts are touched there; everything else continues
down the stack. PHP has no process-wide HTTP seam (curl has no hook;
stream_wrapper misses curl), so these two seams are the coverage: any SDK
accepting a PSR-18 client, a Guzzle client or a handler stack. An SDK that
builds its own curl handle uses gatewayUrl() (base-URL mode).
Route mode is the custody path — the key never enters your process.
What the SDK reports about uncovered calls, and how to turn it off
Reporting is on by default. When the interceptor sends a call direct
because no Route covers its host and you did not list the host, and that call
carries a credential header (Authorization, X-Api-Key, or any name ending
in -api-key, -token, -secret or -auth), the SDK counts it. About once a
minute, the SDK reports the counts to KnoxCall
(POST /v1/wrap/egress-observations) with its own credential. The dashboard
uses the report to show which credentials still leave your process outside
KnoxCall custody.
What is sent. Each report carries the host, the first path segment, the method, the credential header's name, a count, and first/last-seen times. The header's value is never sent, and neither are the query string, the body, or any deeper path.
How to turn it off. Pass 'observe_uncovered' => false when you install, or set
KNOXCALL_OBSERVE_UNCOVERED=off in the environment. Nothing is reported while
KNOXCALL_INTERCEPT=off. If your key lacks routes:read, the first report is
refused, you get one warning, and reporting stops. 'on_observation_flush' receives the
server's {accepted, dropped} after each report.
Pagination
The server wraps every JSON response in {data, meta} and paginates with page / per_page (default 20, max 100). Paginated list() methods return that envelope; single-object methods return data unwrapped; iterate() walks all pages for you:
$page = $client->routes->list(['page' => 2, 'per_page' => 50]); // $page['data'] = rows; $page['meta'] = ['total', 'page', 'per_page', 'total_pages', 'request_id'] foreach ($client->routes->iterate() as $route) { // every page, transparently echo $route['slug'], "\n"; }
Bare-array endpoints (environments->list(), agents->list(), crypto->listKeys(), routes->listEnvironments(), …) return a plain array and take no page params.
Resources
// Routes — reference by slug (preferred), UUID also works; bare names are legacy $route = $client->routes->create(['name' => 'stripe', 'target_base_url' => 'https://api.stripe.com']); $client->routes->createAction($route['id'], [ 'direction' => 'response', 'action' => 'tokenize', 'selectors' => ['$.card.number'], ]); // Secrets $secret = $client->secrets->create(['name' => 'Stripe Key', 'value' => 'sk_live_…']); $client->secrets->setValue($secret['id'], 'sk_live_new', 'production'); // OAuth2 secrets — the proxy injects the provider's access token upstream $oauth2 = $client->secrets->createOAuth2([ 'name' => 'Google Drive', 'provider' => 'google', 'client_id' => '…', 'client_secret' => '…', 'scopes' => ['drive.readonly'], 'grant_type' => 'authorization_code', ]); // Certificate / mTLS secrets $cert = $client->secrets->createCertificate([ 'name' => 'mTLS Client', 'certificate_content' => $pem, 'private_key' => $key, 'certificate_type' => 'pem', ]); // Wrap — escrow a raw provider credential into custody in one call. // The value is sent ONCE and never returned; reference it by name afterwards. $wrapped = $client->wrap->escrow([ 'provider' => 'stripe', 'name' => 'stripe-live', 'value' => 'sk_live_…', 'hosts' => ['api.stripe.com'], ]); // => ['secret_id' => …, 'allowed_hosts' => ['api.stripe.com'], 'sandbox' => false, …] // Webhooks (create returns the once-only secret_key — store it) $hook = $client->webhooks->create(['name' => 'orders', 'url' => 'https://example.com/hook', 'event_types' => ['request.completed']]); $client->webhooks->test($hook['id']); $event = $client->webhooks->constructEvent($rawBody, $headers, $hook['secret_key']); // see below // Clients (device/server identities) + credentials $device = $client->clients->create(['name' => 'warehouse-pi', 'type' => 'server', 'ip_address' => '203.0.113.9']); $client->clients->createCredential($device['id'], 'mtls_thumbprint', 'rack-4', ['mode' => 'issue']); // OAuth clients (machine-to-machine credentials; create returns the once-only client_secret) $oauth = $client->oauthClients->create(['name' => 'ci', 'grant_types' => ['client_credentials']]); // Environments $client->environments->create(['name' => 'staging', 'display_name' => 'Staging', 'color' => '#f59e0b']); // API keys (create returns the once-only plaintext api_key) $key = $client->apiKeys->create(['name' => 'ci key']); // Account + usage $account = $client->account->get(); $usage = $client->account->getUsage(); // Audit logs foreach ($client->auditLogs->iterate(['action' => 'route.create']) as $row) { /* … */ } // Agents (create returns the once-only agent_secret) $agent = $client->agents->create('build-agent'); // Crypto / Transit (encryption-as-a-service) $client->crypto->createKey(['name' => 'app-default', 'mode' => 'cloud-only']); ['ciphertext' => $ct] = $client->crypto->encrypt('app-default', ['plaintext' => 'hello']); ['plaintext' => $pt] = $client->crypto->decrypt('app-default', $ct, 'utf8'); $jwt = $client->crypto->signJwt('app-default', ['sub' => 'user_1']); // Portable kc: encryption — arbitrary JSON in, same shape out with kc: ciphertexts $sealed = $client->crypto->encryptData(['ssn' => '123-45-6789'], ['role' => 'pii']); $open = $client->crypto->decryptData($sealed['ciphertext'], ['role' => 'pii']); $client->crypto->inspect($sealed['ciphertext']['ssn']); // metadata, no decryption $client->crypto->mintClientToken(['action' => 'decrypt', 'data' => $sealed['ciphertext']['ssn']]); // one-shot browser reveal $bundle = $client->crypto->getSealingBundle(); // public bits for client-side sealing // PKI (customer CA) $client->pki->createRoot('internal-ca', ['common_name' => 'Acme Internal CA']); $leaf = $client->pki->issueCert('internal-ca', 'web-servers', ['common_name' => 'api.internal.test']); $pem = $client->pki->getRootCert('internal-ca'); // raw PEM string // Vaults (tokenization) $vault = $client->vaults->create(['name' => 'cards', 'token_format' => 'pan']); $token = $client->vaults->tokenize('cards', '4242424242424242'); $value = $client->vaults->detokenize('cards', $token['token']); foreach ($client->vaults->iterateTokens('cards') as $t) { /* … */ } // Dynamic DB credentials $client->dynamicDb->create(['name' => 'analytics-db', 'engine' => 'postgres', /* … */]); $creds = $client->dynamicDb->mint('analytics-db', 'read-only', 3600); // once-only password $client->dynamicDb->listLeases(); // AI Gateway (secret → gateway → agent → capability token). // provider + upstream_secret_id compose the upstream route. An agent created // with NEITHER those nor primary_route_id has no upstream and 502s on its // first data-plane call. provider is a plain string — the catalog is // server-side, and a bad value returns a 400 naming the valid set. $secret = $client->secrets->create(['name' => 'anthropic-key', 'value' => getenv('ANTHROPIC_API_KEY')]); $gw = $client->aiGateway->createGateway(['name' => 'Prod', 'slug' => 'prod']); $agent = $client->aiGateway->createAgent($gw['id'], [ 'name' => 'summarizer', 'slug' => 'summarizer', 'provider' => 'anthropic', 'upstream_secret_id' => $secret['id'], 'default_model' => 'claude-sonnet-5', ]); $minted = $client->aiGateway->mintToken($agent['id'], ['kind' => 'agent']); // $minted['token'] is plaintext shown ONCE foreach ($client->aiGateway->iterateGateways() as $g) { /* … */ } $usage = $client->aiGateway->usage(['period' => '30d']); // cost + tokens by model // Signup — credential-less, static (no client needed). Two steps: signup() // returns a claim handle and emails a sign-in link; claimSignup() collects the // one-time test key once the owner has clicked it. $accepted = KnoxCall::signup(['email' => 'dev@example.com', 'tenant_name' => 'Acme Inc']); $claim = KnoxCall::claimSignup($accepted['claim_handle']); // poll until status === 'ready' $client = new KnoxCall(['api_key' => $claim['starter']['api_key']['api_key'], 'sandbox' => true]);
Webhook verification
constructEvent() verifies the delivery AND parses it in one step — use it in your webhook endpoint with the RAW request body:
use KnoxCall\KnoxCall; use KnoxCall\WebhookSignatureVerificationException; $rawBody = file_get_contents('php://input'); try { $event = KnoxCall::constructEvent($rawBody, getallheaders(), $endpointSecret, [ 'format' => 'stripe', // must match the webhook's hmac_format (default 'legacy') 'tolerance_seconds' => 300, // replay window; null disables ]); } catch (WebhookSignatureVerificationException $e) { http_response_code(400); exit; } match ($event['event']) { 'request.server_error' => alert($event['data']['route_name'], $event['data']['response']['status']), 'audit.event' => log($event['data']['action']), default => null, // the event-type list is open — unknown types still parse };
Supported formats mirror the server exactly: legacy, stripe, github, slack, aws-sns, and custom (pass header_name). All are HMAC-SHA256 with constant-time comparison. The boolean KnoxCall::verifySignature() helper remains for stripe-shaped headers when you only need a yes/no.
Errors
KnoxCallException base (extends RuntimeException)
├── ApiException HTTP errors (->statusCode, ->errorCode, ->requestId, ->responseHeaders, ->responseBody)
│ ├── AuthenticationException 401
│ ├── PermissionDeniedException 403
│ ├── NotFoundException 404
│ ├── ConflictException 409
│ ├── ValidationException 422 (->fields)
│ ├── RateLimitException 429 (->retryAfter)
│ ├── ServerException 5xx
│ └── SignupException KnoxCall::signup() failures
├── ConnectionException transport failures (->requestSent)
│ └── ConnectionTimeoutException
└── WebhookSignatureVerificationException constructEvent() failures
Every ApiException carries the server's request_id (->requestId) — include it when contacting support.
try { $client->routes->create(['name' => 'x', 'target_base_url' => 'y']); } catch (\KnoxCall\RateLimitException $e) { sleep($e->retryAfter ?? 1); } catch (\KnoxCall\ValidationException $e) { var_dump($e->fields); }
Retries & idempotency
Management requests retry HTTP 408/429/500/502/503/504 (never 409) with half-jitter exponential backoff, honoring Retry-After up to 30s; a 401 purges the cached token and re-auths once. Every mutating request carries a ULID X-Idempotency-Key that stays stable across retries, so replays are safe. Data-plane calls never replay a mutation that may have reached the upstream. Configure with retry_max_attempts, retry_base_delay_ms, retry_max_delay_ms, timeout_ms.
DPoP — sender-constrained tokens
For higher-security tenants, enable DPoP (RFC 9449; requires ext-openssl):
$client = new KnoxCall(['tenant' => 'acme', 'dpop' => 'always']);
The SDK generates an ES256 keypair, binds the access token to it via the cnf.jkt claim, and signs a fresh proof JWT per request — token, management, and data-plane alike. Stolen tokens become useless without the keypair.
In the default 'auto' mode the SDK starts with plain Bearer tokens and upgrades to DPoP automatically when the OAuth client record has require_dpop set (the token endpoint answers invalid_dpop_proof; the SDK generates a keypair, retries once, and operates as DPoP from then on). 'dpop' => 'never' opts out — if the server issues a DPoP-bound token anyway, the SDK throws a typed exception rather than 401-looping.
Route references
Reference routes by slug — the write-once machine handle set on the route (rename-proof, portable across tenants). UUIDs also work; bare names are legacy.