Skip to content

TypeScript SDK

Official TypeScript/JavaScript SDK for the FOTOhub API. Provides full type safety, tree-shaking support, automatic retries, streaming via async iterators, and built-in error handling. Works in Node.js 18+, Deno, and edge runtimes (Cloudflare Workers, Vercel Edge).

Installation

bash
npm install fotohub
bash
pnpm add fotohub
bash
yarn add fotohub
bash
bun add fotohub

Requirements: Node.js 18+ (or any runtime with fetch and ReadableStream support).

Quick Start

typescript
import { FotoHub } from 'fotohub';

const client = new FotoHub({ apiKey: 'fh_live_your_key_here' });

// Generate an image
const result = await client.generateImage({
  prompt: 'A futuristic city at sunset',
  model: 'imagen-4-standard',
});

console.log(`Image: ${result.images[0]}`);
console.log(`Cost: $${result.cost_usd}`);

Client Initialization

With API Key

typescript
import { FotoHub } from 'fotohub';

const client = new FotoHub({
  apiKey: 'fh_live_your_key_here',
  baseUrl: 'https://apis.fotohub.app',  // default
  timeout: 60_000,                          // 60s default
  maxRetries: 3,                            // automatic retries for 429/5xx
});

With Environment Variable

The TypeScript SDK does not read environment variables automatically — pass the key explicitly from process.env:

typescript
import { FotoHub } from 'fotohub';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY! });

Store the key in your .env file and load it (e.g. with dotenv) before constructing the client:

bash
# .env
FOTOHUB_API_KEY=fh_live_your_key_here

Configuration Options

OptionTypeDefaultDescription
apiKeystring— (required)Your API key (starts with fh_live_ or fh_test_)
baseUrlstringhttps://apis.fotohub.appAPI base URL
timeoutnumber60000Request timeout in milliseconds
maxRetriesnumber3Max retries for transient errors (429, 5xx)

Security

Never hardcode API keys in source code. Always use environment variables or a secrets manager (AWS Secrets Manager, Vault, Doppler) in production. Add .env to your .gitignore.

Type-Safe Interfaces

The SDK exports comprehensive TypeScript interfaces for all request and response types.

typescript
import type {
  // Request options
  GenerateImageOptions,
  GenerateVideoOptions,
  GenerateMusicOptions,
  ChatOptions,
  ChatClaudeOptions,

  // Response types
  ImageResult,
  VideoResult,
  MusicResult,
  ChatResult,
  ChatStreamChunk,
  ChatStream,

  // Common types
  BillingInfo,
  BillingBalance,
  Model,
} from 'fotohub';

GenerateImageOptions

typescript
interface GenerateImageOptions {
  /** Text prompt describing the desired image */
  prompt: string;
  /** Model ID. Defaults to 'seedream-5-0-260128' */
  model?: string;
  /** Image width in pixels */
  width?: number;
  /** Image height in pixels */
  height?: number;
  /** Aspect ratio shorthand (e.g., '16:9', '1:1', '9:16') */
  aspect_ratio?: string;
  /** Number of images to generate (1-4) */
  num_images?: number;
  /** Negative prompt for exclusions */
  negative_prompt?: string;
  /** Style preset */
  style?: string;
  /** Generation seed for reproducibility */
  seed?: number;
  /** Guidance / CFG scale */
  guidance_scale?: number;
  /** Number of inference steps */
  steps?: number;
  /** Output format */
  output_format?: 'png' | 'jpeg' | 'webp';
  /** Reference image URL for img2img / style reference */
  reference_image_url?: string;
  /** Strength of the reference image (0.0-1.0) */
  reference_strength?: number;
}

GenerateVideoOptions

typescript
interface GenerateVideoOptions {
  /** Text prompt describing the desired video */
  prompt: string;
  /**
   * Model ID. See GET /v1/models?category=video for the full list. Examples:
   * veo-3.1-generate-001, veo-2.0-generate-001, wan2.2-t2v-plus, kling-v3,
   * hailuo-o2, sora-2.
   *
   * Seedance models are asynchronous and are not reachable through this method —
   * use generateSeedance() instead.
   */
  model?: string;
  /** Video duration in seconds */
  duration?: number;
  /** Aspect ratio (e.g., '16:9', '9:16', '1:1') */
  aspect_ratio?: string;
  /** Input image URL for image-to-video */
  image_url?: string;
  /** Resolution */
  resolution?: '720p' | '1080p' | '4k';
  /** Negative prompt */
  negative_prompt?: string;
  /** Random seed */
  seed?: number;
  /** Guidance scale */
  guidance_scale?: number;
  /** Frames per second */
  fps?: number;
}

ChatOptions

typescript
interface ChatOptions {
  /** Array of message objects */
  messages: ChatMessage[];
  /** Model ID (e.g., 'gemini-flash', 'gemini-pro', 'gpt-4o') */
  model?: string;
  /** System prompt (convenience, prepended to messages) */
  system?: string;
  /** Sampling temperature (0-2) */
  temperature?: number;
  /** Maximum tokens in response */
  max_tokens?: number;
  /** Whether to stream the response */
  stream?: boolean;
  /** Top-p nucleus sampling */
  top_p?: number;
  /** Stop sequences */
  stop?: string | string[];
  /** Frequency penalty (-2.0 to 2.0) */
  frequency_penalty?: number;
  /** Presence penalty (-2.0 to 2.0) */
  presence_penalty?: number;
}

// Premium token-based chat (dot-notation model IDs)
interface ChatClaudeOptions {
  messages: ChatMessage[];
  /** e.g. 'claude-sonnet-4.6', 'claude-haiku-4.5', 'nova-pro' */
  model?: string;
  temperature?: number;
  max_tokens?: number;
  system?: string;
}

interface ChatMessage {
  role: 'system' | 'user' | 'assistant';
  content: string;
}

Image Generation

Basic Generation

typescript
import { FotoHub } from 'fotohub';

const client = new FotoHub({ apiKey: 'fh_live_your_key_here' });

const result = await client.generateImage({
  prompt: 'A futuristic city at sunset',
  model: 'imagen-4-standard',
});

console.log(`Image: ${result.images[0]}`);
console.log(`Cost: $${result.cost_usd}`);

Multiple Images with Options

typescript
const result = await client.generateImage({
  prompt: 'Professional product photography, white background, studio lighting',
  model: 'seedream-5-0-260128',
  aspect_ratio: '1:1',
  num_images: 4,
  negative_prompt: 'blurry, low quality, distorted',
});

// Iterate over all generated images
for (const imageUrl of result.images) {
  console.log(imageUrl);
}

// Access billing information -- the API is prepaid in USD
console.log(`Cost: $${result.cost_usd}`);
console.log(`Balance left: $${result.billing.balance_usd}`);

With Explicit Dimensions

typescript
const result = await client.generateImage({
  prompt: 'A panoramic mountain landscape',
  model: 'imagen-4-standard',
  width: 1920,
  height: 1080,
  seed: 42, // reproducible results
});

ImageResult Response Type

typescript
interface ImageResult {
  /** Model used for generation */
  model: string;
  /** USD charged. Same figure as `billing.cost_usd`. */
  cost_usd?: number;
  /** Always `'USD'`. */
  currency?: 'USD';
  /** Billing information */
  billing: BillingInfo;
  /** Array of generated image URLs */
  images: string[];
  /** Generation metadata */
  metadata?: ImageMetadata;
  /** @deprecated Not sent by the prepaid API. Use `cost_usd`. */
  usd_charged?: number;
}

interface BillingInfo {
  /** USD charged, to six decimal places. */
  cost_usd: number;
  /** Wallet balance AFTER this charge. */
  balance_usd?: number | null;
  currency?: 'USD';
  method?: 'wallet';
  model?: 'prepaid';
}

Do not default usd_charged to 0

It is deprecated and absent on every current response. Code that reads result.usd_charged ?? 0 misreports a real charge as free. Read cost_usd.

Video Generation

Video generation is synchronous — the promise resolves once the video is ready and the result carries the finished video_url. There is no job to poll.

Seedance models

The Seedance family runs asynchronously and is not reachable through generateVideo(). Use generateSeedance(), which submits and polls for you.

typescript
const result = await client.generateVideo({
  prompt: 'A drone flying over a mountain landscape, cinematic',
  model: 'veo-3.1-generate-001',
  duration: 5,
  aspect_ratio: '16:9',
});

console.log(`Video URL: ${result.video_url}`);
console.log(`Cost: $${result.cost_usd}`);

Image-to-Video

typescript
const result = await client.generateVideo({
  prompt: 'Gentle camera zoom, subtle movement in the clouds',
  model: 'veo-3.1-generate-001',
  image_url: 'https://example.com/my-image.jpg',
  duration: 5,
  aspect_ratio: '16:9',
});

VideoResult Response Type

typescript
interface VideoResult {
  /** Model used */
  model: string;
  /** USD charged. Same figure as `billing.cost_usd`. */
  cost_usd?: number;
  /** @deprecated Not sent by the prepaid API. Use `cost_usd`. */
  usd_charged?: number;
  /** Video output URL */
  video_url?: string;
  /** Current status */
  status: 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled';
  /** Video duration in seconds */
  duration: number;
  /** Thumbnail URL */
  thumbnail_url?: string;
}

Seedance (long clips, video editing)

Seedance models are asynchronous: the API answers 202 with a job_id and the render runs in a queue. generateSeedance() submits, polls, and resolves once the job is finished, so the result already contains video_url.

seedance-2-5 (the default) is the only model that produces a 30-second clip in one request, and the only one that accepts a source video for editing or extension. Native audio is included in its price — $0.0107 per 1000 output tokens whether generate_audio is on or off, which works out to about $1.17 for a 5-second 720p clip and $6.99 for a 30-second one.

typescript
const video = await client.generateSeedance({
  prompt:
    'A chef plates a dish in a warm restaurant kitchen: hands dust herbs over ' +
    'seared scallops, steam rises, the camera pushes in slowly.',
  duration: 30,          // 4-30 on 2.5; nothing else reaches past 15
  resolution: '720p',    // 480p | 720p — 1080p and 4K are a 400 on 2.5
  aspect_ratio: '16:9',
  generate_audio: true,  // free on 2.5
  onProgress: (r) => console.log(`${r.status} ${r.progress ?? 0}%`),
});

console.log(video.video_url);
console.log(video.cost_usd); // e.g. 6.99138

720p ceiling

2.5 is not a superset of seedance-2-0-pro. It reaches 30 seconds but stops at 720p; 2.0 Pro reaches 4K but stops at 15 seconds. Requesting a resolution a model does not support returns a 400 rather than downgrading silently, because the price scales with resolution.

Edit or extend an existing video

Attach a source clip and describe the change. The output keeps the source geometry and length, so those are resolved for you and the effective values come back on the result.

typescript
const edited = await client.generateSeedance({
  prompt: 'Replace the grey sky with a clear blue sky and warm afternoon light',
  reference_videos: ['https://s1.fotohub.app/storage/v1/object/public/videos/source.mp4'],
  duration: -1,               // match the source clip's length
});

console.log(edited.task_type);    // "editing"
console.log(edited.aspect_ratio); // "adaptive"

A source video raises the cost, because its frames bill as input tokens on top of the output. Image and audio references do not change the rate. Price the exact call with estimateCost() first if the difference matters.

Face consistency

Register a portrait once (free), then reuse it across generations:

typescript
const asset = await client.registerVideoAsset(
  'https://s1.fotohub.app/storage/v1/object/public/photos/face.jpg'
);

const video = await client.generateSeedance({
  prompt: 'The same woman walks through a night market, neon on wet pavement',
  duration: 15,
  asset_ids: [asset.uri],
});

GenerateSeedanceOptions

typescript
interface GenerateSeedanceOptions {
  /** Text prompt describing the video to generate */
  prompt: string;
  /** Default 'seedance-2-5'. Others: seedance-2-0-pro / -fast / -mini, seedance-1-5-pro-251215, seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015 */
  model?: string;
  /** 2.5: 4-30. 2.0: 4-15. 1.x: 5-10. -1 matches a source clip's length */
  duration?: number;
  /** 2.5 accepts only 480p and 720p; seedance-2-0-pro accepts all four */
  resolution?: '480p' | '720p' | '1080p' | '4K';
  /** 16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9 | adaptive */
  aspect_ratio?: string;
  /** Native soundtrack. Free on 2.5 */
  generate_audio?: boolean;
  /** First frame (image-to-video) */
  image_url?: string;
  /** Final frame */
  last_frame_url?: string;
  /** Up to 30 on 2.5 (9 on 2.0). URLs or { mimeType, base64 } */
  reference_images?: SeedanceReference[];
  /** Up to 10 on 2.5 (3 on 2.0). Raises the rate — source frames bill as input */
  reference_videos?: SeedanceReference[];
  /** Up to 10 on 2.5 (3 on 2.0). Needs at least one image or video reference */
  reference_audios?: SeedanceReference[];
  /** Pre-registered asset:// portrait ids from registerVideoAsset() */
  asset_ids?: string[];
  /** Output container. 2.5 only */
  output_format?: 'mp4' | 'mov';
  negative_prompt?: string;
  seed?: number;
  /** HTTPS URL POSTed once the job reaches a terminal state */
  callback_url?: string;
  /** Let the model pick the aspect ratio */
  smart_ratio?: boolean;
  /** Let the model pick the duration */
  smart_duration?: boolean;
  /** Milliseconds between status checks (default 10 000) */
  pollInterval?: number;
  /** Max milliseconds to wait (default 1 800 000) */
  maxWait?: number;
  /** Called on every poll with the in-flight job */
  onProgress?: (result: SeedanceResult) => void;
}

SeedanceResult Response Type

typescript
interface SeedanceResult extends VideoResult {
  /** 0-100 while rendering */
  progress?: number;
  /** Resolution actually rendered */
  resolution?: string;
  /** Aspect ratio actually rendered ('adaptive' for editing/extension) */
  aspect_ratio?: string;
  /** Whether a native soundtrack was generated */
  generate_audio?: boolean;
  /** Inferred task: t2v | reference | editing | extension | frames */
  task_type?: string;
  /** USD charged. Same figure as `billing.cost_usd`. */
  cost_usd?: number;
  /** Charge detail — `cost_usd`, `balance_usd`, and a per-leg token breakdown */
  billing?: Record<string, unknown>;
  poll_url?: string;
  estimated_seconds?: number;
  created_at?: string;
  completed_at?: string;
  error_message?: string;
}

Throws JobTimeoutError if the job outlives maxWait (it may still finish — the job id is on the error) and JobFailedError if the render fails, in which case the wallet is refunded server-side and the response says so explicitly.

Music Generation

typescript
const result = await client.generateMusic({
  prompt: 'Upbeat electronic music with synthesizers, 120 BPM',
  model: 'minimax',
  duration: 30,
});

console.log(`Audio URL: ${result.audio_url}`);
console.log(`Duration: ${result.duration}s`);
console.log(`Cost: $${result.cost_usd}`);   // per minute: 30s on minimax is $0.0125

MusicResult Response Type

typescript
interface MusicResult {
  /** Model used */
  model: string;
  /** USD charged. Music is billed per minute of generated audio. */
  cost_usd?: number;
  /** @deprecated Not sent by the prepaid API. Use `cost_usd`. */
  usd_charged?: number;
  /** URL to the generated audio file */
  audio_url: string;
  /** Duration in seconds */
  duration: number;
}

Only two providers are accepted: minimax ($0.025/min) and elevenlabs ($0.045/min).

Chat Completions

Standard Chat (OpenAI-compatible)

Billed from the real token counts of the completion. Four ids are accepted: gemini-flash, gemini-pro, gpt-4o, claude-sonnet.

typescript
const chat = await client.chat({
  messages: [{ role: 'user', content: 'Explain quantum computing' }],
  model: 'gemini-flash',
  temperature: 0.7,
  max_tokens: 1024,
});

console.log(chat.choices[0].message.content);
console.log(`Cost: $${chat.cost_usd}`);
console.log(`Tokens: ${chat.usage.prompt_tokens} in / ${chat.usage.completion_tokens} out`);

Premium Chat (Token-Based Billing)

typescript
const chat = await client.chatClaude({
  messages: [{ role: 'user', content: 'Hello' }],
  model: 'claude-sonnet-4.6',
  system: 'You are a helpful assistant. Be concise.',
});

console.log(chat.choices[0].message.content);
console.log(`Tokens: ${chat.usage.total_tokens}`);

With System Prompt and Parameters

typescript
const chat = await client.chat({
  messages: [
    { role: 'system', content: 'You are a creative writing assistant.' },
    { role: 'user', content: 'Write a short story about a robot' },
  ],
  model: 'gemini-flash',
  temperature: 1.2,
  max_tokens: 2048,
  top_p: 0.9,
  stop: ['THE END'],
});

ChatResult Response Type

typescript
interface ChatResult {
  /** Unique completion ID */
  id: string;
  /** Model used */
  model: string;
  /** USD charged, derived from the real token counts below. */
  cost_usd?: number;
  /** @deprecated Not sent by the prepaid API. Use `cost_usd`. */
  usd_charged?: number;
  /** Completion choices */
  choices: Array<{
    index: number;
    message: { role: 'assistant'; content: string };
    finish_reason: 'stop' | 'length' | 'content_filter' | null;
  }>;
  /** Token usage */
  usage: {
    prompt_tokens: number;
    completion_tokens: number;
    total_tokens: number;
  };
  /** Billing information */
  billing?: {
    cost_usd: number;
    balance_usd?: number | null;
    currency?: 'USD';
    /** `'tokens'` on chat — the charge came from the counts above. */
    basis?: 'tokens' | 'flat_fallback';
    /** Per-leg breakdown: input tokens and output tokens priced separately. */
    legs?: Array<Record<string, unknown>>;
  };
}

Streaming

client.chatStream() yields nothing

chatStream() posts to /v1/ai/chat/completions, which does not stream — it accepts stream: true for OpenAI compatibility and returns one complete JSON body. The SDK's SSE parser finds no data: frames in that body, so the iterator completes after zero chunks and throws no error, while the request is still billed. Do not use it until it is repointed.

The one streaming endpoint is POST /v1/ai/agent/stream, which has no SDK wrapper yet. Call it with fetch, as below. Full reference: the Streaming Guide.

Basic Streaming

Frames are discriminated by a type field (text_delta, tool_use, done, error), not by choices[].delta, and the stream is terminated by data: [DONE].

typescript
const response = await fetch('https://apis.fotohub.app/v1/ai/agent/stream', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'claude-sonnet-4.6',
    messages: [{ role: 'user', content: 'Write a story about space exploration' }],
  }),
});
// Auth and validation fail before the stream opens, so they are real statuses.
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);

const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';

outer: while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  // One read() can end mid-frame — buffer to the blank-line separator.
  buffer += decoder.decode(value, { stream: true });
  const frames = buffer.split('\n\n');
  buffer = frames.pop() ?? '';

  for (const raw of frames) {
    if (!raw.startsWith('data: ')) continue;
    const data = raw.slice(6).trim();
    if (data === '[DONE]') break outer;

    const frame = JSON.parse(data);
    if (frame.type === 'text_delta') {
      process.stdout.write(frame.text);
    } else if (frame.type === 'error') {
      throw new Error(frame.message);
    }
  }
}

