Arrival Space File Upload API
Overview
The integration allows third-party applications to upload assets directly to a user's Arrival Space. This is just the beginning — we will be adding more functionality to the API in future updates.
Make sure you have enough available storage left before starting the upload. Otherwise, the upload may fail.
Flow Summary
- Obtain an API key:
- Option A: User generates an API key on Arrival Space (for production use)
- Option B: Generate an anonymous API key via
/auth/generate-anonymous-key - Option C: Use OAuth 2.0 login flow — user signs in via browser popup, your app receives an API key automatically (recommended for native apps)
- Third Party uses the key to upload files on behalf of that user
- After uploading to S3, confirm the upload to receive the resource key
- For zip files or files over 1.5GB: poll the job status endpoint with real-time progress updates until processing completes
- For small files: receive the resource key immediately
- Use the resource key to create a new space in Arrival Space
- Note: Spaces created with anonymous keys include a
claimparameter in the URL for later claiming to a real account
- Note: Spaces created with anonymous keys include a
- Manage entities within a space — create, list, read, update, and delete entities (3D models, objects, etc.)
Configuration
const API_BASE_URL = "https://api-staging.arrival.space/api";
const VERSION = "v1";
All endpoints should be prefixed with ${API_BASE_URL}/${VERSION}.
The API endpoint may change in the future. If you experience any connection issues, please reach out to our support team.
Authentication
All API requests require a Bearer token in the Authorization header:
Authorization: Bearer <api_key>
Obtaining an API Key
There are two ways to obtain an API key:
Option 1: User Account API Key
- Log in to Arrival Space
- Navigate to your account settings
- Generate or Copy the previously generated API key
Option 2: Anonymous API Key
Generate a temporary anonymous API key without requiring user registration.
GET /auth/generate-anonymous-key
No authentication required.
Response:
{
"status": "ok",
"data": {
"apiKey": "a1b2c3d4e5f6..."
}
}
When creating a space with an anonymous API key, the space URL will include a claim parameter. Visiting this URL will:
- If logged in: Automatically claim the space to your account
- If not logged in: Prompt you to log in, then claim the space to your account
This allows anonymous users to create spaces that can later be claimed by real accounts.
Example:
// Generate anonymous key
const response = await fetch(`${API_BASE_URL}/${VERSION}/auth/generate-anonymous-key`);
const { data } = await response.json();
const apiKey = data.apiKey;
// Use the key for API requests
// Spaces created with this key will have a claim parameter in the URL
Option 3: OAuth 2.0 Login (for native apps)
Native applications can use OAuth 2.0 Authorization Code flow with PKCE to let users log in via their Arrival.Space account (Google sign-in or email/password). This is the recommended approach for third-party integrations where users should not have to manually copy API keys.
The OAuth flow uses standard endpoints and returns an API key as the access token — the same key visible in the user's account settings. This means the token survives server restarts and works identically across all API endpoints.
OAuth Endpoints:
| Endpoint | Description |
|---|---|
GET /.well-known/oauth-authorization-server | OAuth 2.0 server metadata (auto-discovery) |
POST /register | Dynamic Client Registration (RFC 7591) |
GET /authorize | Authorization endpoint — redirects to login page |
POST /token | Token exchange — returns the access token |
POST /revoke | Token revocation |
Flow Overview:
-
Register your client (one-time, or on first launch):
const regResponse = await fetch(`${API_BASE_URL}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "My App",
redirect_uris: ["https://myapp.example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none"
})
});
const client = await regResponse.json();
// Save client.client_id for future use -
Generate PKCE challenge and redirect user to authorize:
// Generate PKCE code verifier + challenge
const codeVerifier = generateRandomString(64);
const codeChallenge = base64url(sha256(codeVerifier));
// Open browser to:
const authUrl = new URL(`${API_BASE_URL}/authorize`);
authUrl.searchParams.set("client_id", client.client_id);
authUrl.searchParams.set("redirect_uri", "https://myapp.example.com/callback");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", generateRandomString(32));The user sees the Arrival.Space login page with Google sign-in and email/password options.
-
Handle the callback — after login, the browser redirects to your
redirect_uriwith?code=...&state=...:// Your callback handler receives: code, state
const tokenResponse = await fetch(`${API_BASE_URL}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: "https://myapp.example.com/callback",
client_id: client.client_id,
code_verifier: codeVerifier
})
});
const tokens = await tokenResponse.json();
const apiKey = tokens.access_token; -
Use the token — it's a standard API key, use it like any other:
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
- The
access_tokenreturned by the OAuth flow is the user's actual API key — the same one shown in their account settings - Tokens do not expire (no
expires_inlimit) — they persist until the user revokes the key - No refresh token is needed since the access token does not expire
- If the user already has an API key, the existing key is returned (no duplicates created)
If your server sits behind a reverse proxy (Apache, nginx), make sure /.well-known/oauth-authorization-server is forwarded to the Node.js backend. If discovery is unavailable, clients can use the endpoints directly: /authorize, /token, /register.
The OAuth endpoints are also compatible with the Model Context Protocol (MCP). MCP clients like Claude Desktop can connect directly using https://api-dev.arrival.space/api/v1/mcp — the OAuth flow is handled automatically by the MCP client.
Endpoints
1. Request Upload URL
POST /files/upload
Generates a presigned S3 URL for file upload.
Request
Headers:
const headers = {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
};
Body:
const body = {
file_name: "example.zip", // Name of the file
file_size: 1048576000, // File size in bytes
content_type: "application/zip" // MIME type
};
Response
{
"status": "ok",
"data": {
"upload_type": "presigned_url",
"params": {
"url": "https://s3.amazonaws.com/bucket/...?X-Amz-Signature=...",
"method": "PUT",
"headers": { "Content-Type": "application/zip" }
}
}
}
Upload to S3
Once you receive the presigned URL, use the returned params to upload the file directly to S3:
fetch(params.url, {
method: params.method,
headers: params.headers,
body: file,
});
2. Confirm Upload
POST /files/upload-complete
Call this after the file has been successfully uploaded to S3. Confirms the upload is complete and returns the resource key you can later use to create a space or other entities.
For .zip files or files over 1.5GB, this endpoint returns a job ID instead of the resource key directly. You must poll the job status endpoint to get the final resource_key once processing completes. See Get Job Status for details.
Request
Headers:
const headers = {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
};
Body:
const body = {
status: "success",
extra_info: {
file_url: "https://s3.amazonaws.com/bucket/uploads/example.zip"
}
};
Response (Small Files)
For files under 1.5GB (non-zip), returns the resource key immediately:
{
"status": "confirmed",
"data": {
"resource_key": "api-uploads/...ply"
}
}
Response (Large Files - Async Processing)
For .zip files or files over 1.5GB, returns a job ID for polling:
{
"status": "processing",
"message": "Large file queued for processing",
"data": {
"job_id": "a1b2c3d4e5f6...",
"poll_url": "/api/v1/jobs/a1b2c3d4e5f6..."
}
}
| Field | Description |
|---|---|
status | "processing" indicates async job started |
job_id | Unique identifier to poll for job status |
poll_url | Convenience URL for polling |
3. Get Job Status
GET /jobs/:jobId
Poll this endpoint to check the status of an async job (e.g., zip file processing or large file copying). The endpoint provides real-time progress updates. Continue polling until job_status is "completed" or "failed".
Request
Headers:
const headers = {
Authorization: `Bearer ${api_key}`
};
URL Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
jobId | string | Yes | The job ID returned from upload-complete |
Response
{
"status": "ok",
"data": {
"job_id": "a1b2c3d4e5f6...",
"job_status": "processing",
"progress": 45,
"message": "Copying file... 45% (2.3 GB / 5.0 GB)",
"result": null,
"error": null,
"created_at": "2025-01-15T10:30:00.000Z",
"updated_at": "2025-01-15T10:31:30.000Z"
}
}
Completed Job Example:
{
"status": "ok",
"data": {
"job_id": "a1b2c3d4e5f6...",
"job_status": "completed",
"progress": 100,
"message": "File copied successfully",
"result": {
"resource_key": "api-uploads/..."
},
"error": null,
"created_at": "2025-01-15T10:30:00.000Z",
"updated_at": "2025-01-15T10:32:15.000Z"
}
}
Job Status Values
| Status | Description |
|---|---|
queued | Job is waiting to be processed |
processing | Job is currently being processed |
completed | Job finished successfully — result.resource_key is available |
failed | Job failed — check error field for details |
Response Fields
| Field | Type | Description |
|---|---|---|
job_id | string | Unique job identifier |
job_status | string | Current status of the job |
progress | number | Progress percentage (0-100), updated in real-time during processing |
message | string | Human-readable status message with progress details (e.g., "Copying file... 45% (2.3 GB / 5.0 GB)") |
result | object | Result data when completed (contains resource_key) |
error | string | Error message if job failed |
created_at | string | ISO timestamp when job was created |
updated_at | string | ISO timestamp of last status update |
For both zip files and large files (over 1.5GB), the progress and message fields are updated in real-time as the file is processed. You can poll this endpoint every 1-2 seconds to get live progress updates showing:
- Percentage complete
- Amount of data processed (MB or GB)
- Current operation (e.g., "Copying file...", "Extracting zip...", "Uploading files...")
Example: Polling for Job Completion
async function pollJobStatus(apiKey, jobId) {
const pollUrl = `${API_BASE_URL}/${VERSION}/jobs/${jobId}`;
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const res = await fetch(pollUrl, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const data = await res.json();
const job = data.data;
console.log(`Progress: ${job.progress}% - ${job.message}`);
if (job.job_status === 'completed') {
resolve(job.result.resource_key);
} else if (job.job_status === 'failed') {
reject(new Error(job.error));
} else {
// Still processing, poll again in 2 seconds
setTimeout(poll, 2000);
}
} catch (e) {
reject(e);
}
};
poll();
});
}
4. List Jobs
GET /jobs
List all jobs for the authenticated user. Useful for tracking multiple async operations.
Request
Headers:
const headers = {
Authorization: `Bearer ${api_key}`
};
Response
{
"status": "ok",
"data": {
"jobs": [
{
"job_id": "a1b2c3d4e5f6...",
"type": "zip-processing",
"job_status": "completed",
"progress": 100,
"message": "Zip file processed successfully",
"created_at": "2025-01-15T10:30:00.000Z",
"updated_at": "2025-01-15T10:32:15.000Z"
},
{
"job_id": "b2c3d4e5f6a1...",
"type": "file-processing",
"job_status": "processing",
"progress": 65,
"message": "Copying file... 65% (3.25 GB / 5.0 GB)",
"created_at": "2025-01-15T11:00:00.000Z",
"updated_at": "2025-01-15T11:01:30.000Z"
}
],
"count": 1
}
}
Jobs are automatically cleaned up after 1 hour.
5. Create Space
POST /user/create-space
Creates a new space in Arrival Space with the key to the uploaded file. Call this after confirming the upload.
Space Types
There are two types of spaces:
| Type | Description |
|---|---|
"infinite" | Default. Open space with no room architecture. The 3D content floats in an open environment. No static gate frames are created. Best for showcasing individual 3D models. |
"hub" | Room with architecture — walls, floor, ceiling, and 7 static gate frames arranged around the room. Best for creating a home space with portals to other spaces. |
Request
Headers:
const headers = {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
};
Body:
const body = {
space_data: {
title: "My New Space", // Title for the space (optional)
description: "Optional description", // Description (optional)
resource_key: "api-uploads/...ply", // resource_key from upload-complete (optional: if not provided an empty space is created)
space_type: "hub", // "hub" or "infinite" (optional, default: "infinite")
}
};
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | No | Space title (auto-detected from filename if omitted) |
description | string | No | Space description |
resource_key | string | No | Resource key from upload-complete. If omitted, an empty space is created |
space_type | string | No | "hub" or "infinite". Default: "infinite" |
Response
For regular users:
{
"status": "ok",
"message": "Space created successfully",
"data": {
"space_url": "https://arrival.space/12345678_9012",
"title": "My New Space",
"space_type": "hub"
}
}
For anonymous users:
{
"status": "ok",
"message": "Space created successfully. Use the link to claim your space.",
"data": {
"space_url": "https://arrival.space/12345678_9012?claim=a1b2c3d4e5f6",
"title": "My New Space",
"space_type": "infinite"
}
}
When a space is created with an anonymous API key, the space_url includes a claim query parameter. To claim the space:
- Visit the URL with the
claimparameter - If logged in: The space is automatically claimed to your account
- If not logged in: You'll be prompted to log in, then the space will be claimed
Once claimed, the space becomes owned by the logged-in user and can be managed like any other space.
Complete Integration Example
const API_BASE_URL = "https://api-staging.arrival.space/api";
const VERSION = "v1";
/**
* Poll job status until completion or failure
* Used for async operations like zip file processing or large file copying
* Provides real-time progress updates during processing
*/
async function pollJobStatus(apiKey, jobId, onProgress = null) {
const pollUrl = `${API_BASE_URL}/${VERSION}/jobs/${jobId}`;
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const res = await fetch(pollUrl, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const data = await res.json();
const job = data.data;
// Optional progress callback
if (onProgress) {
onProgress(job.progress, job.message);
}
if (job.job_status === "completed") {
resolve(job.result.resource_key);
} else if (job.job_status === "failed") {
reject(new Error(job.error || "Job failed"));
} else {
// Still processing, poll again in 2 seconds
setTimeout(poll, 2000);
}
} catch (e) {
reject(e);
}
};
poll();
});
}
/**
* Upload a file to Arrival Space and create a space
*/
async function uploadFileToArrivalSpace(apiKey, file, spaceTitle, spaceDescription = "") {
// Step 1: Request presigned upload URL
const uploadRequest = await fetch(`${API_BASE_URL}/${VERSION}/files/upload`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
file_name: file.name,
file_size: file.size,
content_type: file.type || "application/octet-stream"
})
});
if (!uploadRequest.ok) {
throw new Error("Failed to get upload URL");
}
const { data } = await uploadRequest.json();
const { params } = data;
// Step 2: Upload file to S3
const s3Upload = await fetch(params.url, {
method: params.method,
body: file,
headers: params.headers
});
if (!s3Upload.ok) {
throw new Error("Failed to upload file to S3");
}
// Step 3: Confirm upload
const fileUrl = params.url.split("?")[0]; // Remove query params
const confirmRequest = await fetch(`${API_BASE_URL}/${VERSION}/files/upload-complete`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
status: "success",
extra_info: {
file_url: fileUrl
}
})
});
if (!confirmRequest.ok) {
throw new Error("Failed to confirm upload");
}
const confirmData = await confirmRequest.json();
let resource_key;
// Step 3b: Handle async processing for large files (zip files or files over 1.5GB)
if (confirmData.status === "processing" && confirmData.data.job_id) {
console.log("Large file detected - polling for completion...");
resource_key = await pollJobStatus(apiKey, confirmData.data.job_id, (progress, message) => {
console.log(`Processing: ${progress}% - ${message}`);
});
} else {
// Immediate response for small files (under 1.5GB, non-zip)
resource_key = confirmData.data.resource_key;
}
// Step 4: Create space with uploaded file
const createSpaceRequest = await fetch(`${API_BASE_URL}/${VERSION}/user/create-space`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
space_data: {
title: spaceTitle,
description: spaceDescription,
resource_key: resource_key,
}
})
});
if (!createSpaceRequest.ok) {
throw new Error("Failed to create space");
}
return await createSpaceRequest.json();
}
// Usage
const apiKey = "your_api_key_here";
const file = document.getElementById("fileInput").files[0];
uploadFileToArrivalSpace(apiKey, file, "My New Space", "Optional description")
.then(result => console.log("Space created:", result.data))
.catch(error => console.error("Upload failed:", error));
6. Entity Management
Entities are objects within a space (3D models, portals, media, etc.). These endpoints let you create, read, update, and delete entities in spaces you own.
All entity endpoints require the Authorization: Bearer <api_key> header and space ownership.
6a. Create Entity
POST /spaces/:spaceId/entities
Creates a new entity in a space. When resource_key is provided, the entity is created from an uploaded file with auto-detected defaults (scale, rotation based on file type). When omitted, a bare entity is created.
Body:
const body = {
resource_key: "api-uploads/...glb", // Optional — triggers file-based creation with auto-detected defaults
entity_id: "my-custom-id", // Optional — auto-generated if omitted
entity_type: "UserModelEntity", // Optional — defaults to "UserModelEntity" when resource_key given, "Simple" otherwise
entity_data: { // Optional — merged with auto-detected defaults
scale: [2, 2, 2],
position: [0, 1, 0]
}
};
| Parameter | Type | Required | Description |
|---|---|---|---|
resource_key | string | No | S3 key from upload-complete. Triggers file-based entity creation with auto-detected scale/rotation |
entity_id | string | No | Custom entity ID. Auto-generated if omitted |
entity_type | string | No | Entity type (e.g., "UserModelEntity", "Simple", "Gate") |
entity_data | object | No | Entity data. Merged with auto-detected defaults when resource_key is provided |
Response (201):
{
"status": "ok",
"data": {
"entity_id": "user-model-91de72809185c9fa",
"entity_type": "UserModelEntity",
"entity_data": {
"glbUrl": "https://s3.amazonaws.com/...",
"scale": [2, 2, 2],
"rotation": { "x": 0, "y": 0, "z": 0 },
"position": [0, 1, 0]
}
}
}
6b. List Entities
GET /spaces/:spaceId/entities
Returns all entities in a space (excluding the internal RoomInfo entity).
Response:
{
"status": "ok",
"data": {
"entities": [
{
"entity_id": "user-model-91de72809185c9fa",
"entity_type": "UserModelEntity",
"entity_data": { "glbUrl": "...", "scale": 1, "rotation": { "x": 0, "y": 0, "z": 0 } },
"entity_state": {},
"created_date": "2026-01-27T12:00:00.000Z",
"change_date": "2026-01-27T12:00:00.000Z"
}
],
"count": 1
}
}
6c. Get Entity
GET /spaces/:spaceId/entities/:entityId
Returns a single entity by ID.
Response:
{
"status": "ok",
"data": {
"entity_id": "user-model-91de72809185c9fa",
"entity_type": "UserModelEntity",
"entity_data": { "glbUrl": "...", "scale": 1, "rotation": { "x": 0, "y": 0, "z": 0 } },
"entity_state": {},
"created_date": "2026-01-27T12:00:00.000Z",
"change_date": "2026-01-27T12:00:00.000Z"
}
}
Error (404):
{ "status": "error", "message": "Entity not found" }
6d. Update Entity
PUT /spaces/:spaceId/entities/:entityId
Updates an entity using merge semantics: provided fields override existing values, unset fields are preserved. The entity's state is also preserved.
Body:
const body = {
entity_data: {
scale: [3, 3, 3],
label: "My Label"
}
};
Response:
{
"status": "ok",
"data": {
"entity_id": "user-model-91de72809185c9fa",
"entity_type": "UserModelEntity",
"entity_data": {
"glbUrl": "https://s3.amazonaws.com/...",
"scale": [3, 3, 3],
"rotation": { "x": 0, "y": 0, "z": 0 },
"label": "My Label"
}
}
}
6e. Delete Entity
DELETE /spaces/:spaceId/entities/:entityId
Permanently deletes an entity from a space.
Response:
{
"status": "ok",
"data": { "message": "Entity deleted successfully" }
}
Entity Management Example
async function manageEntities(apiKey, spaceId, resourceKey) {
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const base = `${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities`;
// Create entity from uploaded file
const createRes = await fetch(base, {
method: "POST",
headers,
body: JSON.stringify({
resource_key: resourceKey,
entity_data: { scale: [2, 2, 2] }
})
});
const { data: created } = await createRes.json();
console.log("Created:", created.entity_id);
// List all entities
const listRes = await fetch(base, { headers });
const { data: list } = await listRes.json();
console.log("Entities in space:", list.count);
// Update entity
await fetch(`${base}/${created.entity_id}`, {
method: "PUT",
headers,
body: JSON.stringify({ entity_data: { label: "Updated" } })
});
// Delete entity
await fetch(`${base}/${created.entity_id}`, {
method: "DELETE",
headers
});
}
7. Gates (Static & Dynamic)
Gates are portals that link spaces together. There are two kinds:
-
Static gates — 7 fixed portal frames arranged around a hub room. They are automatically created when a hub space is made (
space_type: "hub"inPOST /user/create-space). You can fill them with content or leave them empty, but you cannot add or remove them. Static gates do not exist in infinite spaces — since infinite spaces have no room architecture, there are no gate frames to display. -
Dynamic gates — freely positioned portals placed anywhere in 3D space. You can create any number of them in both hub and infinite spaces.
Both gate types are managed through the entity endpoints described above.
Static gates only make sense in hub spaces (where hideArchitecture is false). When you create a space with space_type: "infinite" (the default), no static gates are created because the room architecture is hidden. Use dynamic gates if you need portals in an infinite space.
Static Gates (type: Gate)
Static gates are created automatically in hub spaces. Their 3D positions are fixed by the client — you only control their content (title, link, description, thumbnail, etc.).
Every hub space has the same 7 gate entity IDs. Use these fixed UUIDs to address gates directly via PUT /spaces/:spaceId/entities/:entityId:
| Gate | Entity ID |
|---|---|
| 0 | 9769e4b4-e5d9-4286-9353-c2c66b158347 |
| 1 | 1aef8fd4-6447-4e04-969a-4669c11dd52e |
| 2 | 75f16b87-5314-4cdf-a8d5-bb2a8292b687 |
| 3 | 608f2483-802b-4fa7-95bb-920bed1431ad |
| 4 | fcc35518-309c-47e1-8d93-d939d6bca475 |
| 5 | 404c7fe3-26f1-4fb6-bac7-a50bf1f3cb1a |
| 6 | b9a67fa0-00ce-401d-94c2-4045c4aaec35 |
Example — Set gate 3 to link to another space:
const GATE_IDS = [
"9769e4b4-e5d9-4286-9353-c2c66b158347", // Gate 0
"1aef8fd4-6447-4e04-969a-4669c11dd52e", // Gate 1
"75f16b87-5314-4cdf-a8d5-bb2a8292b687", // Gate 2
"608f2483-802b-4fa7-95bb-920bed1431ad", // Gate 3
"fcc35518-309c-47e1-8d93-d939d6bca475", // Gate 4
"404c7fe3-26f1-4fb6-bac7-a50bf1f3cb1a", // Gate 5
"b9a67fa0-00ce-401d-94c2-4045c4aaec35", // Gate 6
];
// Internal link — point gate 3 to another space
await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities/${GATE_IDS[3]}`, {
method: "PUT",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
entity_data: {
title: "My Portal",
link: "42485456_5076",
platform: "arrival.space",
description: "Step through to see my gallery"
}
})
});
// External link — point gate 0 to a web page
// Set videoURL to the same URL so the page renders on the gate surface
await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities/${GATE_IDS[0]}`, {
method: "PUT",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
entity_data: {
title: "Visit Our Website",
link: "https://example.com",
platform: "<auto-detect>",
videoURL: "https://example.com"
}
})
});
Gate content fields (entity_data):
| Field | Type | Description |
|---|---|---|
title | string | Gate title displayed on the portal frame |
link | string | Space ID for internal links (e.g., "42485456_5076") or a full URL for external links (e.g., "https://example.com"). Leave empty for no link |
platform | string | "arrival.space" for internal space links, "<auto-detect>" for external URLs. Must match the link type |
description | string | Description text (supports HTML) |
videoURL | string | Image URL or web page URL displayed on the gate surface. For external links, set this to the same URL as link to render the page on the gate |
logoURL | string | Logo image URL (small icon on the gate) |
logoLinkURL | string | URL opened when the logo is clicked |
content360Enabled | boolean | Enable 360° content mode |
contentSynchronized | boolean | Synchronize content across users |
openAsTab | boolean | Open link in a new browser tab instead of navigating |
enableWebStream | boolean | Enable web streaming |
enableWebProxy | boolean | Enable web proxy |
embeddedEnabled | boolean | Enable embedded content |
screenSelect | number | Screen selection index (default: 0) |
Use merge semantics to your advantage — you only need to send the fields you want to change. The boolean flags and other fields will keep their defaults.
Dynamic Gates (type: DynamicGate)
Dynamic gates have the same content fields as static gates, plus a dynamicGate object that defines their 3D position, rotation, and scale. You can create any number of them.
Create dynamic gates with POST /spaces/:spaceId/entities.
Additional field:
| Field | Type | Description |
|---|---|---|
dynamicGate | object | Required for dynamic gates. Contains 3D transform data |
dynamicGate.position | {x, y, z} | World position |
dynamicGate.rotation | {x, y, z} | Euler rotation in degrees |
dynamicGate.scale | {w, h} | Width and height scale (default: {w: 1, h: 1}) |
dynamicGate.frameless | boolean | Hide the portal frame (false = show frame) |
dynamicGate.hidden | boolean | Hide the gate entirely |
Example — Create a dynamic gate:
await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
entity_id: "my-dynamic-gate",
entity_type: "DynamicGate",
entity_data: {
dynamicGate: {
position: { x: 5.0, y: 0.0, z: -3.0 },
rotation: { x: 0, y: 45, z: 0 },
frameless: false,
hidden: false,
scale: { w: 1, h: 1 }
},
title: "Side Gallery",
link: "42485456_5076",
platform: "arrival.space",
description: "A freely-placed portal"
}
})
});
Clearing a Gate
To clear a static gate (remove its content but keep the slot), update it with empty values:
await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities/${gateId}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
entity_data: {
title: "",
link: "",
description: "",
videoURL: "",
logoURL: "",
logoLinkURL: ""
}
})
});
To remove a dynamic gate entirely, use DELETE /spaces/:spaceId/entities/:entityId.
Do not delete static gates — they are part of the room structure. Only clear their content. Deleting a static gate will leave an empty slot that cannot be restored via the API.
8. Update Space Privacy
POST /spaces/update-privacy
Updates the privacy setting of an existing space. Requires ownership of the space.
Request
Headers:
const headers = {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
};
Body:
const body = {
spaceId: "12345678_9012", // The space ID (from the space URL)
roomPrivacy: "Public", // Privacy setting: "Public", "Private", or "Link Only"
informFollowers: true // Whether to notify followers when publishing (optional)
};
| Parameter | Type | Required | Description |
|---|---|---|---|
spaceId | string | Yes | The unique identifier of the space (found in the space URL) |
roomPrivacy | string | Yes | Privacy setting. One of: "Public", "Private", "Link Only" |
informFollowers | boolean | No | If true, followers will be notified when the space is set to "Public" |
Privacy Options
| Option | Description |
|---|---|
Public | Space is publicly visible and discoverable. Followers can be notified. |
Private | Space is private and not discoverable. Only accessible via direct link by owner. |
Link Only | Space is accessible via direct link but not publicly listed. Requires Pro subscription. |
Response
Success:
{
"status": "ok",
"message": "Space privacy updated successfully"
}
Error Responses:
// 404 - Space not found
{
"status": "error",
"message": "Space not found"
}
// 403 - Not authorized
{
"status": "error",
"message": "You are not authorized to update the privacy of this space"
}
// 400 - Invalid privacy option
{
"status": "error",
"message": "Invalid privacy option"
}
// 400 - Pro required for Link Only
{
"status": "error",
"message": "You need to be a Pro user to set a space to Link Only"
}
Example
async function updateSpacePrivacy(apiKey, spaceId, privacy, informFollowers = false) {
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces/update-privacy`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
spaceId: spaceId,
roomPrivacy: privacy,
informFollowers: informFollowers
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
return await response.json();
}
// Usage
updateSpacePrivacy("your_api_key", "12345678_9012", "Public", true)
.then(result => console.log("Privacy updated:", result))
.catch(error => console.error("Update failed:", error));
Error Handling
| Status Code | Description |
|---|---|
200 | OK — Request succeeded |
201 | Created — Resource created successfully (e.g., entity) |
202 | Accepted — Async job started (poll for completion) |
204 | No Content — Async job updates headers |
400 | Bad Request — Invalid parameters |
401 | Unauthorized — Invalid or expired API key |
403 | Forbidden — Insufficient permissions |
404 | Not Found — Resource or job not found |
413 | Payload Too Large — File exceeds size limit |
500 | Internal Server Error |
507 | Insufficient Storage — User storage quota exceeded |
Notes
- API keys are tied to individual user accounts (or anonymous accounts for testing)
- Uploaded files create spaces owned by the API key holder
- Presigned URLs expire after a limited time — upload promptly
- Keep your API key secure and never expose it in client-side code
- Anonymous API keys: Spaces created with anonymous keys can be claimed by visiting the space URL with the
claimparameter. The space will be transferred to the logged-in user's account.