Vyso Blog
Building workflows with the Vyso API
A custom asset workflow uses authenticated requests to manage the library and image URLs to serve representations. Upload a source image, add descriptive metadata, assign it to a collection, then save its delivery URL in your application. Publishing a page or updating an external system remains a separate operation.
Vyso's management API and delivery system have different jobs. Keeping that boundary clear avoids building an integration around rendition objects or publishing events that the API does not expose.
Confirm the deployment addresses first
The Developer API reference specifies https://api.vyso.io/v1 for management. The delivery reference specifies https://cdn.vyso.io for image URLs.
Use these public base URLs for the examples:
export VYSO_API_BASE_URL="https://api.vyso.io/v1"
export VYSO_DELIVERY_BASE_URL="https://cdn.vyso.io"
Authenticate with a Clerk session token
Protected management requests send Authorization: Bearer followed by a Clerk session token. The authentication reference does not define a separate Vyso API-key flow.
Obtain the token from an active session in an application using Vyso's Clerk configuration. Clerk's client SDK provides getToken(), through useAuth() or the initialized Clerk.session object. Clerk's authenticated-request guide explains how to attach it to a request.
For a short manual run, supply the current token through VYSO_SESSION_TOKEN. Keep it out of source files and logs. A saved session token is not a permanent credential for a scheduled job; a longer-running integration needs the supported session and token-refresh flow.
curl --fail-with-body --silent --show-error \
"${VYSO_API_BASE_URL:?Set the public API base}/assets?limit=1" \
-H "Authorization: Bearer ${VYSO_SESSION_TOKEN:?Set a current Clerk session token}"
The API constrains management operations to resources available to the authenticated identity. That scope does not describe a granular role system or an asset's permitted commercial uses. Public image delivery is a separate route and does not use the management bearer token.
Upload, describe, organize and deliver one image
The example uses a local JPEG named hero.jpg. It finds or creates a collection, uploads the file, applies metadata, assigns the asset, retrieves it, and creates or reuses a matching delivery preset. Run it as a Node.js module with native fetch, FormData and Blob support.
The upload endpoint accepts one binary multipart part named file. Let FormData set the multipart Content-Type and boundary. Optional fields include collectionId and warnAboutDuplicateImages; this example assigns the collection afterward so the separate update is visible.
import { readFile } from "node:fs/promises";
const apiBase = process.env.VYSO_API_BASE_URL?.replace(/\/+$/, "");
const deliveryBase = process.env.VYSO_DELIVERY_BASE_URL?.replace(/\/+$/, "");
const token = process.env.VYSO_SESSION_TOKEN;
if (!apiBase || !deliveryBase || !token) {
throw new Error("Set the API base, delivery base and current session token.");
}
async function request(path, options = {}) {
const headers = new Headers(options.headers);
headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${apiBase}${path}`, { ...options, headers });
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`${response.status}: ${body?.error ?? response.statusText}`);
}
return body;
}
const { collections } = await request("/asset-collections");
const collection = collections.find(item => item.name === "Autumn 2026")
?? await request("/asset-collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Autumn 2026" })
});
const form = new FormData();
form.append("file", new Blob([await readFile("hero.jpg")], {
type: "image/jpeg"
}), "hero.jpg");
const upload = await request("/assets/upload", { method: "POST", body: form });
if (!upload?.asset?.id || !upload.asset.key) {
throw new Error("Upload did not return an asset ID and key.");
}
const assetPath = `/assets/${encodeURIComponent(upload.asset.id)}`;
await request(assetPath, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userTitle: "Autumn campaign hero",
userDescription: "Hero image for the autumn landing page.",
userTags: ["autumn-2026", "hero"]
})
});
await request(assetPath, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collectionId: collection.id })
});
const { asset } = await request(assetPath);
const query = new URLSearchParams({
collectionId: collection.id,
assetType: "images",
search: "Autumn",
sort: "newest",
limit: "20"
});
const page = await request(`/assets?${query}`);
const config = { steps: [
{ key: "resize", params: { width: 1200 } },
{ key: "format", params: { type: "webp" } }
] };
const slug = "campaign-hero-1200";
const { presets } = await request("/delivery/presets");
let preset = presets.find(item => item.slug === slug);
if (preset && JSON.stringify(preset.config) !== JSON.stringify(config)) {
throw new Error("Existing preset has different settings. Review it first.");
}
if (!preset) {
preset = await request("/delivery/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Campaign hero 1200", slug, config })
});
}
const key = encodeURIComponent(asset.key);
const presetUrl = `${deliveryBase}/preset/${encodeURIComponent(preset.slug)}/${key}`;
const directUrl = `${deliveryBase}/resize:width=1200/format:type=webp/${key}`;
console.log(JSON.stringify({
assetId: asset.id,
assetKey: asset.key,
collectionId: collection.id,
matchingAssetsOnPage: page.count,
presetUrl,
directUrl
}, null, 2));
The printed object is your application's record of the integration, not a Vyso API response schema. Save the asset ID and key with the consuming application's content record. Use the chosen image URL in its template or component, with alternative text appropriate to that placement.
This is a starting point with checked HTTP responses and no automatic retries. It creates a new asset on each successful upload. If it stops partway through, inspect the completed operations before running the upload again. Collection and preset names can also race with another process creating the same resource.
Read the response you actually received
A successful new upload returns asset, including asset.id and asset.key. Use the ID for management requests. Use the returned key, including its source extension, for image delivery. Do not construct a key from the original filename.
A stored file does not mean all generated metadata or indexed search data is ready. Retrieve the asset by ID when your next step needs its current stored fields.
If duplicate warnings are enabled, a detected duplicate can instead return skipped: true and skipMessage, without an asset object. HTTP 200 alone is therefore insufficient to decide whether a new asset was created.
Metadata updates use PATCH /v1/assets/:assetId in the public reference. The example uses the exact fields userTitle, userDescription and userTags. They record descriptive context; they are not structured license or approval fields.
Send collection assignment as its own PATCH body containing collectionId. Collections organize assets through their identifiers; the workflow does not depend on folders or folder paths.
Retrieval returns an asset wrapper. The example retrieves the asset after the updates to read its current stored fields.
Find assets and follow real pagination
The workflow's list request filters by collectionId, selects assetType=images, searches for Autumn, and sorts with sort=newest. These are supported parameters of the assets endpoint.
The list response contains assets, count, total, offset, limit and nextCursor. Count describes the returned page; total describes the matching set. Continue with the returned cursor until it is null. This continuation uses the example's existing request helper and collection:
const query = new URLSearchParams({
collectionId: collection.id,
assetType: "images",
sort: "newest",
limit: "20"
});
let nextCursor;
do {
if (nextCursor) query.set("cursor", nextCursor);
const page = await request(`/assets?${query}`);
console.log(page.assets.map(asset => ({ id: asset.id, key: asset.key })));
nextCursor = page.nextCursor;
} while (nextCursor);
Keep filters and sorting unchanged between pages. Offset pagination is also supported through offset and limit; choose one approach for a traversal. Concurrent library changes can affect a traversal, so a synchronization job should track processed IDs in its own system.
The list's search filter matches stored text fields such as filenames and titles. For indexed library search, the public search reference also exposes GET /v1/assets/search/full-text?q=autumn. Its results depend on indexed data. Do not assume it uses the list endpoint's pagination contract or poll an invented approval status.
Choose direct transformations or a preset
A direct delivery URL encodes the transformation chain and source key. The example constructs a resize:width=1200 step followed by format:type=webp. The operations run in that order.
Resizing with width alone preserves aspect ratio. Enlargement is disabled by default, so a smaller source is not guaranteed to become 1200 pixels wide. The explicit format step requests WebP output while the final path component remains the original asset key. Renaming that key's extension would change the source lookup, not select an output encoder. The response Content-Type identifies the delivered format.
A preset stores an ordered config.steps array. Each step has key and params, validated against the transformation schemas. The example's authenticated preset-management requests create a named configuration, then construct a public image URL using its slug.
Use direct URLs when dimensions vary by request. Use a preset when several application placements should share a configuration. Presets resolve within the source asset's account, so a preset from an unrelated account is not a substitute for the one belonging to that asset.
Updating a preset uses PATCH /v1/delivery/presets/:presetId in the public reference. A new origin request can use its new configuration, but an existing delivery URL can remain cached.
For a controlled rollout, create a new preset slug, check the returned image, then update the consuming application's reference. When a source changes, a new asset key also gives the representation a different URL. Inspect delivery headers and follow the caching guidance instead of assuming a deployment-independent cache lifetime.
Handle failed requests without duplicating work
The error reference describes JSON errors and HTTP statuses. Management errors commonly contain an error string, such as {"error":"Asset not found."}. Delivery errors can contain an errors array. Preserve the status and inspect the response body; do not force both services into one invented error schema.
400: inspect the validation or operation error. Correct the request instead of repeating it unchanged.401: obtain a valid session token through the supported authentication flow.404: check the identifier and whether the resource is available to the authenticated identity.409: resolve a conflict, for example a duplicate collection name.429: wait for the returned Retry-After interval before another eligible request.5xx: investigate the server failure. Use bounded retries with backoff for safe reads when the failure is transient.
Uploads can also fail with 413 for an oversized file or 415 for the wrong content type. Deployment limits and storage availability still apply to a correctly formed request.
A timeout or failed write response does not prove that nothing changed. Retrieve the asset to check the stored fields before deciding to repeat the write. For an upload whose response was lost, reconcile the library and your operation record first; blindly retrying can create another asset.
Respect the configured rate limit
The documented general default is 120 requests per 60 seconds per limiter key. The implementation keys its in-memory buckets by client IP, HTTP method and route. Deployment configuration can change that default, and uploads and media delivery use separate buckets.
A throttled request returns 429 with Retry-After in seconds. Queue work in the integration and wait for that interval. The HTTP header can have other forms in other systems, as described by MDN's Retry-After reference; Vyso's current limiter emits a seconds value. Waiting does not resolve uncertainty about a previous write, so reconcile that write before resubmitting it.
Where your integration takes over
Your application chooses which asset belongs in a content record, stores that relationship, and calls the destination platform when content must be published. Updating a CMS entry, publishing a social post or writing a marketplace listing requires that platform's own API and credentials.
Trigger Vyso requests from events your application controls, or run a scheduled reconciliation against fields the API actually exposes. Track completed operations and asset IDs in your own system. The workflow does not depend on an outgoing asset webhook or an approval event.
A website that references a remote delivery URL and a platform that imports its own copy have different update behavior. Changing the source or preset does not replace an uploaded downstream copy. The guide to automated media distribution explains that boundary across web, CMS, email and social publishing.
Keep going
Make the media library less work.
See how Vyso brings search, enrichment, cleanup, and delivery into one product.
Start free