@xmodeai/sdk
v0.7.2
Published
Official TypeScript/JavaScript SDK for the xMode API — AI image and video generation.
Maintainers
Readme
@xmodeai/sdk
Official TypeScript / JavaScript SDK for the xMode API — AI image and video generation.
- Zero runtime dependencies. Uses the platform
fetch— works on Node 18+, Bun, Deno, Cloudflare Workers and other edge runtimes. - Async-first. Submit a job, then poll or receive a webhook. Helpers do the polling for you.
- Forward-compatible. New models and fields the API adds later never break an installed version.
- Typed. First-class TypeScript types with autocomplete for known models, tolerant of everything else.
Install
npm install @xmodeai/sdkQuickstart
import XMode from '@xmodeai/sdk';
const client = new XMode({ apiKey: process.env.XMODE_API_KEY });
// Submit and wait for the result in one call.
const result = await client.generations.createAndWait({
endUserEmail: '[email protected]',
model: 'xSD4.5',
prompt: 'Editorial photo of a red panda astronaut, studio lighting, 85mm',
});
if (result.status === 'succeeded') {
console.log(result.images[0]?.url); // signed URL, valid ~23h — download or re-host
} else if (result.status === 'failed') {
console.error(result.error.code, result.error.message);
}The API key is read from the XMODE_API_KEY environment variable when you don't
pass apiKey explicitly.
Two retention clocks, worth keeping apart: the signed URLs last about 23 hours (re-reading the record re-signs them, but does not extend the window), while the record itself — metadata, cost, error — is kept for 90 days from creation and then removed. Anything you need beyond that belongs in your own store.
Some models take extra knobs — negativePrompt, and prompt rewriting via
promptExtend / promptExtendMode / enableThinking. They are per-model and
off by default; the ones a given model accepts are on its page in the docs, and
a model that does not take one ignores it. The exception is a field whose
silent absence would change what you get or pay for — references on a
text-to-image-only model, sequential: 'auto' on a model without series — which
raises XModeValidationError before you are charged. Note that rewriting makes
seed non-reproducible: the prompt that runs is regenerated on each attempt.
Polling vs. webhooks
createAndWait polls for you (every 5s by default) and resolves once the job
reaches a terminal status. If you'd rather drive it yourself:
const queued = await client.generations.create({ endUserEmail, model: 'xSD4.5', prompt });
// ... later
const record = await client.generations.get(queued.requestId);
// or poll an existing job until terminal:
const done = await client.generations.wait(queued.requestId, { pollInterval: 5000 });
// Delete a generation and its stored files once it's terminal
// (409 ConflictError while it's still queued/processing):
await client.generations.delete(queued.requestId);
// Delete many at once (≤200 per call, one HTTP request, partial success).
// deleteMany does NOT chunk — loop yourself for larger sets:
for (let i = 0; i < ids.length; i += 200) {
const res = await client.generations.deleteMany(ids.slice(i, i + 200));
// res.deleted / res.conflicts (still in-flight) / res.notFound
}For production, prefer webhooks: pass webhookUrl to create and verify the
delivery (see below) instead of polling.
createAndWait/waitreturn the terminal record — includingfailedandexpired. They only throwXModePollTimeoutErrorif the wait budget (default 10 min for images, 20 min for videos) elapses first.
Videos
Video models come in two families, and CreateVideoInput is the superset of
both — every field is valid on at least one model and rejected on the others.
client.models.list() is the authority on which model accepts what.
All-in-one (xW3.0s, xW3.0sPro) — the fields you send pick the capability:
none for text-to-video, first_frame/last_frame to pin exact frames, or
reference_images/reference_videos/reference_audios to compose from supplied
material. Keyframes and references cannot be mixed in one request.
const video = await client.videos.createAndWait({
endUserEmail: '[email protected]',
model: 'xW3.0s',
prompt: 'Image 1 walks into the room and sits down, camera slowly pushing in',
reference_images: ['https://your-cdn.example.com/character.png'],
ratio: '16:9',
duration: 10,
// Same model and the same output quality, finished much sooner, at a higher
// per-second rate. Both rates are in client.models.list().
fast: true,
});
if (video.status === 'succeeded') console.log(video.videos[0]?.url);Image-to-video (xW2.7S) — image is required, and prompt_extend defaults
to true, so a seed alone does not reproduce a result unless you turn it off.
const clip = await client.videos.createAndWait({
endUserEmail: '[email protected]',
model: 'xW2.7S',
prompt: 'camera slowly pushing in, gentle motion',
image: 'https://your-cdn.example.com/input.png',
resolution: '1080p',
duration: 5,
});
// Removal is not split by kind — there is no `client.videos.delete`:
await client.generations.delete(clip.requestId);Text
Text generation is the one synchronous endpoint: the answer comes back in the same call, so there is no request to poll and no webhook to register.
const { text, finishReason, cost } = await client.chat.create({
endUserEmail: '[email protected]',
model: 'xTL1.0',
messages: [
{ role: 'system', content: 'You reply with a single paragraph and no preamble.' },
{ role: 'user', content: 'Describe a neon-lit alley after rain.' },
],
maxTokens: 400,
});
if (finishReason === 'truncated') {
// The answer hit `maxTokens`. Raise it, or ask for less.
}Images in
A message's content can be an array of parts instead of a string, and one of
those parts can be an image. This is the shape behind "describe this frame" and
"turn this frame into a video prompt":
const { text } = await client.chat.create({
endUserEmail: '[email protected]',
model: 'xTL1.0',
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'https://cdn.your-product.com/frames/a1b2c3.jpg' } },
{ type: 'text', text: 'Describe this image in detail.' },
],
},
],
maxTokens: 600,
});Parts keep their order on the way to the model, so an image before the instruction reads differently from one after it.
URLs only. The image must be https and reachable from the public internet
without authentication; a data: URI is refused by name, as is a URL carrying
credentials (https://user:pass@…). A redirect is
followed, so a share link or a bucket that points at its CDN works. The API
fetches each URL to check it before the call is charged, so a dead link costs
nothing — it
comes back as XModeValidationError naming the index (Image at index 0 ...)
rather than echoing your URL. Budget a second or two for that check.
The number of images one call may carry is per model, and so is the byte
ceiling: client.models.list() publishes both as maxImagesPerCall and
maxInputBytes on the entry whose type is 'text'. The byte ceiling counts
text only — an image URL's length is free — and the price is flat per call
whichever way you use it.
A full-length answer takes 15-25 seconds. The SDK waits up to 95 seconds for
this endpoint regardless of the client-wide timeout, because that default (30s)
is shorter than the endpoint needs — pass timeout per call to change it.
Billing is per call, not per token. The amount is the same whatever the
length, cost.xTokens says what was charged, and a call that fails is refunded
automatically. Like the other paying creates, this one is retried only on
429 — a retry after a call that actually succeeded would be a second charge.
Two things to know before you port a prompt from elsewhere:
- The input limit is counted in UTF-8 bytes, not characters. Plain English is one byte per character; accented and non-Latin text is two to four.
- Field names are camelCase (
maxTokens,topP), and the endpoint rejects anything it does not recognise instead of ignoring it — so an OpenAI-shapedmax_tokenscomes back as a400naming the key rather than silently doing nothing. Streaming, multiple completions, tool calling and structured-output modes are not supported.
client.models.list() covers text models too, as type: 'text' entries
carrying maxInputBytes, maxImagesPerCall, maxOutputTokens and
defaultOutputTokens. Branch on type with a default — the union has gained a
member once already.
Verifying webhooks
The webhook helper is published at the @xmodeai/sdk/webhooks subpath and uses Web
Crypto, so it runs on Node and every edge runtime. Pass the raw request body
— do not re-serialize the parsed JSON.
import { verifyWebhook } from '@xmodeai/sdk/webhooks';
// Example: a framework that gives you the raw body string.
const { event } = await verifyWebhook({
payload: rawBody,
headers: request.headers,
secret: process.env.XMODE_WEBHOOK_SECRET,
});
if (event.status === 'succeeded') {
// event is the same shape as generations.get / videos.get
}Verification fails (throws WebhookVerificationError with a reason) on a bad
signature, a timestamp outside the 5-minute tolerance, or missing headers.
Pagination
List methods return a value you can await for one page or for await to
stream every item across all pages.
const firstPage = await client.generations.list({ pageSize: 50 });
for await (const generation of client.generations.list({ status: 'failed' })) {
console.log(generation.requestId);
}Model limits
Limits are not uniform across models — they differ in which size presets they
accept and which one is the default, in the window for an explicit WxH, in how
many reference and output images a request may carry, and in whether they can
produce a connected series at all. Read them instead of hard-coding them: the
values come from the same place the API validates against, so they cannot drift
from what a call will actually accept.
const models = await client.models.list();
const m = models.find((x) => x.id === 'xSD5Pro');
if (m?.type === 'image') {
m.sizePresets; // exhaustive; anything else is rejected before any debit
m.defaultSize; // applied when `size` is omitted — differs between models
m.explicitSize; // { minTotalPixels, maxTotalPixels, minAspectRatio, maxAspectRatio, minSide?, maxSide? }
m.maxReferences; // 0 → text-to-image only; `references` is refused
m.maxImagesPerRequest; // ceiling on `n`
m.capabilities.sequential; // false → a series is refused; use `n` or several requests
}Only models you can call right now are returned. The list is short and changes rarely — fetch it once at startup and cache it for the process lifetime.
Entries carry a billing shape (per_image / per_second) but never a rate:
rates change without an SDK release, so live numbers live in the dashboard.
Balance and end-users
const { xTokens } = await client.balance.get();
await client.endUsers.block('[email protected]');
await client.endUsers.unblock('[email protected]');
await client.endUsers.delete('[email protected]'); // 409 ConflictError if jobs are pendingError handling
Every API error is an instance of XModeAPIError, subclassed by the stable
error code:
| Class | Code | HTTP |
|---|---|---|
| ValidationError | validation_error | 400 |
| AuthenticationError | unauthorized, account_blocked | 401 |
| PaymentRequiredError | payment_required | 402 |
| PermissionDeniedError | forbidden | 403 |
| NotFoundError | not_found | 404 |
| ConflictError | conflict | 409 |
| ContentPolicyError | content_policy | 422 |
| RateLimitError | rate_limited | 429 |
| ProviderError | provider_error, provider_timeout | 5xx |
| TemporarilyUnavailableError | temporarily_unavailable | 503 |
| InternalServerError | internal_error | 5xx |
import { RateLimitError, ContentPolicyError } from '@xmodeai/sdk';
try {
await client.generations.create({ endUserEmail, model: 'xSD4.5', prompt });
} catch (err) {
if (err instanceof ContentPolicyError) {/* prompt was rejected */}
else if (err instanceof RateLimitError) {/* back off; err.retryAfter is in seconds */}
else throw err;
}Network failures throw APIConnectionError / APIConnectionTimeoutError. An
unrecognized error code yields a plain XModeAPIError rather than crashing, so a
new server-side code never breaks your handling.
Retries
Retryable requests use exponential backoff with jitter and honor Retry-After.
By default:
- GET and idempotent operations (
block,unblock,delete) retry on network errors,408,429and5xx(defaultmaxRetries: 2). generations.create/videos.createretry only on429. They debit xTokens and have no idempotency key, so a timed-out create is never retried automatically — it might have succeeded server-side.
Using new API features before they're in the SDK
A new model that uses an existing endpoint just works — model accepts any
string. For a brand-new parameter or endpoint that isn't typed yet, use the
low-level escape hatch (same auth, retry and error handling):
await client.request({
method: 'POST',
path: '/v1/videos',
body: { endUserEmail, model: 'xSV2.0', prompt, image, cameraMotion: 'orbit' },
});Configuration
new XMode({
apiKey: '...', // default: process.env.XMODE_API_KEY
baseUrl: '...', // default: https://api.xmode.ai
timeout: 30_000, // per-attempt timeout in ms
maxRetries: 2, // automatic retries for retryable requests
fetch: customFetch, // custom fetch (tests / older runtimes)
defaultHeaders: { ... }, // sent with every request
});A custom
baseUrlreceives yourAuthorization: Bearerkey on every request — only point it at an xMode-operated host.
Runtime support
Node 18.17+, Bun, Deno, Cloudflare Workers and other fetch-capable runtimes.
The SDK can run in a browser, but doing so exposes your API key — keep keys
server-side.
Versioning
Pre-1.0, breaking changes bump the minor version and additive changes bump the patch version. From 1.0.0 onward the SDK follows standard semver.
License
MIT