Collecting Full Response from Stream

Wrap the loop above in a helper so callers get the text plus the final done metadata:

typescript
interface AgentFrame {
  type: 'text_delta' | 'tool_use' | 'done' | 'error';
  text?: string;
  message?: string;
  usage?: { input_tokens: number; output_tokens: number; total_tokens: number };
  billing?: { cost_usd: number };
}

async function* agentFrames(messages: unknown[], model = 'claude-sonnet-4.6') {
  const response = await fetch('https://apis.fotohub.app/v1/ai/agent/stream', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model, messages }),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) return;
    buffer += decoder.decode(value, { stream: true });
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const raw of frames) {
      if (!raw.startsWith('data: ')) continue;
      const data = raw.slice(6).trim();
      if (data === '[DONE]') return;
      yield JSON.parse(data) as AgentFrame;
    }
  }
}

let fullContent = '';
let usage: AgentFrame['usage'] | undefined;

for await (const frame of agentFrames([
  { role: 'user', content: 'List 10 programming languages' },
])) {
  if (frame.type === 'text_delta') fullContent += frame.text;
  if (frame.type === 'done') usage = frame.usage;
  if (frame.type === 'error') throw new Error(frame.message);
}

console.log('Full response:', fullContent);
// usage may be undefined: the done frame is skipped when nothing was generated.
console.log('Tokens used:', usage?.total_tokens ?? 'unknown');

done is optional, [DONE] is not

The done frame is omitted when the turn produced no tokens at all, and replaced by an error frame when generation succeeded but billing settlement failed. Exit on [DONE]; treat done as optional metadata. A loop that waits for done can hang.

Streaming to HTTP Response (Server-Sent Events)

Re-emit text_delta frames rather than proxying upstream frames verbatim, so your own wire format stays under your control:

typescript
// Express.js / Node.js HTTP handler
app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  try {
    for await (const frame of agentFrames(req.body.messages)) {
      if (frame.type === 'text_delta') {
        res.write(`data: ${JSON.stringify({ text: frame.text })}\n\n`);
      } else if (frame.type === 'error') {
        res.write(`data: ${JSON.stringify({ error: frame.message })}\n\n`);
        break;
      }
    }
  } finally {
    res.write('data: [DONE]\n\n');
    res.end();
  }
});

ChatStreamChunk Type

This type ships in the SDK and describes the OpenAI chunk shape that chatStream() expects. Nothing on the API emits it — it is kept for the signature only. Note there is no usage field on it.

typescript
interface ChatStreamChunk {
  id: string;
  object: 'chat.completion.chunk';
  created: number;
  model: string;
  choices: Array<{
    index: number;
    delta: { role?: 'assistant'; content?: string };
    finish_reason: 'stop' | 'length' | 'content_filter' | null;
  }>;
}

Next.js Integration

App Router (Route Handler)

typescript
// app/api/generate/route.ts
import { FotoHub } from 'fotohub';
import { NextResponse } from 'next/server';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY! });

export async function POST(request: Request) {
  const { prompt, model } = await request.json();

  try {
    const result = await client.generateImage({
      prompt,
      model: model || 'imagen-4-standard',
    });

    return NextResponse.json({
      images: result.images,
      billing: result.billing,
    });
  } catch (e) {
    if (e instanceof Error) {
      return NextResponse.json(
        { error: e.message },
        { status: 500 }
      );
    }
    throw e;
  }
}

App Router with Streaming

Proxy /v1/ai/agent/stream and forward the text. Your API key stays server-side:

typescript
// app/api/chat/route.ts
export async function POST(request: Request) {
  const { messages } = await request.json();

  const upstream = await fetch('https://apis.fotohub.app/v1/ai/agent/stream', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model: 'claude-sonnet-4.6', messages }),
  });

  // Pre-stream failures (401, 400, 429) are real statuses — pass them through
  // instead of opening an empty 200 stream.
  if (!upstream.ok) {
    return new Response(await upstream.text(), { status: upstream.status });
  }

  const encoder = new TextEncoder();
  const decoder = new TextDecoder();

  const readableStream = new ReadableStream({
    async start(controller) {
      const reader = upstream.body!.getReader();
      let buffer = '';
      try {
        outer: while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          buffer += decoder.decode(value, { stream: true });
          const frames = buffer.split('\n\n');
          buffer = frames.pop() ?? '';

          for (const raw of frames) {
            if (!raw.startsWith('data: ')) continue;
            const data = raw.slice(6).trim();
            if (data === '[DONE]') break outer;

            const frame = JSON.parse(data);
            if (frame.type === 'text_delta') {
              controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: frame.text })}\n\n`));
            } else if (frame.type === 'error') {
              controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: frame.message })}\n\n`));
              break outer;
            }
          }
        }
      } finally {
        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
        controller.close();
        reader.releaseLock();
      }
    },
  });

  return new Response(readableStream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

Server Action

typescript
// app/actions/generate.ts
'use server';

import { FotoHub } from 'fotohub';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY! });

export async function generateImage(prompt: string) {
  const result = await client.generateImage({
    prompt,
    model: 'imagen-4-standard',
    aspect_ratio: '16:9',
  });

  return {
    imageUrl: result.images[0],
    costUsd: result.cost_usd,
  };
}

Edge Runtime Support

typescript
// app/api/chat/route.ts
import { FotoHub } from 'fotohub';

// Works on Vercel Edge, Cloudflare Workers, Deno Deploy
export const runtime = 'edge';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY! });

export async function POST(request: Request) {
  const { messages } = await request.json();

  const chat = await client.chat({
    messages,
    model: 'gemini-flash',
  });

  return Response.json(chat);
}

Stability AI Tools

FOTOhub exposes 13 Stability AI image-editing tools (upscaling, background removal, inpainting, outpainting, search & replace, recolor, style transfer, and more) through a single RPC endpoint: POST /stability/{tool_id}. Every tool takes a base64-encoded input image and returns a base64-encoded output image — there are no URLs on the way in or out.

Authentication: Supabase session required

The Stability tools currently require a logged-in Supabase session token (JWT), not a standalone fh_live_* API key. Pass the session's access_token where the SDKs expect apiKey (it is sent verbatim as Authorization: Bearer <token>). Requests authenticated with only an API key are rejected.

Every tool resolves to a StabilityResult:

typescript
interface StabilityResult {
  image: string;        // base64-encoded output image (NOT a URL)
  tool: string;         // the tool_id that ran
  seed: number | null;  // seed used, when the model returns one
  cost_usd: number;     // USD charged for the call
}

The 13 tool IDs, their price, and which extra inputs they consume. Every price below is ✅ verified against Stability's published rate:

tool_idUSDMaskPromptReference
fast-upscale0.03
outpaint0.06optionalrequired
erase-object0.07required
inpaint0.07requiredrequired
remove-background0.07
search-replace0.07required
search-recolor0.07required
style-guide0.07requiredrequired
control-sketch0.07required
control-structure0.07required
style-transfer0.08required
conservative-upscale0.40
creative-upscale0.60

The two heavy upscalers are 13-20x the fast one

conservative-upscale at $0.40 and creative-upscale at $0.60 cost more than a 5-second Veo 3.1 Lite video. Use fast-upscale at $0.03 unless you specifically need detail synthesis.

List Available Tools

typescript
listStabilityTools(): Promise<StabilityTool[]>

Returns all available Stability AI tools with their USD price and input requirements (GET /stability/tools).

typescript
import { FotoHub } from 'fotohub';

// Pass your Supabase session access_token, not an fh_live_* API key.
const client = new FotoHub({ apiKey: process.env.SUPABASE_ACCESS_TOKEN! });

const tools = await client.listStabilityTools();
for (const tool of tools) {
  console.log(`$${tool.price_usd} per ${tool.unit} — ${tool.id} (mask=${tool.requires_mask}, prompt=${tool.requires_prompt})`);
}
python
from fotohub import FotoHub

# Pass your Supabase session access_token, not an fh_live_* API key.
client = FotoHub(api_key=os.environ["SUPABASE_ACCESS_TOKEN"])

for tool in client.stability_tools():
    print(f"{tool['id']}: ${tool['price_usd']} per {tool['unit']} (mask={tool['requires_mask']})")
go
package main

import (
    "fmt"
    "os"
    "github.com/fotohubapp/sdk-go"
)

func main() {
    // Pass your Supabase session access_token, not an fh_live_* API key.
    client := fotohub.NewClient(os.Getenv("SUPABASE_ACCESS_TOKEN"))

    tools, _ := client.ListStabilityTools()
    for _, tool := range tools {
        fmt.Printf("%s: $%.2f\n", tool.ID, tool.PriceUSD)
    }
}
bash
# Requires a Supabase session token (JWT), not an fh_live_* API key.
curl https://apis.fotohub.app/stability/tools \
  -H "Authorization: Bearer <SUPABASE_JWT>"

Upscale Image

typescript
stabilityUpscale(imageBase64: string, type?: 'fast' | 'creative' | 'conservative'): Promise<StabilityResult>

Upscales an image to a higher resolution. type defaults to 'fast' ($0.03); 'conservative' costs $0.40 and 'creative' costs $0.60. Each mode maps to the tool IDs fast-upscale, conservative-upscale, and creative-upscale.

The examples below show the base64 input/output pattern once — read a file, base64-encode it, send it, and decode the returned string. Later tools reuse the same imageBase64 variable without repeating the file-reading boilerplate.

typescript
import { FotoHub } from 'fotohub';
import { readFileSync, writeFileSync } from 'fs';

const client = new FotoHub({ apiKey: process.env.SUPABASE_ACCESS_TOKEN! });

// Input images are base64 strings, not URLs.
const imageBase64 = readFileSync('photo.jpg').toString('base64');

const result = await client.stabilityUpscale(imageBase64, 'creative');

// Output is base64 too — decode it to save the file.
writeFileSync('photo-upscaled.png', Buffer.from(result.image, 'base64'));
console.log(`Cost: $${result.cost_usd}, seed: ${result.seed}`);
python
import base64
from fotohub import FotoHub

client = FotoHub(api_key=os.environ["SUPABASE_ACCESS_TOKEN"])

# Input images are base64 strings, not URLs.
with open("photo.jpg", "rb") as f:
    image_base64 = base64.b64encode(f.read()).decode()

result = client.stability_upscale(image_base64, type="creative")

# Output is base64 too — decode it to save the file.
with open("photo-upscaled.png", "wb") as f:
    f.write(base64.b64decode(result["image"]))
print(f"Cost: ${result['cost_usd']}")
go
package main

import (
    "encoding/base64"
    "fmt"
    "os"
    "github.com/fotohubapp/sdk-go"
)

func main() {
    client := fotohub.NewClient(os.Getenv("SUPABASE_ACCESS_TOKEN"))

    // Input images are base64 strings, not URLs.
    data, _ := os.ReadFile("photo.jpg")
    imageB64 := base64.StdEncoding.EncodeToString(data)

    result, _ := client.StabilityUpscale(imageB64, "creative")

    // Output is base64 too — decode it to save the file.
    out, _ := base64.StdEncoding.DecodeString(result.Image)
    os.WriteFile("photo-upscaled.png", out, 0644)
    fmt.Printf("Cost: $%.2f\n", result.CostUSD)
}
bash
# tool_id = fast-upscale | conservative-upscale | creative-upscale
# Requires a Supabase session token (JWT), not an fh_live_* API key.
IMAGE_B64=$(base64 -w0 photo.jpg)
curl -X POST https://apis.fotohub.app/stability/creative-upscale \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"output_format\": \"png\"}"
# → { "image": "<base64>", "tool": "creative-upscale", "seed": null, "cost_usd": 0.6 }

Remove Background

typescript
stabilityRemoveBackground(imageBase64: string): Promise<StabilityResult>

Removes the background from an image, returning a transparent PNG in result.image (base64). Costs $0.07.

