Media processing pipeline on Railway with Ittybit

View Markdown

Instead of polling for task completion, let Ittybit push results to you. Deploy a small webhook receiver on Railway, submit media for processing, and get async results posted back to your endpoint when each task finishes.

Project setup

Scaffold a new project with an Express server and a Postgres client:

mkdir ittybit-pipeline && cd ittybit-pipeline
npm init -y
npm install express pg

Set your environment variables in the Railway dashboard or via railway variables set:

ITTYBIT_API_KEY=your_ittybit_api_key
DATABASE_URL=your_railway_postgres_url
RAILWAY_PUBLIC_DOMAIN=your-app.up.railway.app

Create the database table

Create a media_jobs table to track submissions and results:

CREATE TABLE media_jobs (
  id SERIAL PRIMARY KEY,
  task_id TEXT UNIQUE,
  input_url TEXT NOT NULL,
  status TEXT DEFAULT 'pending',
  output_url TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  completed_at TIMESTAMPTZ
);

Build the server

The server has two routes: one to accept media URLs and dispatch tasks, and one to receive webhook callbacks from Ittybit.

import express from "express";
import pg from "pg";

const app = express();
app.use(express.json());

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const ITTYBIT_API_KEY = process.env.ITTYBIT_API_KEY;
const RAILWAY_DOMAIN = process.env.RAILWAY_PUBLIC_DOMAIN;

// Accept a media URL, dispatch an Ittybit task with a webhook
app.post("/process", async (req, res) => {
const { url, kind = "video", options = {} } = req.body;

const task = await fetch("https://api.ittybit.com/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${ITTYBIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: url,
kind,
options,
webhook_url: `https://${RAILWAY_DOMAIN}/webhooks/ittybit`,
}),
});

const data = await task.json();

await pool.query(
"INSERT INTO media_jobs (task_id, input_url, status) VALUES ($1, $2, $3)",
[data.id, url, "processing"]
);

res.json({ task_id: data.id, status: "processing" });
});

// Receive completion webhooks from Ittybit
app.post("/webhooks/ittybit", async (req, res) => {
const { id, status, output } = req.body;

await pool.query(
`UPDATE media_jobs
     SET status = $1, output_url = $2, completed_at = now()
     WHERE task_id = $3`,
[status, output?.url ?? null, id]
);

res.sendStatus(200);
});

// Check job status
app.get("/jobs/:taskId", async (req, res) => {
const { rows } = await pool.query(
"SELECT \* FROM media_jobs WHERE task_id = $1",
[req.params.taskId]
);
if (!rows.length) return res.status(404).json({ error: "not found" });
res.json(rows[0]);
});

app.listen(process.env.PORT || 3000);
# Submit a video for processing
curl -X POST https://your-app.up.railway.app/process \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/uploads/video.mov",
    "kind": "video",
    "options": { "width": 1280, "format": "mp4", "quality": "high" }
  }'

# Check status
curl https://your-app.up.railway.app/jobs/task_abc123

Deploy to Railway

Connect your repo to Railway via GitHub or push directly:

railway up

Railway auto-detects the Node.js project, installs dependencies, and starts the server. Add a Postgres plugin from the Railway dashboard and the DATABASE_URL is injected automatically.

What happens at runtime

  1. You POST a media URL to /process
  2. Your server creates an Ittybit task with webhook_url pointing to your Railway domain
  3. Ittybit processes the media asynchronously
  4. On completion, Ittybit POSTs the result to /webhooks/ittybit
  5. Your server updates Postgres with the output URL and status

Multiple tasks per upload

Submit several tasks for the same input to build a full pipeline:

const tasks = [
  { kind: "video", options: { width: 1920, format: "mp4", quality: "high" } },
  { kind: "image", options: { width: 640, format: "webp", start: 2 } },
  { kind: "video", options: { width: 480, format: "mp4", quality: "low" } },
];

const results = await Promise.all(
tasks.map((task) =>
fetch("https://api.ittybit.com/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: "https://example.com/uploads/video.mov",
...task,
webhook_url: `https://${RAILWAY_DOMAIN}/webhooks/ittybit`,
}),
}).then((r) => r.json())
)
);
# HD version
curl -X POST https://api.ittybit.com/jobs \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://example.com/uploads/video.mov",
    "kind": "video",
    "options": { "width": 1920, "format": "mp4", "quality": "high" },
    "webhook_url": "https://your-app.up.railway.app/webhooks/ittybit"
  }'

# Thumbnail
curl -X POST https://api.ittybit.com/jobs \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://example.com/uploads/video.mov",
    "kind": "image",
    "options": { "width": 640, "format": "webp", "start": 2 },
    "webhook_url": "https://your-app.up.railway.app/webhooks/ittybit"
  }'

# Low-res preview
curl -X POST https://api.ittybit.com/jobs \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://example.com/uploads/video.mov",
    "kind": "video",
    "options": { "width": 480, "format": "mp4", "quality": "low" },
    "webhook_url": "https://your-app.up.railway.app/webhooks/ittybit"
  }'

Each task completes independently and fires its own webhook. Your /webhooks/ittybit handler updates the database as each result arrives.

See also