Skip to content

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.

bash
# 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.tgz

The package is ESM only and ships its own type declarations.

Create a client ​

ts
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,
});
OptionDefaultMeaning
apiKeyrequiredesf_sk_live_v1_…, or esf_sk_test_v1_… for staging. The constructor throws InvalidRequestError when it is empty.
baseUrlhttps://api.esfera.audioUse https://staging-api.esfera.audio with a test key. A trailing slash is ignored.
maxRetries3Extra attempts for retryable requests. See retries.
timeoutMs30000Per control-plane request. Uploads and downloads are not timed.
fetchglobal fetchInject 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 ​

ResourceMethods
esfera.conversionscreate, get, list, iterate, analyze, start, wait, download, delete, job
esfera.webhookscreate, list, revoke, deliveries.list, deliveries.replay
esfera.usageget
esfera.presetsget
esfera.filesinspect
Esfera.webhooks (static)verify, constructEvent

Conversions ​

create(options) returns a ConversionJob: a handle holding the id and the verbs that act on it.

ts
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}`),
});
OptionMeaning
fileThe master: a file path (Node only), a Blob, or a StreamSource of { stream, size, name }.
presetesfera, youtube, bed714_3oa or oa3. Supply it to start the encode automatically after analysis.
profileA writable profile id, or "auto" (the default).
programme0-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.
filenameOverrides the name taken from the path or Blob.
durationSecRequired for a stream source. For a path or Blob it is read from the file's WAV or RF64 header.
idempotencyKeyDefaults to a random UUID, so create() is always safe to retry.
onUploadProgressCalled with { sent, total } while the master uploads.
signalAn 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:

ts
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:

MethodReturnsNotes
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)voidNeeded only when you upload by hand. create() calls it.
start(id, { preset, programme, profile, source })voidprofile here overrides one given at create; "auto" clears it; omitting it keeps it.
wait(id, options)ConversionSee wait.
download(id)ReadableStream<Uint8Array>
delete(id)voidRemoves the conversion and its stored files. It does not refund minutes.
job(id)ConversionJobA 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.

ts
const stream = await job.download();            // ReadableStream
const { path, bytes } = await job.download("master.iamf");   // Node only: writes the file
const bytes2 = await job.downloadToBuffer();    // Uint8Array

Webhooks ​

ts
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 readable

The 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 ​

ts
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.

ts
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),
});
OptionDefaultMeaning
until"ready""analyzed" returns as soon as analysis has finished, or the job is past it.
pollIntervalMs3000Time between polls.
stallMs300000No sign of life for this long raises ConversionStalledError.
timeoutMsnoneThrows a plain Error when the total wait exceeds it.
signalnoneAborting raises AbortError, checked between polls.
onProgressnoneCalled with every snapshot, including the first.

The rules it follows:

  • It returns the Conversion when the status is ready, and throws ConversionFailedError when it is error. The error carries conversion, and errorMessage says why.
  • queued and waiting never count as stalled, however long they last. They are capacity and queue waits, designed behaviour.
  • Life is a change to status, progress or updatedAt. The converter updates updatedAt through 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() raises ConversionStalledError only when none of those has changed for stallMs outside queued and waiting.

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.

ClassWhen
AuthenticationError401, invalid-api-key.
RateLimitError429, rate-limited. retryAfter is in seconds.
QuotaExceededError403, quota-exhausted.
ConcurrencyLimitError409, concurrency-limit.
NotFoundError404.
InvalidRequestError400 or 422, and the client's own validation.
ConflictErrorOther 409s: already-started, programme-required, source-removed, webhook-limit, replay-in-flight.
ApiErrorAny 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:

ClassWhen
UploadErrorThe presigned PUT to storage answered non-2xx. Has status.
ConversionFailedErrorThe job ended in error. Has conversion.
ConversionStalledErrorwait() saw no sign of life. Has conversion and stalledMs.
SignatureVerificationErrorA webhook signature did not verify.
ts
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:

ts
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, PresetsResponse and 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: Preset and PRESETS, ProfileId and PROFILE_IDS, ConversionStatus and CONVERSION_STATUSES, WebhookEventType and WEBHOOK_EVENT_TYPES. ProfileChoice is a writable profile id or "auto".
  • FileSource is string | Blob | StreamSource. A path string needs Node; the dynamic node:fs import happens only when you use a path or download(path), so the rest runs anywhere fetch does.
  • 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, and shouldUseMultipart and planMultipart expose the upload planner.

developers.esfera.audio · the Esfera Converter API, SDK and CLI