typescript
// imageBase64 = readFileSync('product.jpg').toString('base64')
const result = await client.stabilityRemoveBackground(imageBase64);
writeFileSync('product-nobg.png', Buffer.from(result.image, 'base64'));
python
# image_base64 = base64.b64encode(open("product.jpg", "rb").read()).decode()
result = client.stability_remove_background(image_base64)
open("product-nobg.png", "wb").write(base64.b64decode(result["image"]))
go
// imageB64 = base64.StdEncoding.EncodeToString(data)
result, _ := client.StabilityRemoveBackground(imageB64)
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("product-nobg.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 product.jpg)
curl -X POST https://apis.fotohub.app/stability/remove-background \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\"}"

Erase Object

typescript
stabilityErase(imageBase64: string, maskBase64: string): Promise<StabilityResult>

Runs the erase-object tool: erases the masked region from an image and fills it with context-aware content. The mask is required and is a base64 image where white marks the area to erase. Costs $0.07.

typescript
const imageBase64 = readFileSync('photo.jpg').toString('base64');
const maskBase64 = readFileSync('mask.png').toString('base64');

const result = await client.stabilityErase(imageBase64, maskBase64);
writeFileSync('photo-erased.png', Buffer.from(result.image, 'base64'));
python
image_base64 = base64.b64encode(open("photo.jpg", "rb").read()).decode()
mask_base64 = base64.b64encode(open("mask.png", "rb").read()).decode()

result = client.stability_erase(image_base64, mask_base64)
open("photo-erased.png", "wb").write(base64.b64decode(result["image"]))
go
imgData, _ := os.ReadFile("photo.jpg")
maskData, _ := os.ReadFile("mask.png")
imageB64 := base64.StdEncoding.EncodeToString(imgData)
maskB64 := base64.StdEncoding.EncodeToString(maskData)

result, _ := client.StabilityErase(imageB64, maskB64)
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("photo-erased.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 photo.jpg)
MASK_B64=$(base64 -w0 mask.png)
curl -X POST https://apis.fotohub.app/stability/erase-object \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"mask\": \"$MASK_B64\"}"

Inpaint

typescript
stabilityInpaint(imageBase64: string, maskBase64: string, prompt: string): Promise<StabilityResult>

Fills a masked region with AI-generated content guided by a text prompt. Both the mask and prompt are required. Costs $0.07.

typescript
const imageBase64 = readFileSync('room.jpg').toString('base64');
const maskBase64 = readFileSync('mask.png').toString('base64');

const result = await client.stabilityInpaint(
  imageBase64,
  maskBase64,
  'A modern leather couch'
);
writeFileSync('room-inpainted.png', Buffer.from(result.image, 'base64'));
python
image_base64 = base64.b64encode(open("room.jpg", "rb").read()).decode()
mask_base64 = base64.b64encode(open("mask.png", "rb").read()).decode()

result = client.stability_inpaint(image_base64, mask_base64, "A modern leather couch")
open("room-inpainted.png", "wb").write(base64.b64decode(result["image"]))
go
result, _ := client.StabilityInpaint(imageB64, maskB64, "A modern leather couch")
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("room-inpainted.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 room.jpg)
MASK_B64=$(base64 -w0 mask.png)
curl -X POST https://apis.fotohub.app/stability/inpaint \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"mask\": \"$MASK_B64\", \"prompt\": \"A modern leather couch\"}"

Outpaint

typescript
stabilityOutpaint(imageBase64: string, padding: { left?: number; right?: number; up?: number; down?: number }): Promise<StabilityResult>

Extends an image beyond its borders in the specified directions (pixels to extend per side). A prompt is required by the model (pass one via runStabilityTool if you need to guide the fill); a mask is optional. Costs $0.06.

typescript
// imageBase64 = readFileSync('landscape.jpg').toString('base64')
const result = await client.stabilityOutpaint(imageBase64, {
  left: 256,
  right: 256,
  up: 0,
  down: 128,
});
writeFileSync('landscape-extended.png', Buffer.from(result.image, 'base64'));
python
result = client.stability_outpaint(image_base64, left=256, right=256, up=0, down=128)
open("landscape-extended.png", "wb").write(base64.b64decode(result["image"]))
go
result, _ := client.StabilityOutpaint(imageB64, fotohub.OutpaintPadding{
    Left:  256,
    Right: 256,
    Up:    0,
    Down:  128,
})
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("landscape-extended.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 landscape.jpg)
curl -X POST https://apis.fotohub.app/stability/outpaint \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"left\": 256, \"right\": 256, \"up\": 0, \"down\": 128, \"prompt\": \"open sky and rolling hills\"}"

Search and Replace

typescript
stabilitySearchReplace(imageBase64: string, searchPrompt: string, replacePrompt: string): Promise<StabilityResult>

Finds objects matching searchPrompt in the image and replaces them with content described by replacePrompt. Under the hood the replacement text is sent as prompt and the target as search_prompt. Costs $0.07.

typescript
// imageBase64 = readFileSync('street.jpg').toString('base64')
const result = await client.stabilitySearchReplace(
  imageBase64,
  'parked cars',
  'flower beds with roses'
);
writeFileSync('street-replaced.png', Buffer.from(result.image, 'base64'));
python
result = client.stability_search_replace(
    image_base64,
    search_prompt="parked cars",
    prompt="flower beds with roses",
)
open("street-replaced.png", "wb").write(base64.b64decode(result["image"]))
go
result, _ := client.StabilitySearchReplace(imageB64, "parked cars", "flower beds with roses")
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("street-replaced.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 street.jpg)
curl -X POST https://apis.fotohub.app/stability/search-replace \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"search_prompt\": \"parked cars\", \"prompt\": \"flower beds with roses\"}"

Recolor

typescript
stabilityRecolor(imageBase64: string, searchPrompt: string, newColor: string): Promise<StabilityResult>

Recolors a specific object in the image. searchPrompt selects the object to recolor and newColor describes the target color. This maps to the search-recolor tool (search_prompt = the object, prompt = the new color). Costs $0.07.

typescript
// imageBase64 = readFileSync('car.jpg').toString('base64')
const result = await client.stabilityRecolor(
  imageBase64,
  'the car body',
  'deep metallic blue'
);
writeFileSync('car-recolored.png', Buffer.from(result.image, 'base64'));
python
result = client.stability_recolor(
    image_base64,
    search_prompt="the car body",
    prompt="deep metallic blue",
)
open("car-recolored.png", "wb").write(base64.b64decode(result["image"]))
go
result, _ := client.StabilityRecolor(imageB64, "the car body", "deep metallic blue")
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("car-recolored.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 car.jpg)
curl -X POST https://apis.fotohub.app/stability/search-recolor \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"search_prompt\": \"the car body\", \"prompt\": \"deep metallic blue\"}"

Style Transfer

typescript
stabilityStyleTransfer(imageBase64: string, referenceBase64: string): Promise<StabilityResult>

Applies the visual style of a reference image to the content of the source image. The reference image is required and is sent as the base64 reference field. Costs $0.08.

typescript
const imageBase64 = readFileSync('photo.jpg').toString('base64');
const referenceBase64 = readFileSync('watercolor-style.jpg').toString('base64');

const result = await client.stabilityStyleTransfer(imageBase64, referenceBase64);
writeFileSync('photo-styled.png', Buffer.from(result.image, 'base64'));
python
image_base64 = base64.b64encode(open("photo.jpg", "rb").read()).decode()
reference_base64 = base64.b64encode(open("watercolor-style.jpg", "rb").read()).decode()

result = client.stability_style_transfer(image_base64, reference_base64)
open("photo-styled.png", "wb").write(base64.b64decode(result["image"]))
go
result, _ := client.StabilityStyleTransfer(imageB64, referenceB64)
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("photo-styled.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 photo.jpg)
REF_B64=$(base64 -w0 watercolor-style.jpg)
curl -X POST https://apis.fotohub.app/stability/style-transfer \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"reference\": \"$REF_B64\"}"

Run Any Tool Directly

For tools without a dedicated helper (style-guide, control-sketch, control-structure) — or to pass extra options like negative_prompt, seed, or output_format — call runStabilityTool(toolId, options) directly.

typescript
runStabilityTool(toolId: string, options: {
  image: string; mask?: string; prompt?: string; reference?: string;
  search_prompt?: string; output_format?: string; seed?: number;
  negative_prompt?: string; left?: number; right?: number; up?: number; down?: number;
}): Promise<StabilityResult>
typescript
// imageBase64 = readFileSync('sketch.png').toString('base64')
const result = await client.runStabilityTool('control-sketch', {
  image: imageBase64,
  prompt: 'a photorealistic sports car, studio lighting',
  seed: 42,
  output_format: 'png',
});
writeFileSync('from-sketch.png', Buffer.from(result.image, 'base64'));
python
result = client.stability_run(
    "control-sketch",
    image_base64,
    prompt="a photorealistic sports car, studio lighting",
    seed=42,
    output_format="png",
)
open("from-sketch.png", "wb").write(base64.b64decode(result["image"]))
go
result, _ := client.RunStabilityTool("control-sketch", fotohub.StabilityOptions{
    Image:        imageB64,
    Prompt:       "a photorealistic sports car, studio lighting",
    Seed:         42,
    OutputFormat: "png",
})
out, _ := base64.StdEncoding.DecodeString(result.Image)
os.WriteFile("from-sketch.png", out, 0644)
bash
IMAGE_B64=$(base64 -w0 sketch.png)
curl -X POST https://apis.fotohub.app/stability/control-sketch \
  -H "Authorization: Bearer <SUPABASE_JWT>" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$IMAGE_B64\", \"prompt\": \"a photorealistic sports car, studio lighting\", \"seed\": 42, \"output_format\": \"png\"}"

3D Generation

Generate 3D models from images or text prompts. Supports GLB, OBJ, STL, and USDZ output formats.

Generate 3D Model

typescript
generate3D(opts: Generate3DOptions): Promise<Job>

Starts a 3D model generation job. Returns immediately with a job ID for polling.

typescript
import { FotoHub } from 'fotohub';
import { readFileSync } from 'fs';

const client = new FotoHub({ apiKey: 'fh_live_your_api_key' });

// Image to 3D
const imageBase64 = readFileSync('product.jpg').toString('base64');
const job = await client.generate3D({
  mode: 'image-to-3d',
  model: 'fh-lite-3d',
  image: imageBase64,
  format: 'glb',
  quality: 'standard',
});

console.log(`Job ID: ${job.id}, Status: ${job.status}`);
python
import base64
from fotohub import FotoHub

client = FotoHub(api_key="fh_live_your_api_key")

with open("product.jpg", "rb") as f:
    image_b64 = base64.b64encode(f.read()).decode()

job = client.generate_3d(
    mode="image-to-3d",
    model="fh-lite-3d",
    image=image_b64,
    format="glb",
    quality="standard",
)
print(f"Job ID: {job.id}, Status: {job.status}")
go
package main

import (
    "encoding/base64"
    "fmt"
    "os"
    "github.com/fotohubapp/sdk-go"
)

func main() {
    client := fotohub.NewClient("fh_live_your_api_key")

    data, _ := os.ReadFile("product.jpg")
    imageB64 := base64.StdEncoding.EncodeToString(data)

    job, _ := client.Generate3D(fotohub.Generate3DOptions{
        Mode:    "image-to-3d",
        Model:   "fh-lite-3d",
        Image:   imageB64,
        Format:  "glb",
        Quality: "standard",
    })
    fmt.Printf("Job ID: %s, Status: %s\n", job.ID, job.Status)
}
bash
curl -X POST https://apis.fotohub.app/v1/3d/generate \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "image-to-3d",
    "model": "fh-lite-3d",
    "image": "<base64_encoded_image>",
    "format": "glb",
    "quality": "standard"
  }'

Get 3D Job Status

typescript
get3DStatus(jobId: string): Promise<Status>

Retrieves the current status and progress of a 3D generation job.

typescript
const status = await client.get3DStatus('job_abc123');
console.log(`Status: ${status.status}`);  // 'queued' | 'processing' | 'completed' | 'failed'
console.log(`Progress: ${status.progress}%`);
if (status.status === 'completed') {
  console.log(`Download: ${status.url}`);
}
python
status = client.get_3d_status("job_abc123")
print(f"Status: {status.status}")
print(f"Progress: {status.progress}%")
if status.status == "completed":
    print(f"Download: {status.url}")
go
status, _ := client.Get3DStatus("job_abc123")
fmt.Printf("Status: %s\n", status.Status)
fmt.Printf("Progress: %d%%\n", status.Progress)
if status.Status == "completed" {
    fmt.Printf("Download: %s\n", status.URL)
}
bash
curl -X GET https://apis.fotohub.app/v1/3d/status/job_abc123 \
  -H "Authorization: Bearer fh_live_your_api_key"

Wait for 3D Completion

typescript
waitFor3D(jobId: string, options?: { pollInterval?: number; timeout?: number; onProgress?: (status: Status) => void }): Promise<Result>

Polls a 3D generation job until completion or timeout. Returns the final result with download URL.

typescript
const result = await client.waitFor3D('job_abc123', {
  pollInterval: 3000,   // check every 3 seconds
  timeout: 120_000,     // give up after 2 minutes
  onProgress: (s) => console.log(`${s.status}: ${s.progress}%`),
});

console.log(`3D Model URL: ${result.url}`);
console.log(`Format: ${result.format}`);
console.log(`Cost: $${result.billing.cost_usd}`);
python
result = client.wait_for_3d(
    "job_abc123",
    poll_interval=3.0,
    timeout=120.0,
    on_progress=lambda s: print(f"{s.status}: {s.progress}%"),
)
print(f"3D Model URL: {result.url}")
print(f"Format: {result.format}")
print(f"Cost: ${result.billing.cost_usd}")
go
result, _ := client.WaitFor3D("job_abc123", fotohub.WaitOptions{
    PollInterval: 3 * time.Second,
    Timeout:      120 * time.Second,
    OnProgress: func(s fotohub.Status) {
        fmt.Printf("%s: %d%%\n", s.Status, s.Progress)
    },
})
fmt.Printf("3D Model URL: %s\n", result.URL)
fmt.Printf("Cost: $%.6f\n", result.Billing.CostUSD)
bash
# Poll manually until status is "completed"
while true; do
  STATUS=$(curl -s https://apis.fotohub.app/v1/3d/status/job_abc123 \
    -H "Authorization: Bearer fh_live_your_api_key")
  echo "$STATUS" | jq '.status'
  echo "$STATUS" | jq -e '.status == "completed"' && break
  sleep 3
done

List 3D Models

typescript
list3DModels(): Promise<Model[]>

Returns all available 3D generation models with their USD price and supported modes. price_usd comes from the same rate table the charge uses, so it cannot drift from what you are billed.

typescript
const models = await client.list3DModels();
for (const m of models) {
  console.log(`${m.name} (${m.id}): $${m.price_usd} per ${m.unit} — ${m.speed}`);
  console.log(`  Mode: ${m.mode}`);
}
python
models = client.list_3d_models()
for m in models:
    print(f"{m.name} ({m.id}): ${m.price_usd} per {m.unit} - {m.speed}")
    print(f"  Mode: {m.mode}")
go
models, _ := client.List3DModels()
for _, m := range models {
    fmt.Printf("%s (%s): $%.6f per %s - %s\n", m.Name, m.ID, m.PriceUSD, m.Unit, m.Speed)
    fmt.Printf("  Mode: %s\n", m.Mode)
}
bash
curl -X GET https://apis.fotohub.app/v1/ai/generate/3d/models \
  -H "Authorization: Bearer fh_live_your_api_key"

Available 3D Models

ModelUSDSpeedMode
fh-lite-3d0.160772~3simage-to-3d
fh-text-3d0.267953~25stext-to-3d
fh-pro-3d0.803859~60simage-to-3d

Billing

Manage the prepaid USD wallet, pricing information, transactions, and top-up packages. The API is prepaid — there are no credits, and nothing here is denominated in them.

Get Balance

typescript
getBalance(): Promise<BillingBalance>

Returns your prepaid wallet balance and this month's spend, both in USD. The credits field this used to return came from user_usage_tracker, the web app's subscription counter — it told API developers they had hundreds of credits available while their spendable API balance was $0. The API is prepaid and credits cannot pay for it. Read the tier separately with getCurrentTier().

typescript
const balance = await client.getBalance();
console.log(`Balance: $${balance.wallet.balance_usd}`);
console.log(`Spent this month: $${balance.spend.this_month_usd}`);
python
balance = client.get_balance()
print(f"Balance: ${balance['wallet']['balance_usd']}")
print(f"Spent this month: ${balance['spend']['this_month_usd']}")
go
balance, _ := client.GetBalance()
fmt.Printf("Balance: $%.6f\n", balance.Wallet.BalanceUSD)
fmt.Printf("Spent this month: $%.6f\n", balance.Spend.ThisMonthUSD)
bash
curl -X GET https://apis.fotohub.app/v1/billing/balance \
  -H "Authorization: Bearer fh_live_your_api_key"

Get Pricing

typescript
getPricing(): Promise<PricingCatalog>

Returns the full USD pricing catalog. margin_info states the rule that applies to every figure on it: the provider's own rate, 1:1, with no platform fee added, billed from your prepaid wallet.

Prefer GET /v1/pricing (the Model Pricing page) for anything you display or budget against. Both endpoints are USD, but this one rounds each entry to four decimals, so a fraction-of-a-cent leg (prompt enhancement is $0.0004) reads as 0.0004 here while /v1/pricing gives you the unrounded per-unit rate bill_operation actually charges. There is no credit column: credit_costs was removed, because an API call cannot be paid for in credits — a key holding web-app credits and a $0 wallet gets HTTP 402.

typescript
const pricing = await client.getPricing();
console.log(pricing.margin_info);
python
pricing = client.get_pricing()
print(pricing["margin_info"])
go
pricing, _ := client.GetPricing()
fmt.Println(pricing.MarginInfo)
bash
curl -X GET https://apis.fotohub.app/v1/billing/pricing \
  -H "Authorization: Bearer fh_live_your_api_key"

Get Plans

typescript
getPlans(): Promise<ApiPlan[]>

There are no plans to list

This endpoint returns an empty array. The API sells no subscription: rate limits come from a tier that is derived from your wallet balance and lifetime spend, and POST /v1/tiers/subscribe answers 410. It is still served as a 200 so an integration already calling it keeps working — it will simply find nothing to iterate. Use getTierCatalog() for the limits and getTopupPackages() for what you can actually buy.

json
{ "plans": [] }
typescript
const plans = await client.getPlans();
for (const plan of plans) {
  console.log(`${plan.name}: ${plan.rate_limit_rpm} req/min`);
}
python
plans = client.get_plans()
for plan in plans:
    print(f"{plan['name']}: {plan['rate_limit_rpm']} req/min")
go
plans, _ := client.GetPlans()
for _, plan := range plans {
    fmt.Printf("%s: %d req/min\n", plan.Name, plan.RateLimitRPM)
}
bash
curl -X GET https://apis.fotohub.app/v1/billing/plans \
  -H "Authorization: Bearer fh_live_your_api_key"

Get Credits (deprecated)

typescript
getCredits(): Promise<CreditsInfo>

The API has no credits

This endpoint is deprecated. It now answers with your wallet and a message explaining that the API is prepaid in USD — total/used/remaining/resets_at are not in the response. It was kept as a 200 rather than turned into a 404 so an integration already polling it gets a self-explanatory answer instead of one it has to guess about. Use getBalance().

typescript
const info = await client.getCredits();
console.log(info.message);               // why this endpoint has no credits
console.log(`Balance: $${info.wallet.balance_usd}`);
python
info = client.get_credits()
print(info["message"])
print(f"Balance: ${info['wallet']['balance_usd']}")
go
info, _ := client.GetCredits()
fmt.Println(info.Message)
bash
curl -X GET https://apis.fotohub.app/v1/billing/credits \
  -H "Authorization: Bearer fh_live_your_api_key"

Set Overage Limit

typescript
setOverageLimit(hardLimitUsd: number, projectId?: string): Promise<void>

Sets a hard spending limit in USD. Once reached, API calls return 402. Optionally scope to a specific project; a project limit takes precedence over the account-wide one. Pass 0 to disable, which reads back as hard_limit_usd: null.

Requires a write-scoped key

This is the one billing endpoint that will not accept a read-only key — it answers 403. Use a key created with write or admin access, or your dashboard session JWT. Raising your own spending cap is a privileged action.

typescript
// Set an account-wide hard limit of $100
await client.setOverageLimit(100);

// Set a per-project limit
await client.setOverageLimit(25, 'a1b2c3d4-5e6f-7890-abcd-ef1234567890');
python
# Set an account-wide hard limit of $100
client.set_overage_limit(100)

# Set a per-project limit
client.set_overage_limit(25, project_id="a1b2c3d4-5e6f-7890-abcd-ef1234567890")
bash
curl -X PUT https://apis.fotohub.app/v1/billing/overage-limit \
  -H "Authorization: Bearer fh_live_your_write_api_key" \
  -H "Content-Type: application/json" \
  -d '{"hard_limit_usd": 100, "project_id": "YOUR_PROJECT_ID"}'

Get Top-Up Packages

typescript
getTopupPackages(): Promise<TopupPackage[]>

Returns the 12 wallet top-up packages. Each credits total_usd — what you pay plus the volume bonus, which is extra spendable dollars, not a credit unit. From $500 up every rung earns one, rising from 5% to 20%.

typescript
const packages = await client.getTopupPackages();
for (const pkg of packages) {
  console.log(
    `${pkg.slug}: pay $${pkg.amount_usd} → $${pkg.total_usd} in the wallet` +
      (pkg.bonus_usd ? ` (+$${pkg.bonus_usd}, ${pkg.bonus_pct}%)` : ''),
  );
}
// scale-1000: pay $1000 → $1100 in the wallet (+$100, 10%)
python
packages = client.get_topup_packages()
for pkg in packages:
    print(f"{pkg['slug']}: pay ${pkg['amount_usd']} → ${pkg['total_usd']}")
go
packages, _ := client.GetTopupPackages()
for _, pkg := range packages {
    fmt.Printf("%s: pay $%.0f → $%.0f\n", pkg.Slug, pkg.AmountUSD, pkg.TotalUSD)
}
bash
curl -X GET https://apis.fotohub.app/v1/billing/topup/packages

The endpoint is public — no key required — so you can render the pricing table before a visitor signs up.

SlugYou payBonusCredited
topup-50$15$15
topup-100$25$25
topup-250$60$60
topup-500$120$120
scale-500$500+$25 (5%)$525
scale-1000$1,000+$100 (10%)$1,100
scale-2000$2,000+$240 (12%)$2,240
scale-3000$3,000+$390 (13%)$3,390
scale-5000$5,000+$750 (15%)$5,750
scale-7500$7,500+$1,275 (17%)$8,775
scale-10000$10,000+$1,800 (18%)$11,800
scale-15000$15,000+$3,000 (20%)$18,000

The four starter slugs are not their amounts

topup-50 charges $15, topup-100 $25, topup-250 $60 and topup-500 $120 — historical names from the pre-USD PLN pricing, kept so nothing keying off "topup-50" breaks. The scale-* slugs do match their dollar amounts. Read amount_usd / total_usd from the API rather than hardcoding either. topup-1000 ($225) and topup-5000 ($1,000) still resolve but are no longer listed.

Get the Package List with the Bonus Ladder

typescript
getTopupPackageList(): Promise<TopupPackageList>

Returns the same packages plus min_usd, max_usd and bonus_tiers — the machine-readable ladder. Use this when you quote a custom amount, because the bonus is a function of the money, not of the package.

typescript
const { packages, min_usd, max_usd, bonus_tiers } = await client.getTopupPackageList();

// bonus_tiers is ordered high → low; the FIRST match wins.
function bonusFor(amountUsd: number): number {
  const tier = bonus_tiers.find((t) => amountUsd >= t.min_usd);
  return tier ? Math.floor(amountUsd * tier.bonus_pct) / 100 : 0;
}

bonusFor(2500);  // 325 — the 13% rung
bonusFor(499);   // 0 — below the first rung
console.log(`Custom top-ups: $${min_usd}–$${max_usd}`);

Iterate bonus_tiers in the order the API returns it and stop at the first match. Sorting it ascending would pay a $15,000 top-up the $500 rung's 5% instead of 20% — a $2,250 error. Rungs never stack, and the bonus is floored to the cent.

Create Top-Up

typescript
createTopup(packageSlug: string): Promise<TopupResult>

Buys a wallet top-up package. Returns a checkout URL for payment; payment credits total_usd — the amount paid plus the bonus — to the prepaid wallet, the only thing that pays for API calls. The bonus lands as its own top_up_bonus ledger row beside the top_up row.

typescript
const topup = await client.createTopup('scale-1000');
console.log(`Pay $${topup.amount_usd}, get $${topup.total_credited_usd}`);
// Pay $1000, get $1100
// Redirect user to topup.checkout_url
python
result = client.create_topup("scale-1000")
print(f"Complete purchase: {result['checkout_url']}")
go
result, _ := client.CreateTopup("scale-1000")
fmt.Printf("Complete purchase: %s\n", result.CheckoutURL)
bash
curl -X POST https://apis.fotohub.app/v1/billing/topup \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"package": "scale-1000"}'

Send {"amount_usd": 2500} instead of package for a custom amount between $10 and $15,000; package comes back null and the bonus is computed from the ladder. Either way the bonus is recomputed from the amount actually captured at payment, so a Stripe-side adjustment cannot desync it from the ladder.

Get Transactions

typescript
getTransactions(options?: { page?: number; pageSize?: number; type?: string }): Promise<TransactionPage>

Returns one page of the wallet ledger — charges, refunds, top-ups. Rows arrive under data, and there is no total count: page until a page comes back shorter than pageSize. Amounts are signed (negative is a charge); amount_usd is null on rows from before the 2026-08-05 USD cutover, which carry amount_pln instead.

typescript
const page = await client.getTransactions({ page: 1, pageSize: 25 });
for (const tx of page.data) {
  const amount = tx.amount_usd != null ? `$${tx.amount_usd.toFixed(6)}` : `${tx.amount_pln ?? 0} PLN (pre-USD)`;
  console.log(`${tx.created_at} | ${tx.type} | ${amount} | ${tx.description}`);
}
python
page = client.get_transactions(page=1, page_size=25)
for tx in page["data"]:
    amount = f"${tx['amount_usd']:.6f}" if tx.get("amount_usd") is not None else f"{tx.get('amount_pln') or 0} PLN (pre-USD)"
    print(f"{tx['created_at']} | {tx['type']} | {amount} | {tx['description']}")
go
page, _ := client.GetTransactions(fotohub.TransactionOptions{Page: 1, PageSize: 25})
for _, tx := range page.Data {
    fmt.Printf("%s | %s | $%.6f | %s\n", tx.CreatedAt, tx.Type, tx.AmountUSD, tx.Description)
}
bash
curl -X GET "https://apis.fotohub.app/v1/billing/transactions?page=1&pageSize=25" \
  -H "Authorization: Bearer fh_live_your_api_key"

Estimate Cost

typescript
estimateCost(operations: CostOperation[]): Promise<CostEstimate>

Prices a batch of operations in USD before running them, every figure at the provider's own rate — nothing here touches FX. Because the account is prepaid, the estimate is only half an answer: it comes back with the wallet balance and a server-computed sufficient flag, so you never compare two numbers you may have parsed as 0. An operation with no published rate comes back priced: false in breakdown, with a reason, and is excluded from total_usd rather than silently counted as free — check the top-level priced flag before trusting the total.

typescript
const estimate = await client.estimateCost([
  { type: "generate_image", model: "seedream-5-0-260128", count: 4 },
  { type: "generate_video", model: "seedance-2-0-mini", duration: 10 },
]);
console.log(`$${estimate.total_usd} vs $${estimate.balance_usd} — ok: ${estimate.sufficient}`);
if (!estimate.priced) console.warn("Estimate is partial", estimate.breakdown);
python
estimate = client.estimate_cost([
    {"type": "generate_image", "model": "seedream-5-0-260128", "count": 4},
    {"type": "generate_video", "model": "seedance-2-0-mini", "duration": 10},
])
print(f"${estimate['total_usd']} vs ${estimate['balance_usd']} - ok: {estimate['sufficient']}")
go
estimate, _ := client.EstimateCost([]fotohub.CostOperation{
    {Type: "generate_image", Model: "seedream-5-0-260128", Count: 4},
    {Type: "generate_video", Model: "seedance-2-0-mini", Duration: 10},
})
fmt.Printf("$%.6f vs $%.6f - ok: %v\n", estimate.TotalUSD, estimate.BalanceUSD, estimate.Sufficient)
bash
curl -X POST https://apis.fotohub.app/v1/billing/estimate \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "operations": [
      {"type": "generate_image", "model": "seedream-5-0-260128", "count": 4},
      {"type": "generate_video", "model": "seedance-2-0-mini", "duration": 10}
    ]
  }'

Get Invoices

typescript
getInvoices(): Promise<Invoice[]>

Returns all invoices for your account, including download URLs for PDF receipts.

typescript
const invoices = await client.getInvoices();
for (const inv of invoices) {
  console.log(`${inv.created} | ${(inv.amount_paid / 100).toFixed(2)} ${inv.currency.toUpperCase()} | ${inv.status} | ${inv.invoice_pdf}`);
}
python
invoices = client.get_invoices()
for inv in invoices:
    print(f"{inv['created']} | {inv['amount_paid'] / 100:.2f} {inv['currency'].upper()} | {inv['status']} | {inv['invoice_pdf']}")
go
invoices, _ := client.GetInvoices()
for _, inv := range invoices {
    fmt.Printf("%d | %.2f %s | %s | %s\n", inv.Created, float64(inv.AmountPaid)/100, strings.ToUpper(inv.Currency), inv.Status, inv.InvoicePDF)
}
bash
curl -X GET https://apis.fotohub.app/v1/billing/invoices \
  -H "Authorization: Bearer fh_live_your_api_key"

Tiers

Manage subscription tiers, compare plans, and handle enterprise applications.

Get Tier Catalog

typescript
getTierCatalog(): Promise<TierCatalog>

Returns every tier with its features and limits. Nothing on it has a price: the API is prepaid, so a tier is a rate-limit definition, not a product. Every row carries price_monthly: null (or 0 on pay-as-you-go) and purchasable: false, and the response states it outright:

json
{
  "currency": "USD",
  "payg_currency": "USD",
  "billing_cycle": "prepaid",
  "subscriptions_retired": true,
  "overage_policy": "None. The API is prepaid: every call is charged to your wallet at the provider's own rate, and a call that would exceed your balance is declined with HTTP 402 instead of being billed."
}
typescript
const catalog = await client.getTierCatalog();
for (const tier of [...catalog.payg, ...catalog.subscriptions]) {
  // Print the limits, not a price — there is no price to print.
  console.log(
    `${tier.name}: ${tier.limits.rpm} rpm, ` +
    `${tier.limits.concurrent_jobs} concurrent, ` +
    `${tier.limits.storage_gb} GB`
  );
}
python
catalog = client.get_tier_catalog()
for tier in [*catalog["payg"], *catalog["subscriptions"]]:
    lim = tier["limits"]
    print(f"{tier['name']}: {lim['rpm']} rpm, {lim['concurrent_jobs']} concurrent, {lim['storage_gb']} GB")
go
catalog, _ := client.GetTierCatalog()
for _, tier := range append(catalog.Payg, catalog.Subscriptions...) {
    fmt.Printf("%s: %d rpm, %d concurrent, %d GB\n",
        tier.Name, tier.Limits.RPM, tier.Limits.ConcurrentJobs, tier.Limits.StorageGB)
}
bash
curl -X GET https://apis.fotohub.app/v1/tiers/catalog \
  -H "Authorization: Bearer fh_live_your_api_key"

Why subscriptions still exists

The response has no flat tiers array — it arrives as two: payg, whose tier is resolved automatically from your wallet balance and lifetime spend, and subscriptions, which carries subscriptions_retired: true.

Those sub-* rows are not on sale. They are still published because they are live rate-limit definitions: an account that held one before 2026-08-13 keeps being served its limits, so the numbers it is enforced against have to be readable. Each retired row states this per entry — purchasable: false, legacy: true, upgrade_path: "wallet_topup". The one exception is sub-enterprise: never self-served, upgrade_path: "contact_sales".

To raise your limits, fund the wallet — see Get Top-Up Packages.

Get Current Tier

typescript
getCurrentTier(): Promise<TierInfo>

Returns your current tier, rate limits, and usage. The tier caps how fast you may spend — rpm, burst, concurrency, model access. What actually pays for calls is wallet.balance_usd; at 0 every billed endpoint returns 402 regardless of tier.

typescript
const tier = await client.getCurrentTier();
console.log(`Tier: ${tier.name} (${tier.limits.rpm} rpm)`);
console.log(`Today: ${tier.usage.requests_today} / ${tier.limits.daily_quota}`);
console.log(`Balance: $${tier.wallet.balance_usd}`);
python
tier = client.get_current_tier()
print(f"Tier: {tier['name']} ({tier['limits']['rpm']} rpm)")
print(f"Today: {tier['usage']['requests_today']} / {tier['limits']['daily_quota']}")
print(f"Balance: ${tier['wallet']['balance_usd']}")
go
tier, _ := client.GetCurrentTier()
fmt.Printf("Tier: %s (%d rpm)\n", tier.Name, tier.Limits.RPM)
fmt.Printf("Today: %d / %d\n", tier.Usage.RequestsToday, tier.Limits.DailyQuota)
fmt.Printf("Balance: $%.6f\n", tier.Wallet.BalanceUSD)
bash
curl -X GET https://apis.fotohub.app/v1/tiers/current \
  -H "Authorization: Bearer fh_live_your_api_key"

Compare Tiers

typescript
compareTiers(): Promise<TierComparison>

Returns every tier flattened into one comparison list of limits. It does not mark which tier is yours — get that from getCurrentTier().

There is no price column. price_monthly and monthly_credits were removed from this response on 2026-08-13 (they quoted 49/199/799 PLN for plans that cannot be bought), and the top level now says what the API actually charges:

json
{
  "currency": "USD",
  "billing_model": "prepaid_wallet_usd",
  "subscriptions_retired": true
}

Each row carries purchasable: false plus an upgrade_path"wallet_topup" for everything self-serve, "contact_sales" for sub-enterprise.

typescript
const [{ tier: mine }, comparison] = await Promise.all([
  client.getCurrentTier(),
  client.compareTiers(),
]);
for (const row of comparison.tiers) {
  const marker = row.slug === mine ? ' ← current' : '';
  console.log(`${row.name}: ${row.rpm} rpm${marker}`);
}
python
tier = client.get_current_tier()
comparison = client.compare_tiers()
for row in comparison["tiers"]:
    marker = " <- current" if row["slug"] == tier["tier"] else ""
    print(f"{row['name']}: {row['rpm']} rpm{marker}")
go
comparison, _ := client.CompareTiers()
for _, row := range comparison.Tiers {
    fmt.Printf("%s: %d rpm\n", row.Name, row.RPM)
}
bash
curl -X GET https://apis.fotohub.app/v1/tiers/compare \
  -H "Authorization: Bearer fh_live_your_api_key"

Subscribe to Tier — retired

typescript
/** @deprecated Retired 2026-08-13 — always throws. */
subscribeTier(slug: string): Promise<{ checkout_url: string }>

Removed as a product on 2026-08-13

POST /v1/tiers/subscribe answers HTTP 410 for every tier, and this method is a throwing stub kept only so upgrading the SDK gives you a compile-time deprecation instead of a TypeError with nothing pointing at the replacement.

json
{
  "error": "api_subscriptions_retired",
  "use_instead": "POST /v1/tiers/wallet/topup"
}

There are no paid API plans. Rate limits follow the prepaid wallet: top up more and the tier rises on its own, with no monthly commitment to cancel. The swap is in your favour — from $500 up a top-up earns a 5–20% volume bonus that a monthly fee never gave you.

typescript
// Before: await client.subscribeTier('sub-developer');   // now throws (410)
const topup = await client.createTopup('scale-1000');
console.log(`Pay $${topup.amount_usd}, get $${topup.total_credited_usd}`);
python
# Before: client.subscribe_tier("sub-developer")   # now raises (410)
result = client.create_topup("scale-1000")
print(f"Complete purchase: {result['checkout_url']}")
go
// Before: client.SubscribeTier("sub-developer")   // now returns a 410 error
result, _ := client.CreateTopup("scale-1000")
fmt.Printf("Complete purchase: %s\n", result.CheckoutURL)
bash
curl -X POST https://apis.fotohub.app/v1/tiers/wallet/topup \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": 1000}'

sub-enterprise is the one exception and was never bought this way: it is a contract, via POST /v1/tiers/enterprise/apply.

Get Wallet

typescript
getWallet(): Promise<Wallet>

Returns your wallet balance in USD, this month's spend, and recent transactions.

typescript
const wallet = await client.getWallet();
console.log(`Balance: $${wallet.balance.available_usd}`);
console.log(`Spent this month: $${wallet.this_month.spent_usd}`);
python
wallet = client.get_wallet()
print(f"Balance: ${wallet['balance']['available_usd']}")
print(f"Spent this month: ${wallet['this_month']['spent_usd']}")
go
wallet, _ := client.GetWallet()
fmt.Printf("Balance: $%.2f\n", wallet.Balance.AvailableUSD)
bash
curl -X GET https://apis.fotohub.app/v1/tiers/wallet \
  -H "Authorization: Bearer fh_live_your_api_key"

Top Up Wallet

typescript
topupWallet(
  amountUsd: number,
  payCurrency?: "usd" | "pln"
): Promise<{
  checkout_url: string;
  amount_usd: number;
  /** Volume bonus in extra spendable dollars; `0` below $500. */
  bonus_usd: number;
  /** `amount_usd + bonus_usd` — the balance increase on payment. */
  total_credited_usd: number;
  pay_currency: string;
}>

Tops the wallet up by any amount between $10 and $15,000 — the package-free path, for a slider or a text field. Returns a Stripe checkout URL. Polish customers can add pay_currency: 'pln' to pay by BLIK/card/bank transfer in PLN while the wallet is still credited the USD amount.

From $500 up the amount earns a volume bonus in extra spendable dollars, on the same ladder the packages use, so a custom $2,500 is credited $2,825 exactly as a $2,500 preset would be. bonus_usd and total_credited_usd are the quote; total_credited_usd is what the balance becomes. The response's bonus_credits field, if present, is a deprecated leftover that is always null — the bonus is dollars, and this product has no credits.

typescript
const topup = await client.topupWallet(1000);
console.log(`Pay $${topup.amount_usd}, get $${topup.total_credited_usd}`);
// Pay $1000, get $1100
// Redirect user to topup.checkout_url

// Pay in PLN via BLIK while still crediting USD to the wallet
const blik = await client.topupWallet(1000, 'pln');
python
result = client.topup_wallet(1000)
print(f"Pay: {result['checkout_url']} — credited ${result['total_credited_usd']}")
go
result, _ := client.TopupWallet(1000)
fmt.Printf("Pay: %s\n", result.CheckoutURL)
bash
curl -X POST https://apis.fotohub.app/v1/tiers/wallet/topup \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": 1000}'

The bonus is recomputed from the amount actually captured at payment, so the quote above can never disagree with the grant. Use getTopupPackageList() to show the bonus before the customer commits.

Apply for Enterprise

typescript
applyEnterprise(): Promise<{ id: string; status: string }>

Submits an enterprise tier application. FOTOhub team will review and contact you.

typescript
const { id, status } = await client.applyEnterprise({
  company_name: 'Acme Corp',
  contact_email: '[email protected]',
  expected_usage: '50,000+ generations/month',
  use_case: 'E-commerce product photography at scale',
});
console.log(`Application ${id}: ${status}`);
python
result = client.apply_enterprise(
    company_name="Acme Corp",
    contact_email="[email protected]",
    expected_usage="50,000+ generations/month",
    use_case="E-commerce product photography at scale",
)
print(f"Application {result.id}: {result.status}")
go
result, _ := client.ApplyEnterprise(fotohub.EnterpriseApplication{
    CompanyName:   "Acme Corp",
    ContactEmail:  "[email protected]",
    ExpectedUsage: "50,000+ generations/month",
    UseCase:       "E-commerce product photography at scale",
})
fmt.Printf("Application %s: %s\n", result.ID, result.Status)
bash
curl -X POST https://apis.fotohub.app/v1/tiers/enterprise/apply \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Acme Corp",
    "contact_email": "[email protected]",
    "expected_usage": "50,000+ generations/month",
    "use_case": "E-commerce product photography at scale"
  }'

Webhooks

Manage webhooks for receiving async notifications about job completions, billing events, and more.

List Webhooks

typescript
listWebhooks(): Promise<Webhook[]>

Returns all configured webhooks for your account.

typescript
const webhooks = await client.listWebhooks();
for (const wh of webhooks) {
  console.log(`${wh.id}: ${wh.url} — events: ${wh.events.join(', ')} (${wh.status})`);
}
python
webhooks = client.list_webhooks()
for wh in webhooks:
    print(f"{wh.id}: {wh.url} - events: {', '.join(wh.events)} ({wh.status})")
go
webhooks, _ := client.ListWebhooks()
for _, wh := range webhooks {
    fmt.Printf("%s: %s - events: %v (%s)\n", wh.ID, wh.URL, wh.Events, wh.Status)
}
bash
curl -X GET https://apis.fotohub.app/v1/webhooks \
  -H "Authorization: Bearer fh_live_your_api_key"

Create Webhook

typescript
createWebhook(opts: { url: string; events: string[]; description?: string }): Promise<Webhook & { secret: string }>

Creates a new webhook endpoint. Returns the webhook object including the signing secret (shown only once).

events is validated against a fixed list

Only these values are accepted; a single unknown entry rejects the whole call with 400 Invalid events:

generation.completed, generation.failed, credits.low, credits.depleted, key.used, billing.charged, images.batch.completed, background.removed, background.replaced, background.blurred, shadow.added, commerce.job.completed, commerce.job.failed, commerce.item.completed, commerce.job.awaiting_credits.

There are no media-specific events — video and 3D completions both arrive as generation.completed. Earlier revisions of this page showed video.completed, 3d.completed and billing.threshold; none of those exist.

typescript
const webhook = await client.createWebhook({
  url: 'https://myapp.com/webhooks/fotohub',
  events: ['generation.completed', 'generation.failed'],
  description: 'Production webhook',
});

console.log(`ID: ${webhook.id}`);
console.log(`Secret: ${webhook.secret}`);  // Store securely — shown only once
python
webhook = client.create_webhook(
    url="https://myapp.com/webhooks/fotohub",
    events=["generation.completed", "generation.failed"],
    description="Production webhook",
)
print(f"ID: {webhook.id}")
print(f"Secret: {webhook.secret}")  # Store securely
go
webhook, _ := client.CreateWebhook(fotohub.WebhookOptions{
    URL:         "https://myapp.com/webhooks/fotohub",
    Events:      []string{"generation.completed", "generation.failed"},
    Description: "Production webhook",
})
fmt.Printf("ID: %s\n", webhook.ID)
fmt.Printf("Secret: %s\n", webhook.Secret)  // Store securely
bash
curl -X POST https://apis.fotohub.app/v1/webhooks \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://myapp.com/webhooks/fotohub",
    "events": ["generation.completed", "generation.failed"],
    "description": "Production webhook"
  }'

Update Webhook

typescript
updateWebhook(id: string, opts: { url?: string; events?: string[]; status?: 'active' | 'paused' }): Promise<Webhook>

Updates an existing webhook's URL, events, or status.

typescript
const updated = await client.updateWebhook('wh_abc123', {
  events: ['generation.completed', 'generation.failed', 'billing.charged'],
  status: 'active',
});
console.log(`Updated: ${updated.id}, events: ${updated.events.length}`);
python
updated = client.update_webhook("wh_abc123",
    events=["generation.completed", "generation.failed", "billing.charged"],
    status="active",
)
print(f"Updated: {updated.id}, events: {len(updated.events)}")
go
updated, _ := client.UpdateWebhook("wh_abc123", fotohub.WebhookUpdateOptions{
    Events: []string{"generation.completed", "generation.failed", "billing.charged"},
    Status: "active",
})
fmt.Printf("Updated: %s, events: %d\n", updated.ID, len(updated.Events))
bash
curl -X PATCH https://apis.fotohub.app/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["generation.completed", "generation.failed", "billing.charged"],
    "status": "active"
  }'

Delete Webhook

typescript
deleteWebhook(id: string): Promise<void>

Permanently deletes a webhook endpoint.

typescript
await client.deleteWebhook('wh_abc123');
console.log('Webhook deleted');
python
client.delete_webhook("wh_abc123")
print("Webhook deleted")
go
client.DeleteWebhook("wh_abc123")
fmt.Println("Webhook deleted")
bash
curl -X DELETE https://apis.fotohub.app/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer fh_live_your_api_key"

Test Webhook

typescript
testWebhook(id: string): Promise<{ success: boolean; response_time_ms: number }>

Sends a test event to your webhook endpoint and reports whether it responded successfully.

typescript
const test = await client.testWebhook('wh_abc123');
console.log(`Success: ${test.success}, Response time: ${test.response_time_ms}ms`);
python
test = client.test_webhook("wh_abc123")
print(f"Success: {test.success}, Response time: {test.response_time_ms}ms")
go
test, _ := client.TestWebhook("wh_abc123")
fmt.Printf("Success: %t, Response time: %dms\n", test.Success, test.ResponseTimeMs)
bash
curl -X POST https://apis.fotohub.app/v1/webhooks/wh_abc123/test \
  -H "Authorization: Bearer fh_live_your_api_key"

Get Webhook Logs

typescript
getWebhookLogs(id: string): Promise<WebhookLog[]>

Returns recent delivery attempts for a webhook, including status codes and response bodies.

typescript
const logs = await client.getWebhookLogs('wh_abc123');
for (const log of logs) {
  console.log(`${log.timestamp} | ${log.event} | ${log.status_code} | ${log.success ? 'OK' : 'FAIL'}`);
}
python
logs = client.get_webhook_logs("wh_abc123")
for log in logs:
    status = "OK" if log.success else "FAIL"
    print(f"{log.timestamp} | {log.event} | {log.status_code} | {status}")
go
logs, _ := client.GetWebhookLogs("wh_abc123")
for _, log := range logs {
    status := "OK"
    if !log.Success {
        status = "FAIL"
    }
    fmt.Printf("%s | %s | %d | %s\n", log.Timestamp, log.Event, log.StatusCode, status)
}
bash
curl -X GET https://apis.fotohub.app/v1/webhooks/wh_abc123/logs \
  -H "Authorization: Bearer fh_live_your_api_key"

Gabriel AI

Gabriel is FOTOhub's intelligent routing assistant. It classifies prompts, suggests completions, and recommends optimal models and parameters for your use case.

Classify Prompt

typescript
gabrielClassify(prompt: string, opts?: { context?: string }): Promise<Classification>

Classifies a user prompt into a category and suggests the best model and parameters.

typescript
const classification = await client.gabrielClassify(
  'Generate a 3D model of a sneaker from this photo',
  { context: 'e-commerce product pipeline' }
);

console.log(`Category: ${classification.category}`);      // 'image_to_3d'
console.log(`Model: ${classification.recommended_model}`); // 'fh-lite-3d'
console.log(`Confidence: ${classification.confidence}`);   // 0.95
console.log(`Parameters:`, classification.suggested_params);
python
classification = client.gabriel_classify(
    "Generate a 3D model of a sneaker from this photo",
    context="e-commerce product pipeline"
)
print(f"Category: {classification.category}")
print(f"Model: {classification.recommended_model}")
print(f"Confidence: {classification.confidence}")
print(f"Parameters: {classification.suggested_params}")
go
classification, _ := client.GabrielClassify("Generate a 3D model of a sneaker from this photo", fotohub.ClassifyOptions{
    Context: "e-commerce product pipeline",
})
fmt.Printf("Category: %s\n", classification.Category)
fmt.Printf("Model: %s\n", classification.RecommendedModel)
fmt.Printf("Confidence: %.2f\n", classification.Confidence)
bash
curl -X POST https://apis.fotohub.app/v1/gabriel/classify \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Generate a 3D model of a sneaker from this photo",
    "context": "e-commerce product pipeline"
  }'

