REST API client for the Farm TikTok app (slug: video-farm) on OfficeX. Discovers winning TikTok content via keyword or channel search, AI-filters with Gemini, schedules deduplicated reposts to volunteer publishers, and tracks proof-of-publication. Also supports manual mode (BYO content) where users skip discovery and inject their own videos/content directly into the scheduling pipeline. Use when: (1) Creating content discovery jobs by keyword or channel scrape, (2) Reviewing, approving, or rejecting discovered video results, (3) Scheduling approved videos with AI-generated captions/instructions for volunteers, (4) Viewing or managing a content calendar, (5) Submitting or checking proof-of-post from volunteers, (6) Checking NocoDB spreadsheet views, (7) Any TikTok theme page growth or content farming workflow, (8) Injecting your own videos into the scheduling/calendar pipeline (manual mode). Triggers: farm tiktok, daily tiktok, tiktok farm, tiktok content, theme page, tiktok repost, tiktok schedule, content farming, tiktok growth, volunteer post, tiktok calendar, tiktok proof, manual content, byo video.
Install
npx skillscat add officexapp/tiktok-farm Install via the SkillsCat registry.
Farm TikTok — API Skill
Batch TikTok content curation engine on OfficeX. Discovers proven viral content via keyword or channel search, AI-filters it, schedules deduplicated reposts to volunteers via magic links, and tracks proof-of-publication. Also supports manual mode where you bring your own content and use the scheduling/calendar/webhook pipeline directly. Grows niche theme pages to 30k+ followers by reseeding winning content.
Get started on OfficeX: Create a free account at officex.app and install this app from the store: officex.app/store/en/app/video-farm
Prerequisites
After installing the app on OfficeX, you'll receive credentials via the install webhook. Set these in your .env:
# Required — from OfficeX app install (agent_context)
OFFICEX_INSTALL_ID="your_install_id" # Provided on install
OFFICEX_INSTALL_SECRET="your_install_secret" # Provided on install
# Optional — override the default API URL
FARM_TIKTOK_API_URL="https://video-farm-api.cloud.zoomgtm.com"The OFFICEX_INSTALL_ID and OFFICEX_INSTALL_SECRET are provided automatically when you install the app on OfficeX. They are used to generate the Bearer token for API authentication:
# Bearer token = Base64(install_id:install_secret)
TOKEN=$(echo -n "${OFFICEX_INSTALL_ID}:${OFFICEX_INSTALL_SECRET}" | base64)Pipeline
Search/Channel Mode (AI-powered discovery)
CREATE JOB → DISCOVER → AI ANALYZE → APPROVE → SCHEDULE → NOTIFY → PROOF
POST /jobs TokInsight Gemini 2.5 PATCH POST Email + POST
search or match_score /results /results webhook /volunteer
channel tweet_text approve /:id/ at time /:id/proof
scrape caption scheduleManual Mode (BYO content)
CREATE JOB → ADD RESULTS → SCHEDULE → NOTIFY → PROOF
POST /jobs POST /jobs/ POST Email + POST
mode=manual :id/results /results/ webhook /volunteer/
:id/schedule at time :id/proofBase URL
Use the base_url from your agent_context. Fallback:
| Stage | URL |
|---|---|
| Staging | https://video-farm-api-staging.cloud.zoomgtm.com |
| Production | https://video-farm-api.cloud.zoomgtm.com |
Authentication
Bearer token = Base64 of install_id:install_secret (from agent_context or OfficeX install).
const token = btoa(`${installId}:${installSecret}`);
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};Volunteer endpoints (/volunteer/*) require no auth.
TypeScript Types
// === Enums ===
type JobStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
type PublishMode = 'MANUAL_REVIEW' | 'AI_REVIEW' | 'AUTO_APPROVED';
type JobMode = 'search' | 'channel' | 'manual';
// === Entities ===
interface Job {
job_id: string;
user_id: string;
status: JobStatus;
title?: string;
job_mode?: JobMode;
content_prompt?: string;
channel_username?: string;
output_quantity: number;
filter_prompt?: string;
instruction_prompt?: string;
caption_prompt?: string;
publish_mode: PublishMode;
publish_prompt?: string;
schedule_prompt?: string;
on_job_finish_webhook?: string;
on_job_finish_email?: string;
on_schedule_webhook_default?: string;
on_schedule_email_default?: string;
on_proof_webhook_default?: string;
on_proof_email_default?: string;
destination_url?: string;
tracer?: string;
inbox_tracer?: string;
results_created: number;
results_approved: number;
credits_spent: number;
credits_reserved?: number;
reservation_id?: string;
notes?: string;
bookmarked?: boolean;
created_at: string;
updated_at: string;
}
interface Result {
result_id: string;
job_id: string;
user_id: string;
tweet_text: string;
original_video_url?: string;
deduplicated_video_url?: string;
tiktok_video_id?: string;
tiktok_author?: string;
tiktok_view_count?: number;
tiktok_like_count?: number;
tiktok_comment_count?: number;
tiktok_caption?: string;
match_score: number; // 0-100
ai_analysis: string;
approval_decision?: string;
approved: boolean;
rejected: boolean;
user_notes?: string;
caption?: string;
pin_comment?: string;
instructions?: string;
scheduled_datetime?: string;
scheduled: boolean;
on_schedule_email?: string;
on_schedule_webhook?: string;
on_proof_webhook?: string;
on_proof_email?: string;
destination_url?: string;
password_protected?: string;
tracer?: string;
inbox_tracer?: string;
bookmarked?: boolean;
}
interface Scheduled {
scheduled_id: string;
result_id: string;
job_id: string;
user_id: string;
tweet_text: string;
status?: 'pending' | 'completed';
credits_consumed?: number;
volunteer_magic_link?: string;
original_video_url?: string;
deduplicated_video_url?: string;
dedup_status?: 'pending' | 'completed' | 'failed';
caption?: string;
pin_comment?: string;
instructions?: string;
instruction_prompt?: string;
scheduled_datetime: string;
on_schedule_email?: string;
on_schedule_webhook?: string;
on_proof_webhook?: string;
on_proof_email?: string;
destination_url?: string;
password_protected?: string;
fired: boolean;
fired_at?: string;
proof_url?: string;
proof_timestamp?: string;
reported_by?: string;
tracer?: string;
inbox_tracer?: string;
}
// === Request Types ===
interface CreateJobRequest {
job_mode?: JobMode; // default: 'search'. Use 'manual' for BYO content
content_prompt?: string; // keywords (search) or description (channel). Optional for manual
channel_username?: string; // required if job_mode='channel'
output_quantity?: number; // default: 5
filter_prompt?: string;
caption_prompt?: string;
instruction_prompt?: string;
publish_mode?: PublishMode; // default: 'MANUAL_REVIEW'
publish_prompt?: string;
schedule_prompt?: string;
on_job_finish_webhook?: string;
on_job_finish_email?: string;
on_schedule_webhook_default?: string;
on_schedule_email_default?: string;
on_proof_webhook_default?: string;
on_proof_email_default?: string;
destination_url?: string;
tracer?: string;
inbox_tracer?: string;
notes?: string;
bookmarked?: boolean;
}
interface UpdateResultRequest {
approved?: boolean;
rejected?: boolean;
user_notes?: string;
caption?: string;
pin_comment?: string;
instructions?: string;
scheduled_datetime?: string;
on_schedule_email?: string;
on_schedule_webhook?: string;
on_proof_webhook?: string;
on_proof_email?: string;
destination_url?: string;
password_protected?: string;
}
interface CreateManualResultRequest {
post_text?: string;
original_video_url?: string;
caption?: string;
pin_comment?: string;
instructions?: string;
scheduled_datetime?: string;
on_schedule_email?: string;
on_schedule_webhook?: string;
on_proof_webhook?: string;
on_proof_email?: string;
destination_url?: string;
password_protected?: string;
approved?: boolean; // default: true
match_score?: number; // default: 100
ai_analysis?: string; // default: 'Manually created result'
tiktok_video_id?: string;
tiktok_author?: string;
tiktok_view_count?: number;
tiktok_like_count?: number;
tiktok_comment_count?: number;
tiktok_caption?: string;
user_notes?: string;
tracer?: string;
inbox_tracer?: string;
}
interface UpdateScheduledRequest {
tweet_text?: string;
scheduled_datetime?: string;
on_schedule_email?: string;
on_schedule_webhook?: string;
on_proof_webhook?: string;
on_proof_email?: string;
destination_url?: string;
instruction_prompt?: string;
}
interface SubmitProofRequest {
proof_url: string;
reported_by?: string;
}
// === Response Envelope ===
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
interface PaginatedResponse<T> {
items: T[];
next_cursor?: string;
total?: number;
}
// === Webhook Events (outbound from app) ===
interface WebhookEvent<T = unknown> {
id: string;
action: 'SCHEDULE_FIRED' | 'PROOF_SUBMITTED';
payload: T;
timestamp: string;
}
interface ScheduleFiredPayload {
scheduled_id: string;
result_id: string;
job_id: string;
user_id: string;
original_video_url: string;
deduplicated_video_url: string;
caption: string;
pin_comment: string;
instructions?: string;
volunteer_magic_link: string;
submit_proof_endpoint: string;
tracer?: string;
inbox_tracer?: string;
}
interface ProofSubmittedPayload {
scheduled_id: string;
result_id: string;
job_id: string;
user_id: string;
proof_url: string;
proof_timestamp: string;
tweet_text: string;
tracer?: string;
inbox_tracer?: string;
}REST API Reference
All endpoints return ApiResponse<T>. Authenticated routes require Authorization: Bearer <token>.
Health
GET /health → { status: 'healthy', stage, timestamp }Auth
POST /auth/login
Body: { officex_customer_id, officex_install_id, officex_install_secret }
→ { user, token }
GET /auth/me → UserJobs
GET /jobs?limit=50&cursor=<token> → PaginatedResponse<Job>
POST /jobs → 201 { job_id, status:'PENDING', estimated_cost }
GET /jobs/:job_id → Job
PATCH /jobs/:job_id { notes?, bookmarked? } → Job
DELETE /jobs/:job_id → { deleted: true }
POST /jobs/:job_id/resync-nocodb → { job_synced: true, results_synced: number }POST /jobs reserves OfficeX credits and invokes async processing. Job status progresses: PENDING → PROCESSING → COMPLETED|FAILED. For job_mode='manual', the job is created in COMPLETED status immediately with no credit reservation — results are added via POST /jobs/:job_id/results.
Results
GET /jobs/:job_id/results?limit=50&cursor=<token> → PaginatedResponse<Result>
POST /jobs/:job_id/results CreateManualResultRequest → 201 Result (manual jobs only)
GET /results/:result_id → Result
PATCH /results/:result_id UpdateResultRequest → Result
POST /results/:result_id/schedule → 201 ScheduledPOST /jobs/:job_id/results creates a result directly on a manual job. Only works when job_mode='manual'. Results default to approved=true. Use this to inject your own content (videos you created externally, curated links, etc.) into the scheduling/calendar/webhook pipeline.
Scheduling requires approved === true and scheduled === false. If no scheduled_datetime, the AI uses schedule_prompt or defaults to +24h. Triggers async video deduplication.
Scheduled
GET /scheduled?limit=100&cursor=<token>&start_date=ISO&end_date=ISO → PaginatedResponse<Scheduled>
GET /scheduled/:scheduled_id → Scheduled
PATCH /scheduled/:scheduled_id UpdateScheduledRequest → Scheduled
DELETE /scheduled/:scheduled_id → { message: 'Scheduled entry deleted' }
POST /scheduled/:scheduled_id/fire-now → { message, scheduled_id }Changing scheduled_datetime via PATCH deletes and recreates the item (sort key contains datetime). Fails with ALREADY_FIRED if the post has already fired.
POST /scheduled/:scheduled_id/fire-now immediately invokes the notifier (email + webhook) without waiting for the scheduled time. Fails if already fired.
Calendar
GET /calendar/month/:year/:month → { year, month, days: Record<string, Scheduled[]>, total }
GET /calendar/week/:year/:week → { year, week, days: Record<string, Scheduled[]>, total }Volunteer (No Auth)
GET /volunteer/:scheduled_id
→ { scheduled_id, tweet_text, scheduled_datetime, destination_url, fired, proof_url,
proof_timestamp, inbox_tracer, instruction_prompt,
batch?: { total, completed, current_index, next_task_id } }
POST /volunteer/:scheduled_id/proof
Body: { proof_url, reported_by? }
→ { proof_url, proof_timestamp, has_next_task, next_task_link? }NocoDB Views
GET /nocodb/views → { jobs_view_url?, results_view_url?, scheduled_view_url? }
GET /nocodb/jobs/:job_id/results-view → { results_view_url?, view_url? }Webhooks (OfficeX → App)
POST /webhooks/officex
Body: { event: 'INSTALL'|'UNINSTALL'|'RATE_LIMIT_CHANGE', payload, uuid }
INSTALL → { agent_context: { api_url, auth_token, install_id } }Outbound Webhook Events
| Event | When | Key Fields |
|---|---|---|
SCHEDULE_FIRED |
At scheduled_datetime | scheduled_id, deduplicated_video_url, caption, pin_comment, volunteer_magic_link, submit_proof_endpoint |
PROOF_SUBMITTED |
Volunteer submits proof | scheduled_id, proof_url, proof_timestamp, tweet_text, tracer, inbox_tracer |
Error Codes
| Code | HTTP | Meaning |
|---|---|---|
INVALID_REQUEST |
400 | Missing/invalid fields |
UNAUTHORIZED |
401 | Missing or invalid auth |
INVALID_CREDENTIALS |
401 | Install credentials mismatch |
MISSING_CREDENTIALS |
400 | Missing install_id or install_secret |
NOT_APPROVED |
400 | Must approve before scheduling |
ALREADY_SCHEDULED |
400 | Result already scheduled |
ALREADY_FIRED |
400 | Cannot modify a fired post |
MISSING_PROOF_URL |
400 | proof_url is required |
JOB_IN_PROGRESS |
400 | Cannot delete a processing job |
INVALID_DATE |
400 | Invalid year/month/week |
RESERVATION_FAILED |
402 | Insufficient credits |
JOB_NOT_FOUND |
404 | Job not found |
RESULT_NOT_FOUND |
404 | Result not found |
SCHEDULED_NOT_FOUND |
404 | Scheduled post not found |
USER_NOT_FOUND |
404 | User not found |
fetch Examples
Setup
const API = 'https://video-farm-api.cloud.zoomgtm.com'; // or from agent_context.api_url
const token = btoa(`${installId}:${installSecret}`);
const auth = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };Create a keyword search job
const res = await fetch(`${API}/jobs`, {
method: 'POST',
headers: auth,
body: JSON.stringify({
job_mode: 'search',
content_prompt: 'viral fitness transformation clips',
output_quantity: 10,
filter_prompt: 'Only videos with 50k+ views showing real transformations',
publish_mode: 'AI_REVIEW',
caption_prompt: 'Motivational tone. Include #fitness #transformation #gymtok',
schedule_prompt: 'Schedule 2 per day, 9am and 6pm EST',
on_schedule_email_default: 'volunteer@example.com',
inbox_tracer: 'fitness-batch-001'
})
});
const { data } = await res.json();
// { job_id: 'uuid', status: 'PENDING', estimated_cost: 42.22 }Create a channel scrape job
const res = await fetch(`${API}/jobs`, {
method: 'POST',
headers: auth,
body: JSON.stringify({
job_mode: 'channel',
channel_username: 'alexhormozi',
output_quantity: 5,
publish_mode: 'MANUAL_REVIEW',
caption_prompt: 'Rewrite in first person. Add #entrepreneurship #business',
instruction_prompt: 'Post to @our_brand. Add 3 relevant hashtags in comments.'
})
});Create a manual job (BYO content, skip discovery)
const res = await fetch(`${API}/jobs`, {
method: 'POST',
headers: auth,
body: JSON.stringify({
job_mode: 'manual',
schedule_prompt: 'Schedule 3 per day, 8am 12pm 6pm EST',
on_schedule_email_default: 'volunteer@example.com',
on_proof_webhook_default: 'https://hooks.example.com/proof',
inbox_tracer: 'my-batch-001',
notes: 'Q1 content campaign'
})
});
const { data } = await res.json();
// { job_id: 'uuid', status: 'COMPLETED', estimated_cost: 0 }Add results to a manual job
// Add your own video as a result
await fetch(`${API}/jobs/${jobId}/results`, {
method: 'POST',
headers: auth,
body: JSON.stringify({
post_text: 'Check out this amazing transformation! #fitness',
original_video_url: 'https://example.com/my-video.mp4',
caption: 'Day 30 of my fitness journey #gymtok',
instructions: 'Post to @fitness_page with hashtags in first comment',
scheduled_datetime: '2026-03-15T09:00:00Z'
})
});
// Result is created with approved=true, ready to schedulePoll job until complete
const poll = async (jobId: string): Promise<Job> => {
while (true) {
const { data } = await fetch(`${API}/jobs/${jobId}`, { headers: auth }).then(r => r.json());
if (data.status === 'COMPLETED' || data.status === 'FAILED') return data;
await new Promise(r => setTimeout(r, 5000));
}
};List and approve results
const { data } = await fetch(`${API}/jobs/${jobId}/results`, { headers: auth }).then(r => r.json());
for (const result of data.items.filter((r: Result) => r.match_score >= 80 && !r.approved)) {
await fetch(`${API}/results/${result.result_id}`, {
method: 'PATCH',
headers: auth,
body: JSON.stringify({
approved: true,
caption: 'My custom caption #fyp #viral',
scheduled_datetime: '2026-02-10T15:00:00Z',
on_schedule_email: 'volunteer@example.com'
})
});
}Schedule an approved result
const { data } = await fetch(`${API}/results/${resultId}/schedule`, {
method: 'POST',
headers: auth
}).then(r => r.json());
// data.volunteer_magic_link — send to your volunteer
// data.dedup_status === 'pending' — video processing in backgroundGet calendar month view
const { data } = await fetch(`${API}/calendar/month/2026/2`, {
headers: auth
}).then(r => r.json());
// data.days = { '2026-02-10': [Scheduled, ...], '2026-02-11': [...] }Fire a scheduled post immediately
await fetch(`${API}/scheduled/${scheduledId}/fire-now`, {
method: 'POST',
headers: auth
});Reschedule a post
await fetch(`${API}/scheduled/${scheduledId}`, {
method: 'PATCH',
headers: auth,
body: JSON.stringify({ scheduled_datetime: '2026-02-12T10:00:00Z' })
});Submit proof (volunteer, no auth)
const { data } = await fetch(`${API}/volunteer/${scheduledId}/proof`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proof_url: 'https://www.tiktok.com/@our_brand/video/1234567890',
reported_by: 'volunteer@example.com'
})
}).then(r => r.json());
// { proof_url, proof_timestamp, has_next_task, next_task_link }Key Concepts
- Job Modes:
search(keyword query via TokInsight),channel(scrape @username's videos), ormanual(BYO content — skip discovery, add results directly viaPOST /jobs/:job_id/results). - Publish Modes:
MANUAL_REVIEW(you approve each),AI_REVIEW(Gemini decides),AUTO_APPROVED(auto-approve if match_score >= 70). - Video Deduplication: After scheduling, the video is slightly randomized (zoom, tilt, saturation, speed) to avoid TikTok duplicate detection. Track via
dedup_status. - Tracers:
tracer= unique ID per job.inbox_tracer= batch grouping key for volunteers so they can navigate between tasks. - Volunteer Magic Links: No-auth links sent to volunteers. Format:
{frontend}/volunteer/{scheduled_id}?inbox_tracer={tracer}. - Scheduling: An hourly cron finds posts in the next 60min and creates EventBridge rules. At
scheduled_datetime, the Notifier fires emails + webhooks. UsePOST /scheduled/:id/fire-nowto bypass the schedule and fire immediately. - Rescheduling: Changing
scheduled_datetimevia PATCH is a delete+recreate operation. Fails if already fired. - Credit Cost: ~4.2 credits per video (includes TokInsight search, Gemini analysis, video dedup, email notification). A 5-video job costs ~21 credits. Jobs with
schedule_promptorinstruction_promptadd ~0.04 credits per video per prompt. Manual mode jobs have no upfront cost — credits are only consumed when results are scheduled (dedup + email).