Self-Hosted API Specification
Complete protocol specification for implementing your own Seenn-compatible backend
#Overview
To use Seenn SDKs with your own backend, you need to implement 6 REST endpoints and optionally an SSE endpoint for real-time updates.
| Endpoint | Method | Description |
|---|---|---|
/v1/jobs |
POST | Create a new job |
/v1/jobs/:id |
GET | Get job by ID |
/v1/jobs |
GET | List jobs for a user |
/v1/jobs/:id/progress |
POST | Update job progress |
/v1/jobs/:id/complete |
POST | Mark job as completed |
/v1/jobs/:id/fail |
POST | Mark job as failed |
/v1/jobs/:parentId/children |
GET | Get parent job with children |
/v1/sse |
GET | SSE stream (optional) |
#Authentication
All API requests require authentication via the Authorization header:
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx
#API Key Format
Seenn uses prefixed API keys for clarity:
| Prefix | Usage | Permissions |
|---|---|---|
sk_live_ |
Production secret key | Full access (server-side only) |
sk_test_ |
Development secret key | Full access (test environment) |
pk_live_ |
Production public key | Read-only (safe for client-side) |
pk_test_ |
Development public key | Read-only (test environment) |
sk_* keys in client-side code. Use pk_* keys for mobile apps and browsers.
#POST /v1/jobs
Create a new job. Supports both regular jobs and parent-child relationships.
Request Body
{
"jobType": "video-generation", // required, 1-100 chars
"userId": "user_123", // required, 1-255 chars
"title": "Creating your video...", // required, 1-200 chars
"metadata": { "prompt": "..." }, // optional, max 10KB
"queue": { // optional
"position": 5,
"total": 20,
"queueName": "priority"
},
"stage": { // optional
"name": "initializing",
"current": 1,
"total": 3,
"description": "Setting up..."
},
"estimatedCompletionAt": "2026-01-22T15:00:00Z", // optional, ISO 8601
"ttlSeconds": 2592000, // optional, 60-2592000 (30 days)
// Parent-child fields (optional)
"totalChildren": 5, // for parent jobs, 1-1000
"childProgressMode": "average", // 'average' | 'sequential' | 'weighted'
"parentJobId": "01HXYZ...", // for child jobs
"childIndex": 0 // for child jobs, 0-9999
}
Response (201 Created)
{
"id": "01HXYZ123456789ABCDEFGHIJ",
"appId": "app_xxx",
"userId": "user_123",
"jobType": "video-generation",
"title": "Creating your video...",
"status": "pending",
"progress": 0,
"metadata": { "prompt": "..." },
"createdAt": "2026-01-22T14:30:00Z",
"updatedAt": "2026-01-22T14:30:00Z",
// For parent jobs
"children": {
"total": 5,
"completed": 0,
"failed": 0,
"running": 0,
"pending": 5
},
"childProgressMode": "average",
// For child jobs
"parent": {
"parentJobId": "01HXYZ...",
"childIndex": 0
}
}
#GET /v1/jobs/:id
Get a job by its ID.
Response (200 OK)
Same format as POST response above.
#GET /v1/jobs
List jobs for a user with pagination.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
userId |
string | Required. User ID to list jobs for |
limit |
number | Max results (default: 20, max: 100) |
cursor |
string | Pagination cursor from previous response |
Response (200 OK)
{
"jobs": [
{
"id": "01HXYZ...",
"userId": "user_123",
"jobType": "video-generation",
"title": "My Video",
"status": "completed",
"progress": 100,
"createdAt": "2026-01-22T14:30:00Z",
"updatedAt": "2026-01-22T14:35:00Z"
}
],
"nextCursor": "eyJsYXN0S2V5Ijo..."
}
#GET /v1/jobs/:parentId/children
Get a parent job with all its child jobs.
Response (200 OK)
{
"parent": {
"id": "01HXYZ...",
"title": "Christmas Pack",
"status": "running",
"progress": 60,
"children": {
"total": 5,
"completed": 3,
"failed": 0,
"running": 1,
"pending": 1
},
"childProgressMode": "average"
},
"children": [
{
"id": "01HABC...",
"childIndex": 0,
"title": "Snowflake",
"status": "completed",
"progress": 100,
"createdAt": "2026-01-22T14:30:00Z",
"updatedAt": "2026-01-22T14:32:00Z",
"completedAt": "2026-01-22T14:32:00Z"
},
{
"id": "01HDEF...",
"childIndex": 1,
"title": "Christmas Tree",
"status": "running",
"progress": 50,
"createdAt": "2026-01-22T14:30:00Z",
"updatedAt": "2026-01-22T14:33:00Z"
}
]
}
#POST /v1/jobs/:id/progress
Update job progress. Automatically transitions status from pending to running.
Request Body
{
"progress": 50, // required, 0-100
"message": "Processing frames...", // optional
"queue": { ... }, // optional
"stage": { ... }, // optional
"estimatedCompletionAt": "...", // optional
"metadata": { ... } // optional, merged with existing
}
Response (200 OK)
Returns the updated job object.
#POST /v1/jobs/:id/complete
Mark a job as completed.
Request Body
{
"result": { // optional
"type": "video",
"url": "https://cdn.example.com/output.mp4",
"data": { ... }
},
"message": "Video ready!" // optional
}
Response (200 OK)
Returns the updated job with status: "completed", progress: 100, and completedAt timestamp.
#POST /v1/jobs/:id/fail
Mark a job as failed.
Request Body
{
"error": { // required
"code": "PROCESSING_ERROR",
"message": "Failed to generate video",
"details": { "reason": "..." }
},
"retryable": true // optional
}
Response (200 OK)
Returns the updated job with status: "failed" and the error object.
#Error Responses
All errors follow this format:
{
"error": {
"code": "NOT_FOUND",
"message": "Job job_123 not found"
}
}
Error Codes
| HTTP | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid request body or parameters |
| 400 | INVALID_JOB_STATE |
Can't perform action in current job state |
| 400 | INVALID_PARENT |
Parent job doesn't exist or isn't a parent |
| 400 | INVALID_CHILD_INDEX |
Child index exceeds parent's totalChildren |
| 401 | UNAUTHORIZED |
Invalid or missing API key |
| 404 | NOT_FOUND |
Job not found |
| 429 | RATE_LIMITED |
Too many requests |
| 500 | INTERNAL_ERROR |
Server error |
#SSE Events (Optional)
For real-time updates, implement an SSE endpoint at /v1/sse.
Connection
GET /v1/sse?userId=user_123
Authorization: Bearer pk_live_xxx
Accept: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Event Format
event: job.progress
id: 1705932600000
data: {"id":"01HXYZ...","status":"running","progress":50,...}
event: job.completed
id: 1705932660000
data: {"id":"01HXYZ...","status":"completed","progress":100,...}
event: heartbeat
id: 1705932615000
data: {}
Event Types
| Event | Description |
|---|---|
connected |
Connection established |
job.started |
New job created |
job.progress |
Job progress updated |
job.completed |
Job completed successfully |
job.failed |
Job failed |
parent.updated |
Parent job stats updated (child changed) |
heartbeat |
Keep-alive (every 15s) |
#Implementation Tips
Database Schema
Recommended fields for your jobs table:
CREATE TABLE jobs (
id VARCHAR(26) PRIMARY KEY, -- ULID
app_id VARCHAR(100) NOT NULL,
user_id VARCHAR(255) NOT NULL,
job_type VARCHAR(100) NOT NULL,
title VARCHAR(200) NOT NULL,
status ENUM('pending','running','completed','failed'),
progress INT DEFAULT 0,
message TEXT,
metadata JSON,
queue JSON,
stage JSON,
result JSON,
error JSON,
estimated_completion_at TIMESTAMP,
-- Parent-child
parent_job_id VARCHAR(26),
child_index INT,
total_children INT,
completed_children INT DEFAULT 0,
failed_children INT DEFAULT 0,
running_children INT DEFAULT 0,
pending_children INT DEFAULT 0,
child_progress_mode VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
ttl TIMESTAMP,
INDEX idx_user (app_id, user_id),
INDEX idx_parent (parent_job_id)
);
Parent Progress Calculation
// When a child job updates, recalculate parent
function updateParentStats(parentId) {
const children = getChildrenByParentId(parentId);
const parent = getJob(parentId);
let completed = 0, failed = 0, running = 0, pending = 0;
let totalProgress = 0;
for (const child of children) {
switch (child.status) {
case 'completed': completed++; totalProgress += 100; break;
case 'failed': failed++; totalProgress += child.progress; break;
case 'running': running++; totalProgress += child.progress; break;
case 'pending': pending++; break;
}
}
// Calculate progress based on mode
let parentProgress;
switch (parent.childProgressMode) {
case 'average':
parentProgress = Math.round(totalProgress / parent.totalChildren);
break;
case 'sequential':
parentProgress = Math.round((completed / parent.totalChildren) * 100);
break;
}
// Determine parent status
let parentStatus = parent.status;
if (running > 0 || (completed > 0 && completed < parent.totalChildren)) {
parentStatus = 'running';
} else if (completed === parent.totalChildren) {
parentStatus = 'completed';
} else if (failed > 0 && (completed + failed) === parent.totalChildren && completed === 0) {
parentStatus = 'failed';
}
updateJob(parentId, {
completedChildren: completed,
failedChildren: failed,
runningChildren: running,
pendingChildren: pending,
progress: parentProgress,
status: parentStatus
});
}