Suggest Completions

typescript
gabrielSuggest(partial: string, opts?: { limit?: number; category?: string }): Promise<Suggestion[]>

Returns prompt completions and suggestions based on a partial input.

typescript
const suggestions = await client.gabrielSuggest('A cinematic drone shot of', {
  limit: 5,
  category: 'video',
});

for (const s of suggestions) {
  console.log(`${s.text} (score: ${s.score})`);
}
python
suggestions = client.gabriel_suggest(
    "A cinematic drone shot of",
    limit=5,
    category="video"
)
for s in suggestions:
    print(f"{s.text} (score: {s.score})")
go
suggestions, _ := client.GabrielSuggest("A cinematic drone shot of", fotohub.SuggestOptions{
    Limit:    5,
    Category: "video",
})
for _, s := range suggestions {
    fmt.Printf("%s (score: %.2f)\n", s.Text, s.Score)
}
bash
curl -X POST https://apis.fotohub.app/v1/gabriel/suggest \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "partial": "A cinematic drone shot of",
    "limit": 5,
    "category": "video"
  }'

Get Recommendations

typescript
gabrielRecommend(options?: GabrielRecommendOptions): Promise<GabrielRecommendation[]>

Returns proactive, context-aware recommendations based on user state. No authentication required; template-based (<100ms response).

