Configure the SDK once, at startup, then use it anywhere without passing the key again.
import { init } from '@goenvless/sdk'
init({ token: process.env.ENVLESS_TOKEN })import { envless } from '@goenvless/sdk'
const workspace = await envless.workspace.get()envless is a live handle on whatever init installed. Importing it does not read the token, and nothing is resolved until you touch a property on it, so import order between your entrypoint and your modules does not matter.
init is optional. If you never call it, the first method call builds a client from ENVLESS_TOKEN and ENVLESS_API_ENDPOINT automatically. Setting those two variables is enough to use envless with no setup code at all.
Three ways to build it
They differ only in who holds the instance and where the token comes from. Mix them freely; they do not interfere.
| Use when | |
|---|---|
init(options?) | One workspace per process. The usual case. Sets the client envless resolves to, and returns it. |
createClient(options?) | You want an explicit instance to pass around, or several clients at once. |
new Envless(options) | The token has to come from you. It never falls back to process.env, which is what you want in a Worker or any bundle where process is absent or shimmed. |
import { envless, init } from '@goenvless/sdk'
init({ token: process.env.ENVLESS_TOKEN })
await envless.products.list()import { createClient } from '@goenvless/sdk'
const envless = createClient()
await envless.products.list()import { Envless } from '@goenvless/sdk'
const envless = new Envless({ token: 'ev_sk_...' })
await envless.products.list()init and createClient both fall back to ENVLESS_TOKEN and ENVLESS_API_ENDPOINT when process exists, and throw An Envless API key is required. Pass { token } or set ENVLESS_TOKEN. when no token can be found. new Envless() has no environment fallback for either one, so it throws that same message unless you pass token yourself.
@goenvless/sdk administers a workspace, so every constructor wants an ev_sk_ key. If what you want on an edge runtime is the decrypted variables rather than administration, that is a different package: @goenvless/env and its /edge entrypoint. load({ token, project, environment, passphrase }) returns a plain record, touches no filesystem and never writes to process.env.
Working with more than one workspace
A key is bound to one workspace, so reaching two means two clients. init holds exactly one, so use createClient for the rest, or for all of them.
import { createClient, envless, init } from '@goenvless/sdk'
init({ token: process.env.ENVLESS_TOKEN })
const staging = createClient({ token: process.env.ENVLESS_STAGING_TOKEN })
const [live, preview] = await Promise.all([
envless.variables.listAll('api', 'production'),
staging.variables.listAll('api', 'production')
])Two escape hatches for the default client
| Export | Signature | Purpose |
|---|---|---|
getClient | () => Envless | Returns the current default instance, building it from the environment if init was never called. Use it when you need the real object rather than the handle, for example to pass it to rotateWorkspacePassphrase. |
resetClient | () => void | Drops the installed client. The next access resolves afresh. Intended for tests. |
import { getClient, rotateWorkspacePassphrase } from '@goenvless/sdk'
await rotateWorkspacePassphrase({
client: getClient(),
workspaceUniqueId,
from: oldPassphrase,
to: newPassphrase,
project: 'billing',
environment: 'production'
})In tests, init with an injected fetch and tear it down afterwards, so no suite leaks a client into the next one:
import { envless, init, resetClient } from '@goenvless/sdk'
beforeEach(() => init({ token: 'ev_sk_test', baseUrl: 'https://api.test', fetch: stub }))
afterEach(() => resetClient())
test('lists products', async () => {
await envless.products.list()
})The default client is stored on a global symbol, so it is shared by everything in the process. In a multi-tenant server that handles requests for different workspaces, do not init per request: build a client per tenant with createClient and pass it down.
Options
| Option | Default | Purpose |
|---|---|---|
token | ENVLESS_TOKEN | Your API key. Required. |
baseUrl | https://api.envless.cloud | Point at a self-hosted or local instance. Trailing slashes are trimmed. |
maxRetries | 3 | Retries idempotent requests on a transient status. It counts retries and not attempts, so the default is four requests at most. See below. |
timeoutMs | 30000 | Deadline for each attempt, retries included. Bounds the response body too, not just the headers. |
userAgent | envless-sdk/<version> | Sent as User-Agent. Set something identifiable for your own tooling. |
fetch | global fetch | Inject your own for tests, proxies or instrumentation. |
dangerouslyAllowBrowser | false | The client refuses to start in a browser, because an API key does not belong in one. Set this only when a server runtime merely looks like a browser, such as a jsdom test environment. |
disableUpdateNotice | false | Silences the one-time check for a newer published version. Also silenced by ENVLESS_DISABLE_UPDATE_NOTICE, and skipped whenever stdout is not a TTY. |
const envless = createClient({
token: process.env.ENVLESS_TOKEN,
baseUrl: 'http://localhost:2222',
maxRetries: 5,
timeoutMs: 60_000,
userAgent: 'acme-provisioner/2.1'
})Retries
The client retries only when retrying is safe. Two conditions must both hold:
The method is idempotent
GET, HEAD, PUT and DELETE are retried. POST and PATCH never are, because a create or an update that timed out may well have landed.
The status is transient
408, 429, 500, 502, 503 and 504. Everything else, including every 4xx you can fix, fails on the first attempt.
Backoff doubles off a 250ms base, so the first retry waits 500ms, then 1s, then 2s, and every delay is capped at 30 seconds. A Retry-After header is read as a number of seconds and replaces the computed delay whenever it parses above zero, under that same 30 second cap. A date-form Retry-After does not parse and is ignored.
A request that produced no response at all, a refused connection or a DNS failure, throws EnvlessNetworkError on the spot. There is no status to classify, so it is never retried.
Envless does not rate limit your requests, so a 429 can only have come from infrastructure in front of the API, such as a CDN or a proxy. The client retries it with backoff and honours a numeric Retry-After if it carries one.
Cancellation
Every method takes an options.signal. It is combined with the per-request timeout rather than replacing it, so whichever fires first wins. Raise timeoutMs if a single request needs longer than 30 seconds.
const controller = new AbortController()
setTimeout(() => controller.abort(), 5_000)
const products = await envless.products.listAll({ signal: controller.signal })const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(10_000)
])
await envless.products.listAll({ signal })An aborted request throws EnvlessNetworkError, not EnvlessApiError, because nothing came back from the server to classify.
Injecting fetch
The fetch option takes any function with the standard signature, which makes the whole client trivially testable with no network and no mocking framework.
const calls: Array<string> = []
const envless = new Envless({
token: 'ev_sk_test',
baseUrl: 'https://api.test',
fetch: async (url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url}`)
return new Response(JSON.stringify({
success: true,
code: 200,
message: 'OK',
error: null,
data: { products: [], pagination: { limit: 25, offset: 0, totalCount: 0, hasMore: false } }
}), { status: 200 })
}
})
await envless.products.list()
calls // ['GET https://api.test/products']A bare list() sends no query string at all: limit, offset, search and order are only appended when you pass them, and the API applies its own default of 25. Ask for list({ limit: 25 }) and the call becomes GET https://api.test/products?limit=25.
It is also the seam for a proxy, a request logger, or an outbound rate limiter shared with the rest of your service.
The raw escape hatch
envless.raw is the underlying transport. It unwraps the response envelope, applies the same retry policy, and throws the same typed errors, so you keep every guarantee while reaching something the typed namespaces do not cover yet.
const payload = await envless.raw.request<{ products: Array<unknown> }>('/products', {
method: 'GET',
query: { limit: 5, order: 'desc' }
})| Field | Type | Notes |
|---|---|---|
path | string | Appended to baseUrl. Encode your own path segments. |
options.method | string | Defaults to GET. |
options.query | Record<string, string | number | boolean | null | undefined> | undefined, null and '' are dropped rather than sent empty. |
options.body | unknown | Serialised as JSON, and sets Content-Type. |
options.signal | AbortSignal | Combined with the per-request timeout, whichever fires first wins. |
raw.request returns the data field of the envelope, not the envelope. There is no way to read success, message or _req.reqId on a successful response, by design: a non-success envelope is turned into a thrown EnvlessApiError that carries the request id for you.
Reuse one client
A client holds no connection state and no cursor, just configuration. Build one per process and share it. Building one per request costs nothing but buys nothing either.