Recommended integration
Add the hosted Picker.
Vieunite provides the discovery and selection experience. Your CMS controls editor authentication, tenant credentials, persistence and rendering.
How it works
The browser SDK owns the modal or popup, source validation, message protocol, cancellation and one-time capability handling. Application code never receives the launch or selection capability.
Browser SDK
Load the major-version URL and create one reusable client:
<script src="https://collection.vieunite.com/sdk/v1/picker.js"></script>
const picker = VieunitePicker.create({
sessionEndpoint: "/api/vieunite/picker/session",
redeemEndpoint: "/api/vieunite/picker/redeem",
pickerOrigin: "https://collection.vieunite.com",
presentation: "modal",
});
const picker: VieunitePicker.PickerClient = VieunitePicker.create({
sessionEndpoint: "/api/vieunite/picker/session",
redeemEndpoint: "/api/vieunite/picker/redeem",
pickerOrigin: "https://collection.vieunite.com",
presentation: "modal",
});
pickerOrigin is the exact trusted origin allowed to provide picker_url. It defaults to the SDK script origin; set it explicitly when the script is self-hosted.
Open options
const result = await picker.open({
allowedTypes: ["artwork"],
selectionMode: "multiple",
maxSelection: 0,
initialFilters: {
resource_type: ["image"],
orientations: ["portrait", "square"],
},
expiresIn: 600,
});
const options: VieunitePicker.OpenOptions = {
allowedTypes: ["artwork"],
selectionMode: "multiple",
maxSelection: 0,
initialFilters: {
resource_type: ["image"],
orientations: ["portrait", "square"],
},
expiresIn: 600,
};
const result: VieunitePicker.PickerResult = await picker.open(options);
| Option | Default | Meaning |
|---|---|---|
allowedTypes | ["artwork"] | artwork, collection, or both. |
selectionMode | "single" | Single or multiple selection. |
maxSelection | 1 | 0 means unlimited in multiple mode, subject to the completion hard limit. |
initialFilters | {} | Initial discovery state only—not an authorization constraint. |
expiresIn | 600 | Session lifetime from 60 to 900 seconds. |
signal | None | An optional AbortSignal. |
Result
open() resolves only after your CMS backend successfully redeems the selection.
const result = {
status: "selected",
protocolVersion: 2,
sessionId: "pks_12345",
selection: [{
provider: "vieunite-art-collection",
type: "artwork",
id: "art_456",
rendition: "crop"
}]
};
const result: VieunitePicker.SelectedResult = {
status: "selected",
protocolVersion: 2,
sessionId: "pks_12345",
selection: [{
provider: "vieunite-art-collection",
type: "artwork",
id: "art_456",
rendition: "crop"
}]
};
const result = {
status: "cancelled",
reason: "user_closed",
sessionId: "pks_12345",
selection: []
};
const result: VieunitePicker.CancelledResult = {
status: "cancelled",
reason: "user_closed",
sessionId: "pks_12345",
selection: []
};
Closing the Picker is a normal cancelled result, not an exception. An artwork reference always includes its selected rendition; a collection reference never does.
CMS backend bridge
The endpoints below live on your CMS origin. They authenticate the editor, enforce CSRF, constrain request options and call Vieunite with the tenant token. The browser request deliberately has no host_capabilities; the trusted CMS backend adds that immutable declaration.
const API = "https://collection.vieunite.com";
const TOKEN = process.env.VIEUNITE_TENANT_TOKEN;
const CMS_ORIGIN = new URL(process.env.CMS_PUBLIC_URL).origin;
// Server-only helper. TOKEN must never be returned to browser code.
async function vieunite(path, body, headers = {}) {
const response = await fetch(`${API}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok) throw Object.assign(new Error(payload.detail), {
status: response.status,
requestId: response.headers.get("X-Request-ID"),
});
return payload;
}
// requireEditor verifies the CMS login; requireCsrf verifies a CMS CSRF token.
app.post("/api/vieunite/picker/session", requireEditor, requireCsrf,
async (req, res) => {
// Allowlist browser input. Never proxy req.body directly to Vieunite.
const selectionMode = req.body.selection_mode === "multiple"
? "multiple" : "single";
const payload = await vieunite("/v1/picker/sessions", {
allowed_types: ["artwork"],
selection_mode: selectionMode,
max_selection: selectionMode === "multiple" ? 0 : 1,
default_filters: {
resource_type: ["image"],
orientations: ["portrait", "square"],
},
host_capabilities: { credit_display: false },
expires_in: 600,
callback_origin: CMS_ORIGIN,
});
// Persist the session-to-editor binding in your CMS database.
await bindSession(payload.data.session_id, req.user.id);
res.json(payload);
});
app.post("/api/vieunite/picker/redeem", requireEditor, requireCsrf,
async (req, res) => {
// Reject redemption unless this editor owns the Picker session.
await assertSessionOwner(req.body.session_id, req.user.id);
const payload = await vieunite(
"/v1/picker/selections/redeem",
{
session_id: req.body.session_id,
selection_code: req.body.selection_code,
},
{ "Idempotency-Key": `picker:${req.body.session_id}` },
);
res.json(payload);
});
import type { Request, Response } from "express";
const API = "https://collection.vieunite.com";
const TOKEN = process.env.VIEUNITE_TENANT_TOKEN!;
const CMS_ORIGIN = new URL(process.env.CMS_PUBLIC_URL!).origin;
type EditorRequest = Request & {
user: { id: string };
body: Record<string, unknown>;
};
// Server-only helper. TOKEN must never be returned to browser code.
async function vieunite<T>(
path: string,
body: unknown,
headers: Record<string, string> = {},
): Promise<T> {
const response = await fetch(`${API}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
const payload = await response.json() as T & { detail?: string };
if (!response.ok) throw Object.assign(new Error(payload.detail), {
status: response.status,
requestId: response.headers.get("X-Request-ID"),
});
return payload;
}
// requireEditor verifies the CMS login; requireCsrf verifies a CMS CSRF token.
app.post("/api/vieunite/picker/session", requireEditor, requireCsrf,
async (req: EditorRequest, res: Response) => {
// Allowlist browser input. Never proxy req.body directly to Vieunite.
const selectionMode: VieunitePicker.SelectionMode =
req.body.selection_mode === "multiple" ? "multiple" : "single";
const body: VieunitePicker.PickerApiSessionCreateRequest = {
allowed_types: ["artwork"],
selection_mode: selectionMode,
max_selection: selectionMode === "multiple" ? 0 : 1,
default_filters: {
resource_type: ["image"],
orientations: ["portrait", "square"],
},
host_capabilities: { credit_display: false },
expires_in: 600,
callback_origin: CMS_ORIGIN,
};
const payload = await vieunite<{
data: VieunitePicker.PickerSessionCreated;
}>("/v1/picker/sessions", body);
// Persist the session-to-editor binding in your CMS database.
await bindSession(payload.data.session_id, req.user.id);
res.json(payload);
});
app.post("/api/vieunite/picker/redeem", requireEditor, requireCsrf,
async (req: EditorRequest, res: Response) => {
const sessionId = String(req.body.session_id);
// Reject redemption unless this editor owns the Picker session.
await assertSessionOwner(sessionId, req.user.id);
const body: VieunitePicker.PickerRedemptionRequest = {
session_id: sessionId,
selection_code: String(req.body.selection_code),
};
const payload = await vieunite(
"/v1/picker/selections/redeem",
body,
{ "Idempotency-Key": `picker:${sessionId}` },
);
res.json(payload);
});
from os import environ
from urllib.parse import urlsplit
import httpx
from fastapi import Depends, FastAPI, Request
app = FastAPI()
API = "https://collection.vieunite.com"
TOKEN = environ.get("VIEUNITE_TENANT_TOKEN", "")
cms_url = urlsplit(environ.get("CMS_PUBLIC_URL", ""))
CMS_ORIGIN = f"{cms_url.scheme}://{cms_url.netloc}"
# Server-only helper. TOKEN must never be returned to browser code.
async def vieunite(path: str, body: dict, headers: dict | None = None) -> dict:
async with httpx.AsyncClient(base_url=API) as client:
response = await client.post(
path,
json=body,
headers={
"Authorization": f"Bearer {TOKEN}",
**(headers or {}),
},
)
response.raise_for_status()
return response.json()
# Dependencies verify the CMS editor login and a CMS CSRF token.
@app.post("/api/vieunite/picker/session")
async def create_picker_session(
request: Request,
editor=Depends(require_editor),
_csrf=Depends(require_csrf),
) -> dict:
browser_input = await request.json()
# Allowlist browser input. Never proxy browser_input directly.
mode = "multiple" if browser_input.get("selection_mode") == "multiple" else "single"
payload = await vieunite("/v1/picker/sessions", {
"allowed_types": ["artwork"],
"selection_mode": mode,
"max_selection": 0 if mode == "multiple" else 1,
"default_filters": {
"resource_type": ["image"],
"orientations": ["portrait", "square"],
},
"host_capabilities": {"credit_display": False},
"expires_in": 600,
"callback_origin": CMS_ORIGIN,
})
session = payload.get("data", payload)
# Persist the session-to-editor binding in your CMS database.
await bind_session_owner(session["session_id"], editor.id)
return payload
@app.post("/api/vieunite/picker/redeem")
async def redeem_picker_selection(
request: Request,
editor=Depends(require_editor),
_csrf=Depends(require_csrf),
) -> dict:
browser_input = await request.json()
session_id = str(browser_input["session_id"])
# Reject redemption unless this editor owns the Picker session.
await assert_session_owner(session_id, editor.id)
return await vieunite(
"/v1/picker/selections/redeem",
{
"session_id": session_id,
"selection_code": str(browser_input["selection_code"]),
},
{"Idempotency-Key": f"picker:{session_id}"},
)
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
$api = "https://collection.vieunite.com";
$token = (string) env("VIEUNITE_TENANT_TOKEN");
$parts = parse_url((string) config("app.url"));
$port = isset($parts["port"]) ? ":" . $parts["port"] : "";
$cmsOrigin = $parts["scheme"] . "://" . $parts["host"] . $port;
// Server-only helper. $token must never be returned to browser code.
$vieunite = function (string $path, array $body, array $headers = [])
use ($api, $token): array {
return Http::withToken($token)
->acceptJson()
->withHeaders($headers)
->post("{$api}{$path}", $body)
->throw()
->json();
};
// "web" verifies CSRF; "auth" verifies the CMS editor session.
Route::middleware(["web", "auth"])->post(
"/api/vieunite/picker/session",
function (Request $request) use ($vieunite, $cmsOrigin) {
// Allowlist browser input. Never forward $request->all().
$mode = $request->input("selection_mode") === "multiple"
? "multiple" : "single";
$payload = $vieunite("/v1/picker/sessions", [
"allowed_types" => ["artwork"],
"selection_mode" => $mode,
"max_selection" => $mode === "multiple" ? 0 : 1,
"default_filters" => [
"resource_type" => ["image"],
"orientations" => ["portrait", "square"],
],
"host_capabilities" => ["credit_display" => false],
"expires_in" => 600,
"callback_origin" => $cmsOrigin,
]);
$session = $payload["data"] ?? $payload;
// Persist the session-to-editor binding in your CMS database.
PickerSessionOwner::updateOrCreate(
["session_id" => $session["session_id"]],
["editor_id" => $request->user()->id],
);
return response()->json($payload);
},
);
Route::middleware(["web", "auth"])->post(
"/api/vieunite/picker/redeem",
function (Request $request) use ($vieunite) {
$sessionId = (string) $request->input("session_id");
// Reject redemption unless this editor owns the Picker session.
PickerSessionOwner::where("session_id", $sessionId)
->where("editor_id", $request->user()->id)
->firstOrFail();
$payload = $vieunite(
"/v1/picker/selections/redeem",
[
"session_id" => $sessionId,
"selection_code" => (string) $request->input("selection_code"),
],
["Idempotency-Key" => "picker:{$sessionId}"],
);
return response()->json($payload);
},
);
Orientation and credit policy
default_filters.orientations accepts portrait, landscape and square. Multiple values are ORed; omitted, null and [] mean All. The Picker user can change this browsing filter without losing the current selection.
Orientation is calculated from each rendition's delivery dimensions. A rendition is square when abs(width - height) / max(width, height) <= 0.03; source artwork and Preview thumbnail dimensions are not used. The Picker labels its direction-matching choice Recommended version, not “Best Fit”, because no target size or fit mode was supplied.
Presentation and lifecycle
The default modal presentation is responsive, keyboard accessible and isolated with Shadow DOM. Set presentation: "popup" only when a separate browser window fits an existing CMS workflow better.
picker.open(options)Open or focus the active flow. Repeated calls share the same Promise.picker.focus()Focus the active modal or popup.picker.close()picker.cancel()Resolve the active flow as cancelled.picker.destroy()Remove listeners, timers and DOM during permanent page teardown.picker.on(name, handler)Subscribe tostatechange,complete,cancelorerror.
Picker errors
Operational failures reject with VieunitePicker.PickerError and a stable code.
invalid_configinvalid_optionsbrowser_token_forbiddenpopup_blockedinvalid_picker_originsession_request_failedinvalid_sessionredemption_failedinvalid_redemptionpicker_timeoutpicker_destroyed
Unknown sources, origins, event names, protocol versions and session IDs are ignored. Log the SDK error code and related server X-Request-ID, but never capability values.