credits_remaining is a hint, not your API balance

It describes your end user's fotohub.app subscription credits — pass it only if you are building on top of the web app. It never funds an API call. Read getBalance() for the prepaid USD wallet that actually pays for generations.

typescript
const recs = await client.gabrielRecommend({
  page: '/generate/new',
  has_brand: false,
});

for (const rec of recs) {
  console.log(`${rec.text} → ${rec.target}`);
}
python
recs = client.gabriel_recommend(page="/generate/new", has_brand=False)
for rec in recs:
    print(f"{rec['text']} -> {rec['target']}")
go
recs, _ := client.GabrielRecommend(fotohub.GabrielRecommendOptions{
    Page:     "/generate/new",
    HasBrand: false,
})
for _, rec := range recs {
    fmt.Printf("%s -> %s\n", rec.Text, rec.Target)
}
bash
curl -X POST https://apis.fotohub.app/v1/ai/gabriel/recommend \
  -H "Content-Type: application/json" \
  -d '{
    "page": "/generate/new",
    "has_brand": false
  }'

Models

List Models

typescript
listModels(category?: string): Promise<Model[]>

Returns all available AI models, optionally filtered by category (image, video, text, audio). Multiply by the duration when price_unit is "second" — every video model quotes per second, even though pricing_type says "request".

typescript
// List all models
const allModels = await client.listModels();
console.log(`Total models: ${allModels.length}`);

// Filter by category
const videoModels = await client.listModels('video');
for (const m of videoModels) {
  console.log(`${m.name} (${m.id}): $${m.request_price} per ${m.request_price_per}`);
}
python
# List all models
all_models = client.list_models()
print(f"Total models: {len(all_models)}")

# Filter by category
video_models = client.list_models(category="video")
for m in video_models:
    print(f"{m.name} ({m.id}): ${m.request_price} per {m.request_price_per}")
go
// List all models
allModels, _ := client.ListModels("")
fmt.Printf("Total models: %d\n", len(allModels))

// Filter by category
videoModels, _ := client.ListModels("video")
for _, m := range videoModels {
    fmt.Printf("%s (%s): $%.6f per %s\n", m.Name, m.ID, m.RequestPrice, m.RequestPricePer)
}
bash
# All models
curl -X GET https://apis.fotohub.app/v1/models \
  -H "Authorization: Bearer fh_live_your_api_key"

# By category
curl -X GET "https://apis.fotohub.app/v1/models?category=image" \
  -H "Authorization: Bearer fh_live_your_api_key"

Model Response Type

typescript
interface Model {
  /** Unique model identifier */
  id: string;
  /** Human-readable display name */
  name: string;
  /** Category: image, video, text, audio */
  category: string;
  /** Provider name */
  provider: string;
  /** Whether the model is currently offered */
  is_active: boolean;
  /**
   * Price of ONE unit of this model, in USD. The unit is `price_unit` — on a
   * video model this is per SECOND, so a 5s clip costs 5x this figure. `null`
   * on token-priced models.
   */
  request_price: number | null;
  /** What one unit of `request_price` buys: "request" | "second" | "minute" | "1k_characters" | "1k_tokens" */
  price_unit: string;
  /** The same thing as `price_unit`, spelled out for humans */
  request_price_per: string;
  /** Always "USD" */
  currency: string;
  /** USD per 1000 input tokens, on token-priced models */
  input_price_per_1k_tokens?: number | null;
  /** USD per 1000 output tokens, on token-priced models */
  output_price_per_1k_tokens?: number | null;
}

Error Handling

The SDK provides a typed error hierarchy for precise error handling across all methods.

Error Classes

typescript
import {
  FotoHubError,
  InsufficientFundsError,
  RateLimitError,
  AuthenticationError,
  ValidationError,
} from 'fotohub/errors';

InsufficientCreditsError is a deprecated alias

The API is prepaid in USD and has no credits. InsufficientCreditsError is exported as the exact same class as InsufficientFundsError (so an existing instanceof InsufficientCreditsError check still catches it), but the thrown error's code is now insufficient_funds, not insufficient_credits — code that compares the string needs updating. The alias is removed in the next major version.

Comprehensive Error Handling

typescript
import { FotoHub } from 'fotohub';
import {
  FotoHubError,
  InsufficientFundsError,
  RateLimitError,
  AuthenticationError,
  ValidationError,
} from 'fotohub/errors';

const client = new FotoHub({ apiKey: 'fh_live_your_api_key' });

try {
  const result = await client.generateImage({
    prompt: 'A landscape',
    model: 'seedream-5-0-260128',
  });
  console.log(`Image: ${result.images[0]}`);
} catch (e) {
  if (e instanceof AuthenticationError) {
    // Invalid or expired API key (401)
    console.error('Invalid API key. Check your credentials.');
  } else if (e instanceof InsufficientFundsError) {
    // Prepaid wallet is short (402) — nothing was charged
    console.error(`Need $${e.requiredUsd}, balance $${e.balanceUsd}`);
    console.error(`Top up $${e.shortfallUsd} at ${e.topupUrl}`);
  } else if (e instanceof RateLimitError) {
    // Too many requests (429) — SDK retries automatically, but may still throw
    console.error(`Rate limited. Retry after ${e.retryAfter}s`);
  } else if (e instanceof ValidationError) {
    // Invalid request parameters (422)
    console.error(`Validation failed: ${JSON.stringify(e.fieldErrors)}`);
  } else if (e instanceof FotoHubError) {
    // Other API errors (4xx/5xx)
    console.error(`[${e.statusCode}] ${e.code}: ${e.message}`);
  } else {
    // Network errors, timeouts, etc.
    throw e;
  }
}
python
from fotohub import FotoHub
from fotohub.exceptions import (
    FotoHubError,
    AuthenticationError,
    InsufficientFundsError,
    RateLimitError,
    ValidationError,
)

client = FotoHub(api_key="fh_live_your_api_key")

try:
    result = client.generate_image(
        prompt="A landscape",
        model="seedream-5-0-260128",
    )
    print(f"Image: {result.images[0]}")
except AuthenticationError:
    print("Invalid API key. Check your credentials.")
except InsufficientFundsError as e:
    # Prepaid wallet is short. NOTHING was charged.
    print(f"Need ${e.required_usd}, balance ${e.balance_usd}")
    print(f"Top up ${e.shortfall_usd} at {e.topup_url}")
except RateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after}s")
except ValidationError as e:
    print(f"Validation failed: {e.field_errors}")
except FotoHubError as e:
    print(f"[{e.status_code}] {e.code}: {e.message}")
go
package main

import (
    "errors"
    "fmt"
    "github.com/fotohubapp/sdk-go"
)

func main() {
    client := fotohub.NewClient("fh_live_your_api_key")

    result, err := client.GenerateImage(fotohub.GenerateImageOptions{
        Prompt: "A landscape",
        Model:  "seedream-5-0-260128",
    })
    if err != nil {
        var authErr *fotohub.AuthenticationError
        var fundsErr *fotohub.InsufficientFundsError
        var rateErr *fotohub.RateLimitError
        var valErr *fotohub.ValidationError
        var apiErr *fotohub.FotoHubError

        switch {
        case errors.As(err, &authErr):
            fmt.Println("Invalid API key.")
        case errors.As(err, &fundsErr):
            fmt.Printf("Need $%.2f, balance $%.2f\n", fundsErr.RequiredUsd, fundsErr.BalanceUsd)
        case errors.As(err, &rateErr):
            fmt.Printf("Rate limited. Retry after %ds\n", rateErr.RetryAfter)
        case errors.As(err, &valErr):
            fmt.Printf("Validation failed: %v\n", valErr.FieldErrors)
        case errors.As(err, &apiErr):
            fmt.Printf("[%d] %s: %s\n", apiErr.StatusCode, apiErr.Code, apiErr.Message)
        default:
            fmt.Printf("Network error: %v\n", err)
        }
        return
    }
    fmt.Printf("Image: %s\n", result.Images[0])
}
bash
# Errors return JSON with code and message:
# 401: {"error": {"code": "authentication_error", "message": "Invalid API key"}}
# 402: {"detail": {"error": "insufficient_funds", "code": "insufficient_funds", "required_usd": 0.05, "balance_usd": 0.0, "shortfall_usd": 0.05, "charged": false, "topup_url": "..."}}
# 429: {"error": {"code": "rate_limit_exceeded", "message": "...", "retry_after": 30}}
# 422: {"error": {"code": "validation_error", "message": "...", "fieldErrors": {"model": ["required"]}}}

curl -X POST https://apis.fotohub.app/v1/image/generate \
  -H "Authorization: Bearer fh_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "A landscape", "model": "seedream-5-0-260128"}'

# Check HTTP status code:
# 200 = success, 4xx/5xx = error (parse JSON body for details)

Error Class Hierarchy

typescript
class FotoHubError extends Error {
  /** Machine-readable error code */
  code: string;
  /** HTTP status code */
  statusCode: number | undefined;
  /** Additional error context */
  details: Record<string, unknown> | undefined;
}

class AuthenticationError extends FotoHubError {
  // statusCode is always 401
}

class InsufficientFundsError extends FotoHubError {
  // statusCode is always 402, code is "insufficient_funds"
  /** USD price of the refused request */
  requiredUsd: number | undefined;
  /** USD wallet balance at the time of the refusal */
  balanceUsd: number | undefined;
  /** The minimum top-up that would let this request through */
  shortfallUsd: number | undefined;
  /** Where to add funds */
  topupUrl: string | undefined;
  /** The operation that was refused, e.g. `generate_image:seedream-5-0-pro` */
  operation: string | undefined;
  /** Nothing was charged for a refused request. Always `false`. */
  get charged(): boolean;
}

class RateLimitError extends FotoHubError {
  /** Seconds to wait before retrying */
  retryAfter: number | undefined;
}

class ValidationError extends FotoHubError {
  /** Field-level validation errors */
  fieldErrors: Record<string, string[]> | undefined;
}

Error Codes Reference

CodeHTTP StatusDescription
authentication_error401Invalid or missing API key
insufficient_funds402Prepaid wallet balance too low — nothing charged
rate_limit_exceeded429Too many requests, retry after delay
validation_error422Invalid request parameters
not_found404Requested resource does not exist
server_error500Server-side error

Balance and Usage

typescript
// Check account balance
const balance = await client.getBalance();
console.log(`Balance: $${balance.wallet.balance_usd}`);
console.log(`Spent this month: $${balance.spend.this_month_usd}`);

// Get usage statistics
const usage = await client.getUsage({ period: '30d' });
console.log(`Total requests: ${usage.totals.totalRequests}`);

OpenAI SDK Compatibility

The chat completions endpoint is fully compatible with the OpenAI API format. Use the official OpenAI SDK as a drop-in client:

typescript
import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: 'fh_live_your_key_here',
  baseURL: 'https://apis.fotohub.app/v1/ai',
});

// All OpenAI SDK features work
const response = await client.chat.completions.create({
  model: 'gemini-flash',
  messages: [{ role: 'user', content: 'Hello' }],
});
console.log(response.choices[0].message.content);

// Streaming is NOT supported here. `stream: true` is accepted and ignored, and
// the OpenAI SDK will hang or error waiting for chunks that never arrive.
// Use /v1/ai/agent/stream with fetch() instead — see the Streaming section above.

Four model IDs, and no streaming

The OpenAI-compatible endpoint accepts exactly gemini-flash, gemini-pro, gpt-4o and claude-sonnet. Anything else returns 400 with the supported list — it is not silently downgraded to a default. gpt-4o and claude-sonnet are stable aliases that route to newer models internally.

stream: true is accepted for drop-in compatibility and then ignored; the response is always one complete JSON body. Image and video generation use FOTOhub-specific endpoints, not this one.

API Reference

Client Methods

MethodDescriptionReturns
generateImage(options)Generate images from textPromise<ImageResult>
generateVideo(options)Generate a video (synchronous — returns video_url)Promise<VideoResult>
generateMusic(options)Generate audio/musicPromise<MusicResult>
generateSfx(options)Generate sound effectsPromise<SfxResult>
generateSpeech(options)Text-to-speech synthesisPromise<SpeechResult>
transcribe(options)Transcribe audio to textPromise<TranscriptionResult>
chat(options)Standard chat completion (token-billed)Promise<ChatResult>
chatClaude(options)Premium chat completion (token-based)Promise<ChatResult>
chatStream(options)⚠️ Broken — targets the non-streaming endpoint and yields zero chunks while still billing. Use fetch on /v1/ai/agent/stream.Promise<ChatStream>
analyzeImage(options)Analyze an image with vision modelsPromise<AnalysisResult>
enhancePrompt(prompt, style?)Improve a prompt with AIPromise<string>
editImage(options)Edit an imagePromise<EditResult>
generate3D(options)Start 3D model generationPromise<ThreeDResult>
get3DStatus(jobId)Get 3D job statusPromise<ThreeDResult>
waitFor3D(jobId, options?)Poll until 3D completesPromise<ThreeDResult>
list3DModels()List available 3D modelsPromise<ThreeDModelInfo[]>
listStabilityTools()List Stability AI toolsPromise<StabilityTool[]>
runStabilityTool(toolId, options)Run any Stability tool by IDPromise<StabilityResult>
removeBackground(imageUrl)Remove image backgroundPromise<StabilityResult>
upscaleImage(imageUrl, scale?)Upscale an imagePromise<StabilityResult>
getBalance()Get account balancePromise<BillingBalance>
getPricing(category?)Get model pricingPromise<PricingCatalog>
getPlans()Get subscription plansPromise<ApiPlan[]>
getCredits()@deprecated — returns the wallet with a deprecation messagePromise<CreditsInfo>
setOverageLimit(hardLimitUsd, projectId?)Set spending limitPromise<OverageResult>
getTopupPackages()Get top-up packagesPromise<TopupPackage[]>
createTopup(packageSlug)Create top-up checkoutPromise<TopupResult>
getTransactions(options?)Get transaction historyPromise<TransactionPage>
estimateCost(operations)Estimate operation costPromise<CostEstimate>
getInvoices()Get invoicesPromise<Invoice[]>
getTierCatalog()Get all tiersPromise<TierCatalog>
getCurrentTier()Get your current tierPromise<TierInfo>
compareTiers()Compare all tiersPromise<TierComparison>
subscribeTier(tierSlug)Retired 2026-08-13 — throws (410). Use createTopup()never
getTopupPackageList()Packages + bonus ladder + boundsPromise<TopupPackageList>
getWallet()Get wallet balancePromise<WalletInfo>
topupWallet(amountUsd, payCurrency?)Top up walletPromise<{ checkout_url: string }>
applyEnterprise(application)Apply for enterprisePromise<{ id: string; status: string }>
listWebhooks()List webhooksPromise<Webhook[]>
createWebhook(options)Create webhookPromise<Webhook>
updateWebhook(id, options)Update webhookPromise<Webhook>
deleteWebhook(id)Delete webhookPromise<void>
testWebhook(id)Test webhook deliveryPromise<WebhookTestResult>
getWebhookLogs(id)Get webhook logsPromise<WebhookLog[]>
gabrielClassify(options)Classify prompt intentPromise<...>
gabrielSuggest(options)Suggest completionsPromise<...>
gabrielRecommend(options?)Get recommendationsPromise<...>
translate(options)Translate textPromise<...>
listModels(category?)List available modelsPromise<Model[]>

