SDK
@esfera/sdk is the TypeScript client for the Converter API. It creates a conversion, uploads the master (in parallel parts when it is large), follows the job to ready and downloads the IAMF, with typed errors, safe retries and webhook signature helpers. It has no runtime dependencies and runs on Node 20 or later, Bun, Deno and Cloudflare Workers.
Server-side only
The client carries your API key, which is a secret. Run it on a server, a build machine or a worker you control. Never ship it to a browser.
Install
@esfera/sdk is not on npm yet. Until the first release, install it from a tarball built from the repository, or one Esfera sends you.
# in a checkout of the Esfera Developer Hub repository
pnpm install
pnpm --filter @esfera/sdk build
(cd packages/sdk && pnpm pack) # writes esfera-sdk-0.2.0.tgz
# in your project
npm install ./esfera-sdk-0.2.0.tgzThe package is ESM only and ships its own type declarations.
Create a client
import { Esfera } from "@esfera/sdk";
const esfera = new Esfera({
apiKey: process.env.ESFERA_API_KEY!,
baseUrl: "https://api.esfera.audio",
maxRetries: 3,
timeoutMs: 30_000,
});| Option | Default | Meaning |
|---|---|---|
apiKey | required | esf_sk_live_v1_…, or esf_sk_test_v1_… for staging. The constructor throws InvalidRequestError when it is empty. |
baseUrl | https://api.esfera.audio | Use https://staging-api.esfera.audio with a test key. A trailing slash is ignored. |
maxRetries | 3 | Extra attempts for retryable requests. See retries. |
timeoutMs | 30000 | Per control-plane request. Uploads and downloads are not timed. |
fetch | global fetch | Inject your own, for tests. |
Two read-only members are useful at run time: esfera.baseUrl, and esfera.rateLimit, the last X-RateLimit-* values seen (limit, remaining, resetAt, degraded), or null before the first call.
Resources
| Resource | Methods |
|---|---|
esfera.conversions | create, get, list, iterate, analyze, start, wait, download, delete, job |
esfera.webhooks | create, list, revoke, deliveries.list, deliveries.replay |
esfera.usage | get |
esfera.presets | get |
esfera.files | inspect |
Esfera.webhooks (static) | verify, constructEvent |
Conversions
create(options) returns a ConversionJob: a handle holding the id and the verbs that act on it.
const job = await esfera.conversions.create({
file: "master.wav", // a path (Node only), a Blob, or { stream, size, name }
preset: "esfera", // omit for the two-step flow
profile: "advanced-2", // optional; "auto" lets the preset decide
programme: 0, // optional; 0-based, when the ADM declares several
source: { normalization: "sn3d" }, // ambisonic sources only
onUploadProgress: (p) => console.log(`${p.sent} / ${p.total}`),
});| Option | Meaning |
|---|---|
file | The master: a file path (Node only), a Blob, or a StreamSource of { stream, size, name }. |
preset | esfera, youtube, bed714_3oa or oa3. Supply it to start the encode automatically after analysis. |
profile | A writable profile id, or "auto" (the default). |
programme | 0-based programme when the ADM declares several. |
source | { normalization: "sn3d" | "n3d" | "fuma" }, sent as given. For ambisonic sources only; the server refuses it for ADM. |
filename | Overrides the name taken from the path or Blob. |
durationSec | Required for a stream source. For a path or Blob it is read from the file's WAV or RF64 header. |
idempotencyKey | Defaults to a random UUID, so create() is always safe to retry. |
onUploadProgress | Called with { sent, total } while the master uploads. |
signal | An AbortSignal for the upload. |
What create() does, in order: reads the duration from the header, creates the conversion with its sizeBytes, uploads the master, and calls analyze. A master of 16 MiB or less goes as one PUT. A larger one that can be re-read (a path or Blob) goes as parallel parts: 8 MiB each, four at a time, and eight at a time from 1 GiB up, with the part size growing so a very large file stays within 10,000 parts. A stream source always goes as a single PUT, which storage caps at 5 GiB.
create() never calls start() for a job created with a preset: the server starts the encode itself after analysis. If an idempotent replay returns the first job, job.uploaded is false and nothing is sent.
Without a preset, the job stops at analyzed. Read the analysis, then choose:
const job = await esfera.conversions.create({ file: "master.wav" });
const analysed = await job.wait({ until: "analyzed" });
console.log(analysed.programmeCount, analysed.objectCount);
await job.start({ preset: "youtube", programme: 0, profile: "base" });
const done = await job.wait();Other methods:
| Method | Returns | Notes |
|---|---|---|
get(id) | Conversion | |
list({ status, page, pageSize }) | Page<Conversion> | { items, total, page, pageSize }. |
iterate({ status, pageSize }) | AsyncIterable<Conversion> | Walks every page for you. |
analyze(id) | void | Needed only when you upload by hand. create() calls it. |
start(id, { preset, programme, profile, source }) | void | profile here overrides one given at create; "auto" clears it; omitting it keeps it. |
wait(id, options) | Conversion | See wait. |
download(id) | ReadableStream<Uint8Array> | |
delete(id) | void | Removes the conversion and its stored files. It does not refund minutes. |
job(id) | ConversionJob | A handle for an id you already have. |
A ConversionJob has id, conversion (the latest snapshot, null until the first refresh() or wait()), uploaded, and the methods refresh, analyze, start, wait, download, downloadToBuffer and delete.
const stream = await job.download(); // ReadableStream
const { path, bytes } = await job.download("master.iamf"); // Node only: writes the file
const bytes2 = await job.downloadToBuffer(); // Uint8ArrayWebhooks
const { id, secret } = await esfera.webhooks.create({ url: "https://example.com/esfera" });
// Store `secret` now: it is never shown again.
const hooks = await esfera.webhooks.list(); // revoked ones included, never the secret
const log = await esfera.webhooks.deliveries.list(id, { page: 1 });
await esfera.webhooks.deliveries.replay(id, log.deliveries[0].id);
await esfera.webhooks.revoke(id); // revoked, not deleted: its log stays readableThe URL must be HTTPS and cannot point at a private host. An account can have three active endpoints. A delivery can be replayed even after it was delivered, in case your handler lost the event.
Usage, presets and files
const usage = await esfera.usage.get();
usage.limits.perFileMinutes; // read before a long upload
usage.minutes.remaining; // what a job may still cost
const { engine, profiles, presets } = await esfera.presets.get();
const info = await esfera.files.inspect("master.wav");
info.isAdm; info.channels; info.durationSec; info.programmeCount; info.objectCount; info.ambisonicOrder;files.inspect() reads the WAV, RF64 or BW64 header locally and uploads nothing, so you can refuse a plain WAV or an over-long master for free. It throws InvalidRequestError for a file that is not WAV or RF64.
Two helpers work from the presets.get() response. eligibleProfiles(presets, preset, { channels, objectCount, ambisonicOrder }) lists every profile with whether a master can use it and, if not, why. predictProfile(presets, preset, shape) returns the profile auto would declare, or null when the data cannot decide.
Wait
wait() polls a conversion until it is done.
const done = await job.wait({
until: "ready", // or "analyzed"
pollIntervalMs: 3000,
stallMs: 300_000,
timeoutMs: 3_600_000,
signal: controller.signal,
onProgress: (c) => console.log(c.status, c.progress),
});| Option | Default | Meaning |
|---|---|---|
until | "ready" | "analyzed" returns as soon as analysis has finished, or the job is past it. |
pollIntervalMs | 3000 | Time between polls. |
stallMs | 300000 | No sign of life for this long raises ConversionStalledError. |
timeoutMs | none | Throws a plain Error when the total wait exceeds it. |
signal | none | Aborting raises AbortError, checked between polls. |
onProgress | none | Called with every snapshot, including the first. |
The rules it follows:
- It returns the
Conversionwhen the status isready, and throwsConversionFailedErrorwhen it iserror. The error carriesconversion, anderrorMessagesays why. queuedandwaitingnever count as stalled, however long they last. They are capacity and queue waits, designed behaviour.- Life is a change to
status,progressorupdatedAt. The converter updatesupdatedAtthrough passes that write no progress, such as the loudness pass of a long master, so a job can sit at one percentage and still be alive. wait()raisesConversionStalledErroronly when none of those has changed forstallMsoutsidequeuedandwaiting.
Errors
Every non-2xx response becomes a typed error built from the API's problem document. Match on the class, or on error.slug or error.type, never on title.
| Class | When |
|---|---|
AuthenticationError | 401, invalid-api-key. |
RateLimitError | 429, rate-limited. retryAfter is in seconds. |
QuotaExceededError | 403, quota-exhausted. |
ConcurrencyLimitError | 409, concurrency-limit. |
NotFoundError | 404. |
InvalidRequestError | 400 or 422, and the client's own validation. |
ConflictError | Other 409s: already-started, programme-required, source-removed, webhook-limit, replay-in-flight. |
ApiError | Any other non-2xx, including a problem type from a newer server than this SDK knows. Every field is kept. |
All of them extend ApiError, which carries status, type, title, detail, retryAfter, requestId (the edge request id, for support) and slug (the part of type after https://esfera.audio/errors/, or null).
Four more errors are not API responses:
| Class | When |
|---|---|
UploadError | The presigned PUT to storage answered non-2xx. Has status. |
ConversionFailedError | The job ended in error. Has conversion. |
ConversionStalledError | wait() saw no sign of life. Has conversion and stalledMs. |
SignatureVerificationError | A webhook signature did not verify. |
import { RateLimitError, QuotaExceededError, ConversionFailedError } from "@esfera/sdk";
try {
const job = await esfera.conversions.create({ file: "master.wav", preset: "esfera" });
await job.wait();
} catch (err) {
if (err instanceof QuotaExceededError) { /* top up or wait for the reset */ }
else if (err instanceof ConversionFailedError) console.error(err.conversion.errorMessage);
else throw err;
}Retries
GET and DELETE requests, and POSTs that carry an Idempotency-Key, are retried on 429 (waiting Retry-After), on 5xx and on network errors. Backoff starts at 500 ms with 25 percent jitter, doubles, and is capped at 30 seconds, for up to maxRetries extra attempts. A POST without a key is never retried, because retrying it could do the work twice, and a 4xx other than 429 is never retried because it would fail the same way. The SDK sends a key on conversions.create() and webhooks.create().
Webhook helpers
Verify a delivery with the raw request body, the X-Esfera-Signature header and the endpoint's secret:
import express from "express";
import { Esfera, SignatureVerificationError } from "@esfera/sdk";
const app = express();
app.post("/esfera", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await Esfera.webhooks.constructEvent(
req.body, // the raw bytes, not parsed JSON
req.header("x-esfera-signature") ?? "",
process.env.ESFERA_WEBHOOK_SECRET!,
);
if (event.event === "conversion.completed") console.log(event.data.conversion.id);
res.sendStatus(200);
} catch (err) {
if (err instanceof SignatureVerificationError) return res.sendStatus(400);
throw err;
}
});constructEvent(rawBody, header, secret, { toleranceSec }) verifies, parses the envelope { event, createdAt, data: { conversion } } and returns it. It throws SignatureVerificationError for a wrong secret, an altered body, a stale timestamp (the default tolerance is 300 seconds), or a body that is not a known Esfera event. verify(rawBody, header, secret, options) returns a boolean and never throws. Both compare in constant time and fail closed on anything malformed. Parsing the body before verifying breaks the signature, because it signs the exact bytes.
TypeScript notes
- Every wire type is exported:
Conversion,Source,Output,Usage,Webhook,WebhookDelivery,WebhookEvent,PresetsResponseand the rest. They are pinned to the OpenAPI document by a contract test in the repository. - The vocabularies are literal unions with runtime lists beside them:
PresetandPRESETS,ProfileIdandPROFILE_IDS,ConversionStatusandCONVERSION_STATUSES,WebhookEventTypeandWEBHOOK_EVENT_TYPES.ProfileChoiceis a writable profile id or"auto". FileSourceisstring | Blob | StreamSource. A path string needs Node; the dynamicnode:fsimport happens only when you use a path ordownload(path), so the rest runs anywherefetchdoes.presetAllowedForOrder(preset, order)tells you whether a preset can take an ambisonic source of that order before you upload a byte.- The package version is exported as
SDK_VERSION, andshouldUseMultipartandplanMultipartexpose the upload planner.