Recommended integration
Let editors choose artwork in your CMS.
The hosted Picker handles browsing, image versions and selection. You add the entry point, connect two backend routes, and decide how the chosen artwork is saved.
How it works
The SDK opens the modal or popup, checks messages from the Picker and calls your backend to redeem the selection. The temporary picker_url and selection_code pass through your browser callbacks or bridge requests. Keep them out of saved content and logs. Your tenant token stays on the backend.
Editors browse lightweight thumbnails, then opening a result loads its complete description, rights, classification and available-version details together with a watermarked high-resolution detail image.
New to this flow? Follow the quickstart first. It includes the button, CSRF header, cancellation and error handling. Examples here use the Test environment too.
Browser SDK
Load the SDK once on your editor page, then create one client. In a TypeScript project, include the type declarations as well. The two endpoint URLs below are routes you build in your CMS.
<script src="https://collection-test.vieunite.com/sdk/v1/picker.js"></script>
<script>
const picker = VieunitePicker.create({
sessionEndpoint: "/api/vieunite/picker/session",
redeemEndpoint: "/api/vieunite/picker/redeem",
pickerOrigin: "https://collection-test.vieunite.com",
presentation: "modal",
});
</script>
const picker: VieunitePicker.PickerClient = VieunitePicker.create({
sessionEndpoint: "/api/vieunite/picker/session",
redeemEndpoint: "/api/vieunite/picker/redeem",
pickerOrigin: "https://collection-test.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.
The SDK sends same-origin cookies by default. Use headers for your CMS CSRF header, as in the quickstart. If you already have an authenticated API client, supply createSession(request) and redeemSelection(request) callbacks instead of endpoint URLs.
Open options
Call open() from the editor’s button handler. This example allows multiple image selections and starts with portrait and square results. Editors can change those browsing filters.
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 in single mode; 0 in multiple mode | Set 1–100 for a specific cap. 0 removes the session cap in multiple mode; completion still accepts at most 1,000 references. Your backend may set a lower cap. |
initialFilters | {} | Starting search filters. They can be changed by the editor and do not restrict access. |
expiresIn | 600 | Session lifetime from 60 to 900 seconds. |
signal | None | An optional AbortSignal. |
These are SDK defaults. If you call POST /v1/picker/sessions directly, max_selection defaults to 1 even in multiple mode. Send 0 or an explicit limit in your backend request.
Result
A selected result means your backend has successfully redeemed the selection. Save it through your normal content workflow. A cancelled result means the editor made no new selection; keep their existing content unchanged.
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 returns cancelled; a request failure rejects the Promise and belongs in your catch handler. New artwork references include the chosen rendition; collection references have no rendition.
What your backend returns to the SDK
Your routes return JSON with a data object, as shown below. The SDK also accepts the inner object directly. Return an error status when a request fails; a login redirect or HTML error page is not a successful bridge response.
{
"data": {
"session_id": "pks_12345",
"picker_url": "https://collection-test.vieunite.com/picker/launch#code=<temporary-launch-code>",
"expires_at": "2026-09-16T12:10:00Z"
}
}
{
"data": {
"session_id": "pks_12345",
"selection": [{
"provider": "vieunite-art-collection",
"type": "artwork",
"id": "art_456",
"rendition": "crop"
}]
}
}
Return the session ID and launch URL exactly as Vieunite issued them. These example values are placeholders. For redemption, the SDK submits { session_id, selection_code }; your backend checks session ownership and exchanges that code for the selection array.
CMS backend bridge
The examples below show the two Vieunite requests in JavaScript, TypeScript, Python and PHP. They are integration skeletons: plug them into your existing routes, authentication, database and error handling. They won’t run until the CMS helpers below are implemented.
| CMS code to supply | Required behaviour |
|---|---|
requireEditor / require_editor | Require a signed-in editor with permission to change the current content. Laravel uses your existing auth middleware plus the content permission check. |
requireCsrf / require_csrf | Verify your CMS CSRF token on both routes. Laravel’s web middleware supplies its usual CSRF check. |
bindSession, bind_session_owner or PickerSessionOwner | Store the session ID, expiry, editor, organisation and content context in your database before returning the launch URL. |
assertSessionOwner / assert_session_owner | Look up that stored session and verify the same editor, organisation and content permission. Laravel shows the equivalent model query. |
| Request validation and error handler | Validate session and code fields. Map upstream failures to an appropriate HTTP error, retaining X-Request-ID for diagnostics without exposing credentials. |
For brevity, the ownership calls below show the editor ID. Extend them with your organisation and content context. The backend fixes allowed types, filters and expiry in this example; it does not forward every browser option. It also sets host_capabilities, which is never accepted from browser input.
const API = "https://collection-test.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-test.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-test.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-test.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
Catch VieunitePicker.PickerError and inspect code. Start with the failing CMS request in your browser’s Network panel: session_request_failed points to the session route, while redemption_failed points to the redemption route.
invalid_configinvalid_optionsbrowser_token_forbiddenpopup_blockedinvalid_picker_originsession_request_failedinvalid_sessionredemption_failedinvalid_redemptionpicker_timeoutpicker_destroyed
For invalid_picker_origin, compare the launch URL’s origin with pickerOrigin. For picker_timeout, let the editor start a fresh session. The SDK ignores messages from an unexpected origin, window or session. Log the error code and server request ID; keep launch URLs and selection codes out of logs.