Environment Variables

The TypeScript SDK reads only the API key from the environment (and only when you pass it explicitly, e.g. new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY! })). Base URL, timeout, and retries are set via constructor options.

VariableDescription
FOTOHUB_API_KEYAPI key — pass it to the constructor as shown above

Automatic Retries

The SDK automatically retries failed requests for transient errors:

  • 429 Too Many Requests - respects Retry-After header
  • 500, 502, 503, 504 - exponential backoff with jitter

Retries do NOT apply to:

  • 400 (validation errors)
  • 401 (authentication errors)
  • 402 (insufficient funds)
  • 404 (not found)
typescript
const client = new FotoHub({
  apiKey: 'fh_live_your_key_here',
  maxRetries: 5,        // up to 5 retries
  timeout: 120_000,     // 2 minute timeout per attempt
});

Advanced Feature Integrations

Brand Engine (client.brand)

The Brand Engine allows you to create consistent brand assets across all generations. This module helps you define your brand DNA and apply it consistently.

typescript
import { FotoHub } from '@fotohub/sdk';
import { z } from 'zod';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

// Define strong types for Brand Engine
export interface Brand {
  id: string;
  name: string;
  dnaId: string;
  createdAt: string;
}

export interface BrandFace {
  id: string;
  brandId: string;
  features: Record<string, any>;
}

export interface BrandExpression {
  id: string;
  type: 'smile' | 'serious' | 'laughing';
  assetUrl: string;
}

async function createBrandWorkflow() {
  // 1. Create a brand profile
  const brand = await client.brand.createBrand({
    name: "Acme Corp Summer Campaign",
    description: "Bright, energetic, and professional",
    brandColors: ["#FF5733", "#33FF57"],
    guidelinesUrl: "https://acme.com/brand.pdf"
  });
  console.log(`Brand created: ${brand.id} (Cost: $0.05)`);

  // 2. Extract DNA from reference images
  const dna = await client.brand.extractDNA({
    brandId: brand.id,
    imageUrls: [
      "https://example.com/ref1.jpg",
      "https://example.com/ref2.jpg"
    ]
  });
  console.log(`DNA Extracted: ${dna.dnaId} (Cost: $0.15)`);

  // 3. Generate a Brand Face
  const face = await client.brand.generateFace({
    brandId: brand.id,
    dnaId: dna.dnaId,
    demographics: {
      age: 25,
      gender: "female",
      ethnicity: "asian"
    }
  });
  console.log(`Face Generated: ${face.id} (Cost: $0.10)`);

  // 4. Get expressions
  const expressions = await client.brand.getExpressions({
    faceId: face.id,
    types: ["smile", "laughing"]
  });
  
  return { brand, dna, face, expressions };
}
python
import os
from fotohub import FotoHub

client = FotoHub(api_key=os.environ.get("FOTOHUB_API_KEY"))

def create_brand_workflow():
    brand = client.brand.create_brand(
        name="Acme Corp Summer Campaign",
        description="Bright, energetic, and professional",
        brand_colors=["#FF5733", "#33FF57"],
        guidelines_url="https://acme.com/brand.pdf"
    )
    print(f"Brand created: {brand.id} (Cost: $0.05)")

    dna = client.brand.extract_dna(
        brand_id=brand.id,
        image_urls=["https://example.com/ref1.jpg", "https://example.com/ref2.jpg"]
    )
    print(f"DNA Extracted: {dna.dna_id} (Cost: $0.15)")
    return brand
go
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/fotohub/fotohub-go/sdk"
)

func main() {
	client := sdk.NewClient(os.Getenv("FOTOHUB_API_KEY"))
	
	brand, _ := client.Brand.CreateBrand(context.Background(), sdk.CreateBrandRequest{
		Name: "Acme Corp Summer Campaign",
		Description: "Bright, energetic, and professional",
	})
	fmt.Printf("Brand created: %s (Cost: $0.05)
", brand.ID)
}
bash
curl -X POST https://apis.fotohub.app/v1/brand/create   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "name": "Acme Corp Summer Campaign",
    "description": "Bright, energetic, and professional"
  }'

TIP

Brand DNA extraction costs exactly $0.15. Keep this in mind when batch processing multiple brands.

UGC Studio (client.ugc)

Generate user-generated content style videos automatically with full pipeline automation.

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export interface CreateBrief {
  productName: string;
  targetAudience: string;
  keyBenefits: string[];
}

export interface RenderVideo {
  scriptId: string;
  avatarId: string;
  resolution: "1080p" | "4k";
}

async function generateUGCCampaign() {
  // 1. Create Brief
  const brief = await client.ugc.createBrief({
    productName: "GlowSerum",
    targetAudience: "Gen Z Skincare Enthusiasts",
    keyBenefits: ["Hydrating", "Vegan", "Cruelty-free"]
  });

  // 2. Write Script
  const script = await client.ugc.writeScript({
    briefId: brief.id,
    durationSeconds: 30,
    tone: "enthusiastic"
  });

  // 3. Generate Angles
  const angles = await client.ugc.generateAngles({
    scriptId: script.id,
    count: 3
  });

  // 4. Render final video
  const job = await client.ugc.renderVideo({
    scriptId: script.id,
    avatarId: "av_12345",
    resolution: "1080p"
  });
  
  console.log(`UGC Render Job started: ${job.id} (Cost: $1.50)`);
  return job;
}
python
def generate_ugc_campaign():
    brief = client.ugc.create_brief(
        product_name="GlowSerum",
        target_audience="Gen Z Skincare Enthusiasts",
        key_benefits=["Hydrating", "Vegan", "Cruelty-free"]
    )
    script = client.ugc.write_script(
        brief_id=brief.id,
        duration_seconds=30,
        tone="enthusiastic"
    )
    job = client.ugc.render_video(
        script_id=script.id,
        avatar_id="av_12345",
        resolution="1080p"
    )
    print(f"UGC Render Job started: {job.id} (Cost: $1.50)")
    return job
go
// UGC Studio Go Example
bash
curl -X POST https://apis.fotohub.app/v1/ugc/briefs   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "productName": "GlowSerum",
    "targetAudience": "Gen Z Skincare Enthusiasts",
    "keyBenefits": ["Hydrating", "Vegan", "Cruelty-free"]
  }'

Document Intelligence (client.documents)

Process documents securely using our vision models.

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function processExpenseReports(urls: string[]) {
  // Batch processing with Promise.allSettled
  const results = await Promise.allSettled(
    urls.map(url => client.documents.analyzeExpense({
      documentUrl: url,
      extractLineItems: true
    }))
  );

  const successful = results
    .filter((r): r is PromiseFulfilledResult<any> => r.status === 'fulfilled')
    .map(r => r.value);
    
  const failed = results
    .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
    .map(r => r.reason);

  console.log(`Processed ${successful.length} documents. Total cost: $${(successful.length * 0.02).toFixed(2)}`);
  
  return { successful, failed };
}
python
def process_expense_reports(urls):
    successful = []
    for url in urls:
        res = client.documents.analyze_expense(document_url=url, extract_line_items=True)
        successful.append(res)
    print(f"Processed {len(successful)} documents. Total cost: ${len(successful) * 0.02:.2f}")
go
// Document Intelligence Go Example
bash
curl -X POST https://apis.fotohub.app/v1/documents/analyze-expense   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "documentUrl": "https://example.com/receipt.jpg",
    "extractLineItems": true
  }'

Virtual Try-On (client.tryon)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export interface TryonRequest {
  personImageUrl: string;
  garmentImageUrl: string;
  category: 'tops' | 'bottoms' | 'dresses';
}

export interface TryonJob {
  id: string;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  resultUrl?: string;
}

async function runTryOnWithTimeout(req: TryonRequest): Promise<string> {
  const job = await client.tryon.submit(req);
  
  const abortController = new AbortController();
  const timeout = setTimeout(() => abortController.abort(), 60000); // 1 minute timeout

  try {
    // Polling logic
    while (true) {
      if (abortController.signal.aborted) {
        throw new Error("Try-on polling timed out");
      }
      
      const status = await client.tryon.getJob(job.id);
      if (status.status === 'completed') {
        clearTimeout(timeout);
        return status.resultUrl!;
      }
      if (status.status === 'failed') {
        throw new Error("Try-on job failed");
      }
      
      // Wait 2 seconds before next poll
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  } finally {
    clearTimeout(timeout);
  }
}
python
# Try-on Python example
go
// Try-on Go example
bash
curl -X POST https://apis.fotohub.app/v1/tryon/submit   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "personImageUrl": "https://example.com/person.jpg",
    "garmentImageUrl": "https://example.com/shirt.jpg",
    "category": "tops"
  }'

3D Generation (client.models3d)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export interface Generate3DRequest {
  imageUrl: string;
  format: 'GLB' | 'USDZ' | 'OBJ';
  highPoly: boolean;
}

async function createProductModel() {
  const req: Generate3DRequest = {
    imageUrl: "https://example.com/shoe.png",
    format: "GLB",
    highPoly: true
  };
  
  const job = await client.models3d.generate(req);
  console.log(`Started 3D Generation. Cost will be $2.50 upon completion.`);
  return job.id;
}
python
# 3D Gen Python example
go
// 3D Gen Go example
bash
# 3D Gen cURL example

Social Studio (client.social)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function scheduleCampaign() {
  const result = await client.social.schedulePost({
    platforms: ['twitter', 'instagram', 'linkedin'],
    content: "Check out our new AI features! 🚀",
    mediaUrls: ["https://example.com/video.mp4"],
    scheduleAt: new Date(Date.now() + 86400000).toISOString() // Tomorrow
  });
  
  console.log(`Scheduled across platforms. Campaign ID: ${result.campaignId}`);
}
python
# Social Python example
go
// Social Go example
bash
# Social cURL example

Shorts Engine (client.shorts)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function extractViralClips(videoUrl: string) {
  // Using SSE streaming to get clips as they are found
  const stream = await client.shorts.extractClipsStream({
    videoUrl,
    targetDuration: [15, 60],
    focus: 'faces_and_speech'
  });

  for await (const chunk of stream) {
    if (chunk.type === 'clip_found') {
      console.log(`Found a potential viral clip! Score: ${chunk.data.viralityScore}`);
    } else if (chunk.type === 'progress') {
      console.log(`Processing... ${chunk.data.percent}%`);
    }
  }
  
  console.log(`Extraction complete. Cost: $0.50 per minute of source video.`);
}
python
# Shorts Python example
go
// Shorts Go example
bash
# Shorts cURL example

Lip-Sync (client.lipSync)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function animateAvatar() {
  const job = await client.lipSync.create({
    videoUrl: "https://example.com/silent_avatar.mp4",
    audioUrl: "https://example.com/voiceover.mp3",
    syncMode: "high_precision"
  });
  
  console.log(`Lip-sync job started: ${job.id}`);
  // Implement polling similar to Try-On
}
python
# Lip-Sync Python example
go
// Lip-Sync Go example
bash
# Lip-Sync cURL example

Framework Integrations

React Hooks Library

We recommend creating custom hooks to encapsulate FotoHub logic in your React applications.

tsx
import { useState, useCallback } from 'react';
import { FotoHub } from '@fotohub/sdk';

// Initialize client outside component to avoid recreation
const client = new FotoHub({ apiKey: process.env.NEXT_PUBLIC_FOTOHUB_API_KEY });

export function useImageGeneration() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [data, setData] = useState<string | null>(null);

  const generate = useCallback(async (prompt: string) => {
    setLoading(true);
    setError(null);
    try {
      const response = await client.image.generate({
        prompt,
        size: "1024x1024",
        model: "fotohub-v2"
      });
      setData(response.data[0].url);
      return response.data[0].url;
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  return { generate, loading, error, data };
}

Next.js App Router (Server Components)

tsx
import { FotoHub } from '@fotohub/sdk';
import Image from 'next/image';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export const metadata = {
  title: 'AI Hero Banner',
  description: 'Generated dynamically'
};

export default async function Page() {
  // Generate on the server during request or build (depending on cache settings)
  const response = await client.image.generate({
    prompt: "A beautiful cinematic landscape of futuristic city, neon lights",
    size: "1024x512",
    model: "fotohub-v2"
  });

  const imageUrl = response.data[0].url;
  
  return (
    <main className="flex min-h-screen flex-col items-center">
      <h1>Dynamic AI Header</h1>
      <Image 
        src={imageUrl} 
        alt="AI Generated Landscape" 
        width={1024} 
        height={512}
        priority
      />
      <p className="mt-4 text-sm text-gray-500">Cost: $0.025</p>
    </main>
  );
}

Next.js Route Handler

typescript
import { NextResponse } from 'next/server';
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { prompt } = body;
    
    if (!prompt) {
      return NextResponse.json({ error: 'Prompt is required' }, { status: 400 });
    }
    
    const response = await client.image.generate({
      prompt,
      size: "1024x1024"
    });
    
    return NextResponse.json({ url: response.data[0].url });
  } catch (error) {
    console.error('FotoHub error:', error);
    return NextResponse.json(
      { error: 'Failed to generate image' }, 
      { status: 500 }
    );
  }
}

Next.js Edge Runtime

typescript
import { NextResponse } from 'next/server';
import { FotoHub } from '@fotohub/sdk';

export const runtime = 'edge';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export async function POST(request: Request) {
  const { prompt } = await request.json();
  const res = await client.image.generate({ prompt });
  return NextResponse.json(res);
}

Nuxt.js Composable

typescript
import { ref } from 'vue';
import { useFetch } from '#app';

export const useFotoHubImage = () => {
  const imageUrl = ref<string | null>(null);
  const isGenerating = ref(false);
  const error = ref<string | null>(null);

  const generate = async (prompt: string) => {
    isGenerating.value = true;
    error.value = null;
    
    try {
      const { data, error: fetchError } = await useFetch('/api/generate', {
        method: 'POST',
        body: { prompt }
      });
      
      if (fetchError.value) throw new Error(fetchError.value.message);
      
      // @ts-ignore
      imageUrl.value = data.value?.url;
    } catch (e) {
      error.value = (e as Error).message;
    } finally {
      isGenerating.value = false;
    }
  };

  return { imageUrl, isGenerating, error, generate };
};

Remix Loader

typescript
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { FotoHub } from '@fotohub/sdk';

export async function loader({ request }: LoaderFunctionArgs) {
  const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });
  
  const response = await client.image.generate({
    prompt: "A welcoming hero image for a creative agency",
    size: "1024x512"
  });
  
  return json({ heroImage: response.data[0].url });
}

export default function Index() {
  const { heroImage } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <img src={heroImage} alt="Hero" className="w-full" />
    </div>
  );
}

SvelteKit Load

typescript
import { FotoHub } from '@fotohub/sdk';
import { FOTOHUB_API_KEY } from '$env/static/private';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  const client = new FotoHub({ apiKey: FOTOHUB_API_KEY });
  
  const response = await client.image.generate({
    prompt: "Abstract artistic background, cool tones",
    size: "1024x1024"
  });
  
  return {
    backgroundImage: response.data[0].url
  };
};

Bun HTTP Server

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    
    if (req.method === "POST" && url.pathname === "/api/generate") {
      const body = await req.json();
      const result = await client.image.generate({ prompt: body.prompt });
      return new Response(JSON.stringify(result), {
        headers: { "Content-Type": "application/json" }
      });
    }
    
    return new Response("Not Found", { status: 404 });
  }
});

tRPC Procedures

typescript
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { FotoHub } from '@fotohub/sdk';

const t = initTRPC.create();
const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export const appRouter = t.router({
  generateImage: t.procedure
    .input(z.object({
      prompt: z.string().min(5),
      size: z.enum(['512x512', '1024x1024']).default('1024x1024')
    }))
    .mutation(async ({ input }) => {
      const response = await client.image.generate(input);
      return { url: response.data[0].url };
    })
});

export type AppRouter = typeof appRouter;

React Query Integration

tsx
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';

function useGenerateImage() {
  return useMutation({
    mutationFn: async (prompt: string) => {
      const res = await fetch('/api/generate', {
        method: 'POST',
        body: JSON.stringify({ prompt }),
        headers: { 'Content-Type': 'application/json' }
      });
      if (!res.ok) throw new Error('Generation failed');
      return res.json();
    }
  });
}

export function ImageGenerator() {
  const [prompt, setPrompt] = useState('');
  const mutation = useGenerateImage();
  
  return (
    <div>
      <input value={prompt} onChange={e => setPrompt(e.target.value)} />
      <button onClick={() => mutation.mutate(prompt)} disabled={mutation.isPending}>
        Generate ($0.025)
      </button>
      
      {mutation.data && <img src={mutation.data.url} alt="Result" />}
    </div>
  );
}

Advanced TypeScript Patterns

Generic Async Job Poller

A robust polling mechanism is essential for interacting with FotoHub's asynchronous endpoints (Video, 3D, Shorts, LipSync).

typescript
export interface JobResponse<T> {
  status: 'pending' | 'processing' | 'completed' | 'failed';
  result?: T;
  error?: string;
}

export async function pollJob<T>(
  jobId: string,
  getter: (id: string) => Promise<JobResponse<T>>,
  options: { intervalMs?: number; timeoutMs?: number } = {}
): Promise<T> {
  const { intervalMs = 2000, timeoutMs = 120000 } = options;
  const startTime = Date.now();

  while (true) {
    if (Date.now() - startTime > timeoutMs) {
      throw new Error(`Job ${jobId} polling timed out after ${timeoutMs}ms`);
    }

    const response = await getter(jobId);

    if (response.status === 'completed' && response.result) {
      return response.result;
    }
    if (response.status === 'failed') {
      throw new Error(`Job ${jobId} failed: ${response.error}`);
    }

    // Wait before next poll
    await new Promise(resolve => setTimeout(resolve, intervalMs));
  }
}

Type-Safe Webhook Handler

FotoHub webhooks provide asynchronous delivery of events. Validating them with Zod ensures type safety.

typescript
import { z } from 'zod';
import crypto from 'crypto';

const WebhookHeaderSchema = z.object({
  'x-fotohub-signature': z.string()
});

const JobCompletedEventSchema = z.object({
  type: z.literal('job.completed'),
  data: z.object({
    jobId: z.string(),
    resultUrl: z.string().url(),
    costUsd: z.number()
  })
});

const JobFailedEventSchema = z.object({
  type: z.literal('job.failed'),
  data: z.object({
    jobId: z.string(),
    reason: z.string()
  })
});

const WebhookEventSchema = z.discriminatedUnion('type', [
  JobCompletedEventSchema,
  JobFailedEventSchema
]);

export type WebhookEvent = z.infer<typeof WebhookEventSchema>;

export function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
  const hmac = crypto.createHmac('sha256', secret);
  const digest = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}

