REST API
Overview
The Arrival Space REST API lets third-party applications authenticate users, upload assets via presigned URLs, track async processing jobs, create and manage spaces, manage entities, and configure gates.
Capabilities
- Upload assets with presigned S3 URLs and confirm processing
- Track asynchronous processing jobs with real-time progress
- Create spaces from uploads and update privacy settings
- Create, read, update, and delete entities within spaces
- Configure static and dynamic gates using entity endpoints
Make sure you have enough available storage left before starting the upload. Otherwise, the upload may fail.
Base URL & Versioning
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>
Option 1: User Account API Key
- Log in to Arrival Space
- Navigate to your account settings
- Generate or copy your API key
Option 2: 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.
Conventions
- Responses use a common envelope with
status,data, and sometimesmessageorerror - Timestamps are ISO 8601 strings (e.g.,
2025-01-15T10:30:00.000Z) - Some operations are asynchronous and return a
job_idthat you poll viaGET /jobs/:jobIduntiljob_statusiscompletedorfailed resource_keyidentifies an uploaded asset and is used to create spaces or entitiesspaceIdis the unique identifier found in the space URL (e.g.,https://arrival.space/12345678_9012)entity_ididentifies a specific entity within a space
Rate Limits
Limits are enforced per API key (fallback to IP if no key is provided). A 429 response includes a Retry-After header and a retry_after_seconds field in the body.
| Scope | Limit |
|---|---|
| All API endpoints | 300 requests/min |
POST /files/upload | 20 requests/min |
POST /files/upload-complete | 20 requests/min |
GET /jobs/:jobId | 120 requests/min |
POST /user/create-space | 60 requests/min |
POST /spaces/update-privacy | 60 requests/min |
Entity CRUD (/spaces/:spaceId/entities*) | 300 requests/min |
Quickstart
Flow Summary
- Obtain an API key (user account key or OAuth login)
- Request a presigned upload URL
- Upload the file to S3
- Confirm the upload and, if needed, poll for async processing completion
- Create a space and manage entities and gates
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));
API Reference
Uploads
Request Upload URL
POST /files/upload
Generates a presigned S3 URL for file upload.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Content-Type | application/json | Yes | JSON request body |
Body parameters:
| Field | Type | Required | Description |
|---|---|---|---|
file_name | string | Yes | Name of the file |
file_size | number | Yes | File size in bytes |
content_type | string | Yes | 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" }
}
}
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/files/upload`, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
file_name: "example.zip",
file_size: 1048576000,
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,
});
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:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Content-Type | application/json | Yes | JSON request body |
Body parameters:
| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | Use "success" after upload completes |
extra_info.file_url | string | Yes | URL of the uploaded file in S3 |
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 |
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/files/upload-complete`, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
status: "success",
extra_info: {
file_url: "https://s3.amazonaws.com/bucket/uploads/example.zip"
}
})
});
Jobs
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:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
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 |
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${api_key}` }
});
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();
});
}
List Jobs
GET /jobs
List all jobs for the authenticated user. Useful for tracking multiple async operations.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
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
}
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/jobs`, {
headers: { Authorization: `Bearer ${api_key}` }
});
Jobs are automatically cleaned up after 1 hour.
Spaces
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:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Content-Type | application/json | Yes | JSON request body |
Body parameters:
| 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
{
"status": "ok",
"message": "Space created successfully",
"data": {
"space_url": "https://arrival.space/12345678_9012",
"title": "My New Space",
"space_type": "hub"
}
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/user/create-space`, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
space_data: {
title: "My New Space",
description: "Optional description",
resource_key: "api-uploads/...ply",
space_type: "hub"
}
})
});
Update Space Privacy
POST /spaces/update-privacy
Updates the privacy setting of an existing space. Requires ownership of the space.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Content-Type | application/json | Yes | JSON request body |
Body parameters:
| 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 Request
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));
Entities
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.
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.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Content-Type | application/json | Yes | JSON request body |
Body parameters:
| 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]
}
}
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities`, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
resource_key: "api-uploads/...glb",
entity_id: "my-custom-id",
entity_type: "UserModelEntity",
entity_data: {
scale: [2, 2, 2],
position: [0, 1, 0]
}
})
});
List Entities
GET /spaces/:spaceId/entities
Returns all entities in a space (excluding the internal RoomInfo entity).
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | number | No | Max entities to return (default 50, max 200) |
cursor | string | No | Pagination cursor from nextCursor |
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,
"total": 1,
"nextCursor": null,
"hasMore": false
}
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities?limit=50`, {
headers: { Authorization: `Bearer ${api_key}` }
});
Get Entity
GET /spaces/:spaceId/entities/:entityId
Returns a single entity by ID.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Path parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
spaceId | string | Yes | Space ID (from space URL) |
entityId | string | Yes | Entity 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" }
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities/${entityId}`, {
headers: { Authorization: `Bearer ${api_key}` }
});
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.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Content-Type | application/json | Yes | JSON request body |
Body parameters:
| Field | Type | Required | Description |
|---|---|---|---|
entity_data | object | Yes | Fields to merge into the existing entity |
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"
}
}
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities/${entityId}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
entity_data: {
scale: [3, 3, 3],
label: "My Label"
}
})
});
Delete Entity
DELETE /spaces/:spaceId/entities/:entityId
Permanently deletes an entity from a space.
Request
Headers:
| Header | Value | Required | Description |
|---|---|---|---|
Authorization | Bearer <api_key> | Yes | API key for the user |
Path parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
spaceId | string | Yes | Space ID (from space URL) |
entityId | string | Yes | Entity ID |
Response:
{
"status": "ok",
"data": { "message": "Entity deleted successfully" }
}
Example Request
const response = await fetch(`${API_BASE_URL}/${VERSION}/spaces/${spaceId}/entities/${entityId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${api_key}` }
});
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
});
}
Gates
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.
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
- 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