export function handleWebhook(payloadStr: string, headers: Record<string, string>, secret: string) {
  const { 'x-fotohub-signature': signature } = WebhookHeaderSchema.parse(headers);
  
  if (!verifyWebhookSignature(payloadStr, signature, secret)) {
    throw new Error('Invalid signature');
  }
  
  const payload = JSON.parse(payloadStr);
  const event = WebhookEventSchema.parse(payload);
  
  switch (event.type) {
    case 'job.completed':
      console.log(`Job completed! Cost: $${event.data.costUsd}`);
      // Process successful job
      break;
    case 'job.failed':
      console.error(`Job failed: ${event.data.reason}`);
      // Handle failure
      break;
  }
}

Builder Pattern for Complex Requests

typescript
import { ImageGenerationRequest } from '@fotohub/sdk';

export class ImageRequestBuilder {
  private request: Partial<ImageGenerationRequest> = {
    model: 'fotohub-v2',
    size: '1024x1024',
    n: 1
  };

  withPrompt(prompt: string): this {
    this.request.prompt = prompt;
    return this;
  }

  withNegativePrompt(negativePrompt: string): this {
    this.request.negative_prompt = negativePrompt;
    return this;
  }

  landscape(): this {
    this.request.size = '1024x512';
    return this;
  }

  portrait(): this {
    this.request.size = '512x1024';
    return this;
  }

  build(): ImageGenerationRequest {
    if (!this.request.prompt) {
      throw new Error("Prompt is required");
    }
    return this.request as ImageGenerationRequest;
  }
}

// Usage:
// const req = new ImageRequestBuilder()
//   .withPrompt("Cyberpunk city")
//   .landscape()
//   .build();

Middleware Pipeline

typescript
import { FotoHub } from '@fotohub/sdk';

// A wrapper around the standard client to add cross-cutting concerns
export class InstrumentedFotoHub {
  private client: FotoHub;
  
  constructor(apiKey: string) {
    this.client = new FotoHub({ apiKey });
  }
  
  async generateImage(prompt: string) {
    const startTime = performance.now();
    try {
      console.log(`[FotoHub] Starting image generation...`);
      const result = await this.client.image.generate({ prompt });
      const duration = performance.now() - startTime;
      console.log(`[FotoHub] Success (${Math.round(duration)}ms)`);
      return result;
    } catch (error) {
      const duration = performance.now() - startTime;
      console.error(`[FotoHub] Failed after ${Math.round(duration)}ms:`, error);
      throw error;
    }
  }
}

Cost Estimation Before Generation

Always display costs in USD natively in your UI.

typescript
import { ImageGenerationRequest } from '@fotohub/sdk';

export function estimateCost(request: ImageGenerationRequest): number {
  let basePrice = 0.025; // Standard 1024x1024
  
  if (request.size === '2048x2048') basePrice = 0.08;
  if (request.size === '512x512') basePrice = 0.01;
  
  let total = basePrice * (request.n || 1);
  
  // High-step generations cost more
  if (request.steps && request.steps > 50) {
    total *= 1.5;
  }
  
  return Number(total.toFixed(4));
}

Streaming Response Parser

typescript
export async function* parseSSEStream<T>(response: Response): AsyncGenerator<T, void, unknown> {
  if (!response.body) throw new Error("No response body");
  
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('
');
    
    // Keep the last incomplete line in the buffer
    buffer = lines.pop() || '';
    
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const dataStr = line.slice(6);
        if (dataStr === '[DONE]') return;
        try {
          yield JSON.parse(dataStr) as T;
        } catch (e) {
          console.warn('Failed to parse SSE data:', dataStr);
        }
      }
    }
  }
}

Unit Tests with Vitest

typescript
import { describe, it, expect, vi } from 'vitest';
import { FotoHub } from '@fotohub/sdk';

// Mock the global fetch
global.fetch = vi.fn();

describe('FotoHub Client', () => {
  it('generates an image successfully', async () => {
    const mockResponse = {
      data: [{ url: 'https://example.com/image.png' }]
    };
    
    vi.mocked(global.fetch).mockResolvedValueOnce({
      ok: true,
      json: async () => mockResponse
    } as Response);
    
    const client = new FotoHub({ apiKey: 'test_key' });
    const result = await client.image.generate({ prompt: 'test' });
    
    expect(result.data[0].url).toBe('https://example.com/image.png');
    expect(global.fetch).toHaveBeenCalledWith(
      'https://apis.fotohub.app/v1/images/generations',
      expect.objectContaining({
        method: 'POST',
        headers: expect.objectContaining({
          'Authorization': 'Bearer test_key'
        })
      })
    );
  });
});

Production Guides

Environment Variable Management

typescript
import { z } from 'zod';

const envSchema = z.object({
  FOTOHUB_API_KEY: z.string().startsWith('fh_live_'),
  FOTOHUB_WEBHOOK_SECRET: z.string().optional(),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
});

// Throws if validation fails, keeping your app safe
export const env = envSchema.parse(process.env);

Error Handling Hierarchy

typescript
export class FotoHubError extends Error {
  constructor(message: string, public statusCode: number, public requestId?: string) {
    super(message);
    this.name = 'FotoHubError';
  }
}

export class RateLimitError extends FotoHubError {
  constructor(message: string, public retryAfterSec: number) {
    super(message, 429);
    this.name = 'RateLimitError';
  }
}

export class InsufficientFundsError extends FotoHubError {
  constructor(public currentBalanceUsd: number, public requiredUsd: number) {
    super(`Insufficient funds. Have $${currentBalanceUsd}, need $${requiredUsd}`, 402);
    this.name = 'InsufficientFundsError';
  }
}

export function handleApiError(error: any): never {
  if (error.response) {
    const status = error.response.status;
    const data = error.response.data;
    
    if (status === 429) {
      throw new RateLimitError(data.error.message, data.error.retry_after || 60);
    }
    if (status === 402) {
      throw new InsufficientFundsError(data.error.balance_usd, data.error.required_usd);
    }
    throw new FotoHubError(data.error?.message || 'API Error', status, error.response.headers['x-request-id']);
  }
  
  throw error;
}

Logging Integration with Pino

typescript
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => {
      return { level: label.toUpperCase() };
    },
  },
});

// Usage in FotoHub context:
export async function generateWithLogging(prompt: string) {
  logger.info({ action: 'fotohub_generate_start', prompt });
  const start = Date.now();
  try {
    // client call here
    logger.info({ 
      action: 'fotohub_generate_success', 
      durationMs: Date.now() - start,
      costUsd: 0.025
    });
  } catch (err) {
    logger.error({ 
      action: 'fotohub_generate_error', 
      error: err,
      durationMs: Date.now() - start 
    });
    throw err;
  }
}

Sentry Integration

typescript
import * as Sentry from '@sentry/node';
import { FotoHubError } from './FotoHubErrors';

export function setupSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    tracesSampleRate: 1.0,
  });
}

export async function captureFotoHubOperation<T>(
  operationName: string, 
  operation: () => Promise<T>
): Promise<T> {
  return Sentry.startSpan({ name: `FotoHub: ${operationName}` }, async (span) => {
    try {
      return await operation();
    } catch (error) {
      if (error instanceof FotoHubError) {
        Sentry.setContext("FotoHub", {
          statusCode: error.statusCode,
          requestId: error.requestId
        });
        if (error.statusCode === 429) {
          // Maybe don't alert loudly for rate limits
          Sentry.captureMessage('FotoHub Rate Limit Hit', 'warning');
        } else {
          Sentry.captureException(error);
        }
      } else {
        Sentry.captureException(error);
      }
      span.setStatus("error");
      throw error;
    }
  });
}

OpenTelemetry Tracing

typescript
import { trace, context } from '@opentelemetry/api';

const tracer = trace.getTracer('fotohub-sdk-wrapper');

export async function tracedGenerateImage(prompt: string) {
  return tracer.startActiveSpan('FotoHub.Image.Generate', async (span) => {
    span.setAttribute('fotohub.prompt.length', prompt.length);
    span.setAttribute('fotohub.expected_cost_usd', 0.025);
    
    try {
      // Execute the request
      const response = await client.image.generate({ prompt });
      span.setAttribute('fotohub.request_id', response.requestId);
      span.setStatus({ code: 1 }); // OK
      return response;
    } catch (error: any) {
      span.setStatus({
        code: 2, // ERROR
        message: error.message
      });
      span.recordException(error);
      throw error;
    } finally {
      span.end();
    }
  });
}

Complete Architecture Diagram

Here is the high-level architecture diagram showing how the SDK interacts with the FotoHub Platform.

mermaid
flowchart TD
    App[Your Node/React/Edge App] --> SDK[FotoHub TypeScript SDK]
    SDK --> |REST / JSON| APIG[API Gateway `apis.fotohub.app`]
    APIG --> |Auth & Rate Limits| Services
    
    subgraph Services [FotoHub Microservices]
      Img[Image Generation Service]
      Vid[Video & UGC Service]
      Doc[Document Intelligence]
      Brand[Brand Engine]
    end
    
    Img --> GPU1[(GPU Cluster 1 - SDXL)]
    Vid --> GPU2[(GPU Cluster 2 - MMAudio)]
    Vid --> GPU3[(GPU Cluster 3 - MuseTalk/LipSync)]
    Doc --> GPU4[(GPU Cluster 4 - Vision/LLaVA)]
    
    Services --> |Webhooks| App

Parameter Table: client.image.generate

ParameterTypeRequiredDefaultDescription
promptstringYes-The text prompt for generation.
modelstringNofotohub-v2The model to use.
sizestringNo1024x1024Resolution. Valid: 512x512, 1024x1024, 1024x512, 512x1024
nnumberNo1Number of images to generate (1-4).
negative_promptstringNo-Elements to avoid in the generation.
stepsnumberNo30Number of diffusion steps (10-100).
seednumberNoRandomFix the seed for reproducible results.

GPU Affinity & Performance

When utilizing client.ugc and client.lipSync, requests are dynamically routed. GPU2 is strictly dedicated to MMAudio rendering while GPU3 handles MuseTalk/LipSync operations. 3D jobs (client.models3d) will always hit GPU4/5 clusters for optimal speed. Plan your concurrent requests accordingly to avoid localized cluster rate-limits.

Advanced System Patterns

Brand Engine (client.brand_v2)

The Brand Engine allows you to create consistent brand assets across all generations. This module helps you define your brand DNA and apply it consistently.

typescript
import { FotoHub } from '@fotohub/sdk';
import { z } from 'zod';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

// Define strong types for Brand Engine
export interface Brand {
  id: string;
  name: string;
  dnaId: string;
  createdAt: string;
}

export interface BrandFace {
  id: string;
  brandId: string;
  features: Record<string, any>;
}

export interface BrandExpression {
  id: string;
  type: 'smile' | 'serious' | 'laughing';
  assetUrl: string;
}

async function createBrandWorkflow() {
  // 1. Create a brand profile
  const brand = await client.brand_v2.createBrand({
    name: "Acme Corp Summer Campaign",
    description: "Bright, energetic, and professional",
    brandColors: ["#FF5733", "#33FF57"],
    guidelinesUrl: "https://acme.com/brand.pdf"
  });
  console.log(`Brand created: ${brand.id} (Cost: $0.05)`);

  // 2. Extract DNA from reference images
  const dna = await client.brand_v2.extractDNA({
    brandId: brand.id,
    imageUrls: [
      "https://example.com/ref1.jpg",
      "https://example.com/ref2.jpg"
    ]
  });
  console.log(`DNA Extracted: ${dna.dnaId} (Cost: $0.15)`);

  // 3. Generate a Brand Face
  const face = await client.brand_v2.generateFace({
    brandId: brand.id,
    dnaId: dna.dnaId,
    demographics: {
      age: 25,
      gender: "female",
      ethnicity: "asian"
    }
  });
  console.log(`Face Generated: ${face.id} (Cost: $0.10)`);

  // 4. Get expressions
  const expressions = await client.brand_v2.getExpressions({
    faceId: face.id,
    types: ["smile", "laughing"]
  });
  
  return { brand, dna, face, expressions };
}
python
import os
from fotohub import FotoHub

client = FotoHub(api_key=os.environ.get("FOTOHUB_API_KEY"))

def create_brand_workflow():
    brand = client.brand_v2.create_brand(
        name="Acme Corp Summer Campaign",
        description="Bright, energetic, and professional",
        brand_colors=["#FF5733", "#33FF57"],
        guidelines_url="https://acme.com/brand.pdf"
    )
    print(f"Brand created: {brand.id} (Cost: $0.05)")

    dna = client.brand_v2.extract_dna(
        brand_id=brand.id,
        image_urls=["https://example.com/ref1.jpg", "https://example.com/ref2.jpg"]
    )
    print(f"DNA Extracted: {dna.dna_id} (Cost: $0.15)")
    return brand
go
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/fotohub/fotohub-go/sdk"
)

func main() {
	client := sdk.NewClient(os.Getenv("FOTOHUB_API_KEY"))
	
	brand, _ := client.Brand.CreateBrand(context.Background(), sdk.CreateBrandRequest{
		Name: "Acme Corp Summer Campaign",
		Description: "Bright, energetic, and professional",
	})
	fmt.Printf("Brand created: %s (Cost: $0.05)
", brand.ID)
}
bash
curl -X POST https://apis.fotohub.app/v1/brand/create   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "name": "Acme Corp Summer Campaign",
    "description": "Bright, energetic, and professional"
  }'

TIP

Brand DNA extraction costs exactly $0.15. Keep this in mind when batch processing multiple brands.

UGC Studio (client.ugc)

Generate user-generated content style videos automatically with full pipeline automation.

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export interface CreateBrief {
  productName: string;
  targetAudience: string;
  keyBenefits: string[];
}

export interface RenderVideo {
  scriptId: string;
  avatarId: string;
  resolution: "1080p" | "4k";
}

async function generateUGCCampaign() {
  // 1. Create Brief
  const brief = await client.ugc.createBrief({
    productName: "GlowSerum",
    targetAudience: "Gen Z Skincare Enthusiasts",
    keyBenefits: ["Hydrating", "Vegan", "Cruelty-free"]
  });

  // 2. Write Script
  const script = await client.ugc.writeScript({
    briefId: brief.id,
    durationSeconds: 30,
    tone: "enthusiastic"
  });

  // 3. Generate Angles
  const angles = await client.ugc.generateAngles({
    scriptId: script.id,
    count: 3
  });

  // 4. Render final video
  const job = await client.ugc.renderVideo({
    scriptId: script.id,
    avatarId: "av_12345",
    resolution: "1080p"
  });
  
  console.log(`UGC Render Job started: ${job.id} (Cost: $1.50)`);
  return job;
}
python
def generate_ugc_campaign():
    brief = client.ugc.create_brief(
        product_name="GlowSerum",
        target_audience="Gen Z Skincare Enthusiasts",
        key_benefits=["Hydrating", "Vegan", "Cruelty-free"]
    )
    script = client.ugc.write_script(
        brief_id=brief.id,
        duration_seconds=30,
        tone="enthusiastic"
    )
    job = client.ugc.render_video(
        script_id=script.id,
        avatar_id="av_12345",
        resolution="1080p"
    )
    print(f"UGC Render Job started: {job.id} (Cost: $1.50)")
    return job
go
// UGC Studio Go Example
bash
curl -X POST https://apis.fotohub.app/v1/ugc/briefs   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "productName": "GlowSerum",
    "targetAudience": "Gen Z Skincare Enthusiasts",
    "keyBenefits": ["Hydrating", "Vegan", "Cruelty-free"]
  }'

Document Intelligence (client.documents)

Process documents securely using our vision models.

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function processExpenseReports(urls: string[]) {
  // Batch processing with Promise.allSettled
  const results = await Promise.allSettled(
    urls.map(url => client.documents.analyzeExpense({
      documentUrl: url,
      extractLineItems: true
    }))
  );

  const successful = results
    .filter((r): r is PromiseFulfilledResult<any> => r.status === 'fulfilled')
    .map(r => r.value);
    
  const failed = results
    .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
    .map(r => r.reason);

  console.log(`Processed ${successful.length} documents. Total cost: $${(successful.length * 0.02).toFixed(2)}`);
  
  return { successful, failed };
}
python
def process_expense_reports(urls):
    successful = []
    for url in urls:
        res = client.documents.analyze_expense(document_url=url, extract_line_items=True)
        successful.append(res)
    print(f"Processed {len(successful)} documents. Total cost: ${len(successful) * 0.02:.2f}")
go
// Document Intelligence Go Example
bash
curl -X POST https://apis.fotohub.app/v1/documents/analyze-expense   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "documentUrl": "https://example.com/receipt.jpg",
    "extractLineItems": true
  }'

Virtual Try-On (client.tryon)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export interface TryonRequest {
  personImageUrl: string;
  garmentImageUrl: string;
  category: 'tops' | 'bottoms' | 'dresses';
}

export interface TryonJob {
  id: string;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  resultUrl?: string;
}

async function runTryOnWithTimeout(req: TryonRequest): Promise<string> {
  const job = await client.tryon.submit(req);
  
  const abortController = new AbortController();
  const timeout = setTimeout(() => abortController.abort(), 60000); // 1 minute timeout

  try {
    // Polling logic
    while (true) {
      if (abortController.signal.aborted) {
        throw new Error("Try-on polling timed out");
      }
      
      const status = await client.tryon.getJob(job.id);
      if (status.status === 'completed') {
        clearTimeout(timeout);
        return status.resultUrl!;
      }
      if (status.status === 'failed') {
        throw new Error("Try-on job failed");
      }
      
      // Wait 2 seconds before next poll
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  } finally {
    clearTimeout(timeout);
  }
}
python
# Try-on Python example
go
// Try-on Go example
bash
curl -X POST https://apis.fotohub.app/v1/tryon/submit   -H "Authorization: Bearer fh_live_your_api_key"   -H "Content-Type: application/json"   -d '{
    "personImageUrl": "https://example.com/person.jpg",
    "garmentImageUrl": "https://example.com/shirt.jpg",
    "category": "tops"
  }'

3D Generation (client.models3d)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export interface Generate3DRequest {
  imageUrl: string;
  format: 'GLB' | 'USDZ' | 'OBJ';
  highPoly: boolean;
}

async function createProductModel() {
  const req: Generate3DRequest = {
    imageUrl: "https://example.com/shoe.png",
    format: "GLB",
    highPoly: true
  };
  
  const job = await client.models3d.generate(req);
  console.log(`Started 3D Generation. Cost will be $2.50 upon completion.`);
  return job.id;
}
python
# 3D Gen Python example
go
// 3D Gen Go example
bash
# 3D Gen cURL example

Social Studio (client.social)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function scheduleCampaign() {
  const result = await client.social.schedulePost({
    platforms: ['twitter', 'instagram', 'linkedin'],
    content: "Check out our new AI features! 🚀",
    mediaUrls: ["https://example.com/video.mp4"],
    scheduleAt: new Date(Date.now() + 86400000).toISOString() // Tomorrow
  });
  
  console.log(`Scheduled across platforms. Campaign ID: ${result.campaignId}`);
}
python
# Social Python example
go
// Social Go example
bash
# Social cURL example

Shorts Engine (client.shorts)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function extractViralClips(videoUrl: string) {
  // Using SSE streaming to get clips as they are found
  const stream = await client.shorts.extractClipsStream({
    videoUrl,
    targetDuration: [15, 60],
    focus: 'faces_and_speech'
  });

  for await (const chunk of stream) {
    if (chunk.type === 'clip_found') {
      console.log(`Found a potential viral clip! Score: ${chunk.data.viralityScore}`);
    } else if (chunk.type === 'progress') {
      console.log(`Processing... ${chunk.data.percent}%`);
    }
  }
  
  console.log(`Extraction complete. Cost: $0.50 per minute of source video.`);
}
python
# Shorts Python example
go
// Shorts Go example
bash
# Shorts cURL example

Lip-Sync (client.lipSync)

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

async function animateAvatar() {
  const job = await client.lipSync.create({
    videoUrl: "https://example.com/silent_avatar.mp4",
    audioUrl: "https://example.com/voiceover.mp3",
    syncMode: "high_precision"
  });
  
  console.log(`Lip-sync job started: ${job.id}`);
  // Implement polling similar to Try-On
}
python
# Lip-Sync Python example
go
// Lip-Sync Go example
bash
# Lip-Sync cURL example

Framework Integrations

React Hooks Library

We recommend creating custom hooks to encapsulate FotoHub logic in your React applications.

tsx
import { useState, useCallback } from 'react';
import { FotoHub } from '@fotohub/sdk';

// Initialize client outside component to avoid recreation
const client = new FotoHub({ apiKey: process.env.NEXT_PUBLIC_FOTOHUB_API_KEY });

export function useImageGeneration() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [data, setData] = useState<string | null>(null);

  const generate = useCallback(async (prompt: string) => {
    setLoading(true);
    setError(null);
    try {
      const response = await client.image.generate({
        prompt,
        size: "1024x1024",
        model: "fotohub-v2"
      });
      setData(response.data[0].url);
      return response.data[0].url;
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  return { generate, loading, error, data };
}

Next.js App Router (Server Components)

tsx
import { FotoHub } from '@fotohub/sdk';
import Image from 'next/image';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export const metadata = {
  title: 'AI Hero Banner',
  description: 'Generated dynamically'
};

export default async function Page() {
  // Generate on the server during request or build (depending on cache settings)
  const response = await client.image.generate({
    prompt: "A beautiful cinematic landscape of futuristic city, neon lights",
    size: "1024x512",
    model: "fotohub-v2"
  });

  const imageUrl = response.data[0].url;
  
  return (
    <main className="flex min-h-screen flex-col items-center">
      <h1>Dynamic AI Header</h1>
      <Image 
        src={imageUrl} 
        alt="AI Generated Landscape" 
        width={1024} 
        height={512}
        priority
      />
      <p className="mt-4 text-sm text-gray-500">Cost: $0.025</p>
    </main>
  );
}

Next.js Route Handler

typescript
import { NextResponse } from 'next/server';
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { prompt } = body;
    
    if (!prompt) {
      return NextResponse.json({ error: 'Prompt is required' }, { status: 400 });
    }
    
    const response = await client.image.generate({
      prompt,
      size: "1024x1024"
    });
    
    return NextResponse.json({ url: response.data[0].url });
  } catch (error) {
    console.error('FotoHub error:', error);
    return NextResponse.json(
      { error: 'Failed to generate image' }, 
      { status: 500 }
    );
  }
}

Next.js Edge Runtime

typescript
import { NextResponse } from 'next/server';
import { FotoHub } from '@fotohub/sdk';

export const runtime = 'edge';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export async function POST(request: Request) {
  const { prompt } = await request.json();
  const res = await client.image.generate({ prompt });
  return NextResponse.json(res);
}

Nuxt.js Composable

typescript
import { ref } from 'vue';
import { useFetch } from '#app';

export const useFotoHubImage = () => {
  const imageUrl = ref<string | null>(null);
  const isGenerating = ref(false);
  const error = ref<string | null>(null);

  const generate = async (prompt: string) => {
    isGenerating.value = true;
    error.value = null;
    
    try {
      const { data, error: fetchError } = await useFetch('/api/generate', {
        method: 'POST',
        body: { prompt }
      });
      
      if (fetchError.value) throw new Error(fetchError.value.message);
      
      // @ts-ignore
      imageUrl.value = data.value?.url;
    } catch (e) {
      error.value = (e as Error).message;
    } finally {
      isGenerating.value = false;
    }
  };

  return { imageUrl, isGenerating, error, generate };
};

Remix Loader

typescript
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { FotoHub } from '@fotohub/sdk';

export async function loader({ request }: LoaderFunctionArgs) {
  const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });
  
  const response = await client.image.generate({
    prompt: "A welcoming hero image for a creative agency",
    size: "1024x512"
  });
  
  return json({ heroImage: response.data[0].url });
}

export default function Index() {
  const { heroImage } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <img src={heroImage} alt="Hero" className="w-full" />
    </div>
  );
}

SvelteKit Load

typescript
import { FotoHub } from '@fotohub/sdk';
import { FOTOHUB_API_KEY } from '$env/static/private';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  const client = new FotoHub({ apiKey: FOTOHUB_API_KEY });
  
  const response = await client.image.generate({
    prompt: "Abstract artistic background, cool tones",
    size: "1024x1024"
  });
  
  return {
    backgroundImage: response.data[0].url
  };
};

Bun HTTP Server

typescript
import { FotoHub } from '@fotohub/sdk';

const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    
    if (req.method === "POST" && url.pathname === "/api/generate") {
      const body = await req.json();
      const result = await client.image.generate({ prompt: body.prompt });
      return new Response(JSON.stringify(result), {
        headers: { "Content-Type": "application/json" }
      });
    }
    
    return new Response("Not Found", { status: 404 });
  }
});

tRPC Procedures

typescript
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { FotoHub } from '@fotohub/sdk';

const t = initTRPC.create();
const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY });

export const appRouter = t.router({
  generateImage: t.procedure
    .input(z.object({
      prompt: z.string().min(5),
      size: z.enum(['512x512', '1024x1024']).default('1024x1024')
    }))
    .mutation(async ({ input }) => {
      const response = await client.image.generate(input);
      return { url: response.data[0].url };
    })
});

export type AppRouter = typeof appRouter;

React Query Integration

tsx
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';

function useGenerateImage() {
  return useMutation({
    mutationFn: async (prompt: string) => {
      const res = await fetch('/api/generate', {
        method: 'POST',
        body: JSON.stringify({ prompt }),
        headers: { 'Content-Type': 'application/json' }
      });
      if (!res.ok) throw new Error('Generation failed');
      return res.json();
    }
  });
}

export function ImageGenerator() {
  const [prompt, setPrompt] = useState('');
  const mutation = useGenerateImage();
  
  return (
    <div>
      <input value={prompt} onChange={e => setPrompt(e.target.value)} />
      <button onClick={() => mutation.mutate(prompt)} disabled={mutation.isPending}>
        Generate ($0.025)
      </button>
      
      {mutation.data && <img src={mutation.data.url} alt="Result" />}
    </div>
  );
}

Advanced TypeScript Patterns

Generic Async Job Poller

A robust polling mechanism is essential for interacting with FotoHub's asynchronous endpoints (Video, 3D, Shorts, LipSync).

typescript
export interface JobResponse<T> {
  status: 'pending' | 'processing' | 'completed' | 'failed';
  result?: T;
  error?: string;
}

export async function pollJob<T>(
  jobId: string,
  getter: (id: string) => Promise<JobResponse<T>>,
  options: { intervalMs?: number; timeoutMs?: number } = {}
): Promise<T> {
  const { intervalMs = 2000, timeoutMs = 120000 } = options;
  const startTime = Date.now();

  while (true) {
    if (Date.now() - startTime > timeoutMs) {
      throw new Error(`Job ${jobId} polling timed out after ${timeoutMs}ms`);
    }

    const response = await getter(jobId);

    if (response.status === 'completed' && response.result) {
      return response.result;
    }
    if (response.status === 'failed') {
      throw new Error(`Job ${jobId} failed: ${response.error}`);
    }

    // Wait before next poll
    await new Promise(resolve => setTimeout(resolve, intervalMs));
  }
}

Type-Safe Webhook Handler

FotoHub webhooks provide asynchronous delivery of events. Validating them with Zod ensures type safety.

typescript
import { z } from 'zod';
import crypto from 'crypto';

const WebhookHeaderSchema = z.object({
  'x-fotohub-signature': z.string()
});

const JobCompletedEventSchema = z.object({
  type: z.literal('job.completed'),
  data: z.object({
    jobId: z.string(),
    resultUrl: z.string().url(),
    costUsd: z.number()
  })
});

const JobFailedEventSchema = z.object({
  type: z.literal('job.failed'),
  data: z.object({
    jobId: z.string(),
    reason: z.string()
  })
});

const WebhookEventSchema = z.discriminatedUnion('type', [
  JobCompletedEventSchema,
  JobFailedEventSchema
]);

export type WebhookEvent = z.infer<typeof WebhookEventSchema>;

export function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
  const hmac = crypto.createHmac('sha256', secret);
  const digest = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}

export function handleWebhook(payloadStr: string, headers: Record<string, string>, secret: string) {
  const { 'x-fotohub-signature': signature } = WebhookHeaderSchema.parse(headers);
  
  if (!verifyWebhookSignature(payloadStr, signature, secret)) {
    throw new Error('Invalid signature');
  }
  
  const payload = JSON.parse(payloadStr);
  const event = WebhookEventSchema.parse(payload);
  
  switch (event.type) {
    case 'job.completed':
      console.log(`Job completed! Cost: $${event.data.costUsd}`);
      // Process successful job
      break;
    case 'job.failed':
      console.error(`Job failed: ${event.data.reason}`);
      // Handle failure
      break;
  }
}

Builder Pattern for Complex Requests

typescript
import { ImageGenerationRequest } from '@fotohub/sdk';

export class ImageRequestBuilder {
  private request: Partial<ImageGenerationRequest> = {
    model: 'fotohub-v2',
    size: '1024x1024',
    n: 1
  };

  withPrompt(prompt: string): this {
    this.request.prompt = prompt;
    return this;
  }

  withNegativePrompt(negativePrompt: string): this {
    this.request.negative_prompt = negativePrompt;
    return this;
  }

  landscape(): this {
    this.request.size = '1024x512';
    return this;
  }

  portrait(): this {
    this.request.size = '512x1024';
    return this;
  }

  build(): ImageGenerationRequest {
    if (!this.request.prompt) {
      throw new Error("Prompt is required");
    }
    return this.request as ImageGenerationRequest;
  }
}

// Usage:
// const req = new ImageRequestBuilder()
//   .withPrompt("Cyberpunk city")
//   .landscape()
//   .build();

Middleware Pipeline

typescript
import { FotoHub } from '@fotohub/sdk';

// A wrapper around the standard client to add cross-cutting concerns
export class InstrumentedFotoHub {
  private client: FotoHub;
  
  constructor(apiKey: string) {
    this.client = new FotoHub({ apiKey });
  }
  
  async generateImage(prompt: string) {
    const startTime = performance.now();
    try {
      console.log(`[FotoHub] Starting image generation...`);
      const result = await this.client.image.generate({ prompt });
      const duration = performance.now() - startTime;
      console.log(`[FotoHub] Success (${Math.round(duration)}ms)`);
      return result;
    } catch (error) {
      const duration = performance.now() - startTime;
      console.error(`[FotoHub] Failed after ${Math.round(duration)}ms:`, error);
      throw error;
    }
  }
}

Cost Estimation Before Generation

Always display costs in USD natively in your UI.

typescript
import { ImageGenerationRequest } from '@fotohub/sdk';

export function estimateCost(request: ImageGenerationRequest): number {
  let basePrice = 0.025; // Standard 1024x1024
  
  if (request.size === '2048x2048') basePrice = 0.08;
  if (request.size === '512x512') basePrice = 0.01;
  
  let total = basePrice * (request.n || 1);
  
  // High-step generations cost more
  if (request.steps && request.steps > 50) {
    total *= 1.5;
  }
  
  return Number(total.toFixed(4));
}

Streaming Response Parser

typescript
export async function* parseSSEStream<T>(response: Response): AsyncGenerator<T, void, unknown> {
  if (!response.body) throw new Error("No response body");
  
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('
');
    
    // Keep the last incomplete line in the buffer
    buffer = lines.pop() || '';
    
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const dataStr = line.slice(6);
        if (dataStr === '[DONE]') return;
        try {
          yield JSON.parse(dataStr) as T;
        } catch (e) {
          console.warn('Failed to parse SSE data:', dataStr);
        }
      }
    }
  }
}

Unit Tests with Vitest

typescript
import { describe, it, expect, vi } from 'vitest';
import { FotoHub } from '@fotohub/sdk';

// Mock the global fetch
global.fetch = vi.fn();

describe('FotoHub Client', () => {
  it('generates an image successfully', async () => {
    const mockResponse = {
      data: [{ url: 'https://example.com/image.png' }]
    };
    
    vi.mocked(global.fetch).mockResolvedValueOnce({
      ok: true,
      json: async () => mockResponse
    } as Response);
    
    const client = new FotoHub({ apiKey: 'test_key' });
    const result = await client.image.generate({ prompt: 'test' });
    
    expect(result.data[0].url).toBe('https://example.com/image.png');
    expect(global.fetch).toHaveBeenCalledWith(
      'https://apis.fotohub.app/v1/images/generations',
      expect.objectContaining({
        method: 'POST',
        headers: expect.objectContaining({
          'Authorization': 'Bearer test_key'
        })
      })
    );
  });
});

Production Guides

Environment Variable Management

typescript
import { z } from 'zod';

const envSchema = z.object({
  FOTOHUB_API_KEY: z.string().startsWith('fh_live_'),
  FOTOHUB_WEBHOOK_SECRET: z.string().optional(),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
});

// Throws if validation fails, keeping your app safe
export const env = envSchema.parse(process.env);

Error Handling Hierarchy

typescript
export class FotoHubError extends Error {
  constructor(message: string, public statusCode: number, public requestId?: string) {
    super(message);
    this.name = 'FotoHubError';
  }
}

export class RateLimitError extends FotoHubError {
  constructor(message: string, public retryAfterSec: number) {
    super(message, 429);
    this.name = 'RateLimitError';
  }
}

export class InsufficientFundsError extends FotoHubError {
  constructor(public currentBalanceUsd: number, public requiredUsd: number) {
    super(`Insufficient funds. Have $${currentBalanceUsd}, need $${requiredUsd}`, 402);
    this.name = 'InsufficientFundsError';
  }
}

export function handleApiError(error: any): never {
  if (error.response) {
    const status = error.response.status;
    const data = error.response.data;
    
    if (status === 429) {
      throw new RateLimitError(data.error.message, data.error.retry_after || 60);
    }
    if (status === 402) {
      throw new InsufficientFundsError(data.error.balance_usd, data.error.required_usd);
    }
    throw new FotoHubError(data.error?.message || 'API Error', status, error.response.headers['x-request-id']);
  }
  
  throw error;
}

Logging Integration with Pino

typescript
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => {
      return { level: label.toUpperCase() };
    },
  },
});

// Usage in FotoHub context:
export async function generateWithLogging(prompt: string) {
  logger.info({ action: 'fotohub_generate_start', prompt });
  const start = Date.now();
  try {
    // client call here
    logger.info({ 
      action: 'fotohub_generate_success', 
      durationMs: Date.now() - start,
      costUsd: 0.025
    });
  } catch (err) {
    logger.error({ 
      action: 'fotohub_generate_error', 
      error: err,
      durationMs: Date.now() - start 
    });
    throw err;
  }
}

Sentry Integration

typescript
import * as Sentry from '@sentry/node';
import { FotoHubError } from './FotoHubErrors';

export function setupSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    tracesSampleRate: 1.0,
  });
}

export async function captureFotoHubOperation<T>(
  operationName: string, 
  operation: () => Promise<T>
): Promise<T> {
  return Sentry.startSpan({ name: `FotoHub: ${operationName}` }, async (span) => {
    try {
      return await operation();
    } catch (error) {
      if (error instanceof FotoHubError) {
        Sentry.setContext("FotoHub", {
          statusCode: error.statusCode,
          requestId: error.requestId
        });
        if (error.statusCode === 429) {
          // Maybe don't alert loudly for rate limits
          Sentry.captureMessage('FotoHub Rate Limit Hit', 'warning');
        } else {
          Sentry.captureException(error);
        }
      } else {
        Sentry.captureException(error);
      }
      span.setStatus("error");
      throw error;
    }
  });
}

OpenTelemetry Tracing

typescript
import { trace, context } from '@opentelemetry/api';

const tracer = trace.getTracer('fotohub-sdk-wrapper');

export async function tracedGenerateImage(prompt: string) {
  return tracer.startActiveSpan('FotoHub.Image.Generate', async (span) => {
    span.setAttribute('fotohub.prompt.length', prompt.length);
    span.setAttribute('fotohub.expected_cost_usd', 0.025);
    
    try {
      // Execute the request
      const response = await client.image.generate({ prompt });
      span.setAttribute('fotohub.request_id', response.requestId);
      span.setStatus({ code: 1 }); // OK
      return response;
    } catch (error: any) {
      span.setStatus({
        code: 2, // ERROR
        message: error.message
      });
      span.recordException(error);
      throw error;
    } finally {
      span.end();
    }
  });
}

Complete Architecture Diagram

Here is the high-level architecture diagram showing how the SDK interacts with the FotoHub Platform.

mermaid
flowchart TD
    App[Your Node/React/Edge App] --> SDK[FotoHub TypeScript SDK]
    SDK --> |REST / JSON| APIG[API Gateway `apis.fotohub.app`]
    APIG --> |Auth & Rate Limits| Services
    
    subgraph Services [FotoHub Microservices]
      Img[Image Generation Service]
      Vid[Video & UGC Service]
      Doc[Document Intelligence]
      Brand[Brand Engine]
    end
    
    Img --> GPU1[(GPU Cluster 1 - SDXL)]
    Vid --> GPU2[(GPU Cluster 2 - MMAudio)]
    Vid --> GPU3[(GPU Cluster 3 - MuseTalk/LipSync)]
    Doc --> GPU4[(GPU Cluster 4 - Vision/LLaVA)]
    
    Services --> |Webhooks| App

Parameter Table: client.image.generate

ParameterTypeRequiredDefaultDescription
promptstringYes-The text prompt for generation.
modelstringNofotohub-v2The model to use.
sizestringNo1024x1024Resolution. Valid: 512x512, 1024x1024, 1024x512, 512x1024
nnumberNo1Number of images to generate (1-4).
negative_promptstringNo-Elements to avoid in the generation.
stepsnumberNo30Number of diffusion steps (10-100).
seednumberNoRandomFix the seed for reproducible results.

GPU Affinity & Performance

When utilizing client.ugc and client.lipSync, requests are dynamically routed. GPU2 is strictly dedicated to MMAudio rendering while GPU3 handles MuseTalk/LipSync operations. 3D jobs (client.models3d) will always hit GPU4/5 clusters for optimal speed. Plan your concurrent requests accordingly to avoid localized cluster rate-limits.