Developer Hub
Custom Ordering API·v1

Integration Guide · 12 chapters

Build commissioned UGC into your platform.

Quote a price, place an order across one or many creators, route your customer through Stripe Checkout, and receive the finished videos via signed URLs — all through one REST surface and a single dsk_ API key.

Status: v1 — locked. Last updated: 2026-05-29.

Audience: partner platforms (SaaS tools, agencies, automation vendors) embedding DansUGC custom video ordering into their own product.

For the instant-download B-Roll Library and Posting API, see llms-full.txt. This guide is exclusively about custom video orders — videos filmed to your brief by verified human creators.

· · ·

01Introduction

The DansUGC Custom Orders API lets your platform place real custom UGC video orders against our roster of 35+ verified human creators, the same way our own /order/new flow does. You catalogue creators and content formats, build a quote, submit an order, and receive a Stripe Checkout URL to hand to your end-user. Once paid, the order enters our fulfillment pipeline and your platform polls for status and grabs deliverables via signed URLs.

End-to-end flow:

  • Browse formats (tier-priced TikTok-style templates) and models (verified creators).
  • Compute a quote with POST /pricing/quote — multipliers, add-ons, and photo line items resolved server-side.
  • Submit POST /orders with one or more model_ids — one fulfillment row per creator, one shared payment.
  • Redirect the end-user to the returned Stripe Checkout URL.
  • Poll GET /orders/{id} (v1.1 will replace this with webhooks).
  • Pull deliverables via GET /orders/{id}/files once status is delivered.

This API is the same surface that powers dansugc.com/order/new. Pricing math, add-on rules, and delivery semantics are identical. If you can build it in our UI, you can build it through this API.

· · ·

02What you're integrating

DansUGC has two API products. Use the right one for the right job.

NeedProductEndpoint family
Instant download of pre-recorded reaction clips ($3–$25/video, library of 2,000+)B-Roll Library/api/v1/broll/*
Brief filmed by a named creator over 2–3 business days, fully exclusive, scripted or to-specCustom Orders (this doc)/api/v1/{models,formats,pricing,orders}/*

The two products share an API key prefix (dsk_) and the same rate-limit bucket. A single key with the right scopes can drive both. Where the B-Roll API gives you a download_url synchronously, the Custom Orders API returns an order resource that progresses through fulfillment over hours-to-days.

End-to-end flow

+--------------+      +--------------+      +--------------+      +--------------+
|  1. CATALOG  | ---> |  2. QUOTE    | ---> |  3. ORDER    | ---> |  4. PAY      |
|  /formats    |      |  /pricing/   |      |  POST        |      |  Stripe      |
|  /models     |      |  quote       |      |  /orders     |      |  Checkout    |
+--------------+      +--------------+      +--------------+      +--------------+
                                                                         |
                                                                         v
+--------------+      +--------------+      +--------------+      +--------------+
|  8. DOWNLOAD | <--- |  7. READY    | <--- |  6. FILM     | <--- |  5. ACCEPT  |
|  /orders/id/ |      |  status =    |      |  creators    |      |  webhook    |
|  files       |      |  delivered   |      |  upload R2   |      |  → status   |
+--------------+      +--------------+      +--------------+      +--------------+

Steps 1–4 are synchronous and partner-driven. Steps 5–7 are asynchronous and DansUGC-side. Step 8 is partner-driven again — short-lived signed URLs the partner streams or proxies to its end-user.

· · ·

03How DansUGC's own order form works (mental model)

If you understand this section, the API will feel obvious. Everything below mirrors what happens at dansugc.com/order/new.

Step 1 — Pick formats

A format is a TikTok-style template (e.g. "Honest skincare reaction," "POV unboxing"). Each format has a difficulty (1–10) that maps to a pricing tier. The tier's base_price is the floor cost per video for that format style.

The 10 tiers (from pricing_tiers):

LevelNameBase priceNotes
1Basic Face Reaction$8No talking; raw reaction
2Lipsync / Singing$15Matching audio, single take
3Script Reading / App Demo$20Talking-head — billed by minute
4Reaction + App Demo$30Picture-in-picture
5The "Problem/Solution"$55Script + demo + captions + music
6Aesthetic Lifestyle$85Product in environment, 5+ cuts
7High-Conversion Ad$120Hook testing, pro lighting
8Product Deep Dive$160Unboxing + voiceover + 4K
9Brand Storytelling$205Concepting + VFX
10Premium Campaign$250Full rights + 3 hook variations

Each format also has a min_order_videos (defaults to 5 when null). The order will be rejected if you submit fewer than that for any single format.

Step 2 — Pick creators

A creator (model) has a creator_type slug. Each slug carries a price multiplier that applies to every video that creator films:

SlugDisplayMultiplier
standardStandard Creator1.0×
viralViral Creator1.5×
couplesCouples2.0×

Format unit price = pricing_tiers.base_price[level] × creator_types.multiplier[slug].

Pick more than one creator and you create a multi-model order: one POST, N fulfillment rows (one per creator), one Stripe Checkout session covering all of them. Each row is priced with its own creator's multiplier. A viral creator and a standard creator both filming 5 Tier-5 videos yields different per-model subtotals — $412.50 viral, $275 standard — billed as one $687.50 payment.

Step 3 — Configure add-ons

Three add-ons can be stacked on top of the format selection:

Add-onChargedGoes to creator
addon_video_editing$20/video$0 initially. Creator earns $5/video if they opt in to edit the deliverable.
addon_video_demo$20/video$10/video — always. The same creator films the product demo alongside the reaction.
photosper-creator custom rate (model_pricing.custom_price) or default video_types.pricePer-creator. Quantity-based.

Editing and demo apply per video in the order, per creator. If you order 10 videos with editing on and pick 2 creators, that's 10 × $20 × 2 = $400 in editing charges.

Photos are quantity-based and don't count toward a format's min_order_videos. You can order 0 videos + 12 photos (subject to other gates), and you can order videos + photos together. Per-creator pricing applies — a creator with a $35 custom photo rate charges $35 × quantity, regardless of what your other creator charges.

Tier 3 talking-head billing

For formats at difficulty level 3 (script reading / app demo), if you include a scripts array on the line item the order is billed by the longest script in the bundle:

minutes_billed = max(1, ceil(max(word_count(s)) for s in scripts) / 150)
unit_price_billed = base_price[3] × multiplier × minutes_billed

150 words per minute is the standard talking-head delivery rate. A 220-word script becomes a 2-minute video billed at $20 × multiplier × 2. Scripts under 150 words bill at 1 minute (the floor).

Delivery handshake

When the order's status flips to delivered, the creator has uploaded all final files to Cloudflare R2 under custom-orders/{order_id}/. We mint a fresh delivery_token on the order. GET /orders/{id}/files returns the file list plus short-lived signed URLs. Your platform streams or proxies those URLs to your end-user. There is no permanent customer-facing download page exposed through the API — you control the UX.

· · ·

04API Quickstart (10-minute integration)

This walks the full happy path. Every snippet assumes DSK_API_KEY is set in the environment.

Note: All examples target https://dansugc.com/api/v1. Money values returned by the API are decimal strings (e.g. "199.50"), not floats — preserve them as strings to avoid IEEE-754 drift.

Authenticate

Shell
export DSK_API_KEY=dsk_live_abc123...
curl https://dansugc.com/api/v1/models -H "Authorization: Bearer $DSK_API_KEY"

List one format

JavaScript
const headers = { Authorization: `Bearer ${process.env.DSK_API_KEY}` };
const { data: formats } = await fetch(
  "https://dansugc.com/api/v1/formats?difficulty=5&limit=1",
  { headers }
).then((r) => r.json());
console.log(formats[0].id, formats[0].display_name, formats[0].base_price);
Python
import os, requests
H = {"Authorization": f"Bearer {os.environ['DSK_API_KEY']}"}
formats = requests.get(
    "https://dansugc.com/api/v1/formats",
    params={"difficulty": 5, "limit": 1}, headers=H
).json()["data"]
print(formats[0]["id"], formats[0]["display_name"], formats[0]["base_price"])

List one model

JavaScript
const { data: models } = await fetch(
  "https://dansugc.com/api/v1/models?creator_type=standard&limit=1",
  { headers }
).then((r) => r.json());
const model = models[0];

Get a quote

JavaScript
const quote = await fetch("https://dansugc.com/api/v1/pricing/quote", {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    model_ids: [model.id],
    items: [
      { format_id: formats[0].id, quantity: 5 },
    ],
    addons: { video_editing: false, video_demo: false, photos: 0 },
  }),
}).then((r) => r.json());
console.log("Grand total:", quote.data.grand_total);

Create the order

JavaScript
const order = await fetch("https://dansugc.com/api/v1/orders", {
  method: "POST",
  headers: {
    ...headers,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    model_ids: [model.id],
    customer_email: "buyer@acme.com",
    promotion_details: "Honest reaction to our new skincare serum. Mention 'glow' once.",
    items: [{ format_id: formats[0].id, quantity: 5 }],
    addons: { video_editing: false, video_demo: false, photos: 0 },
  }),
}).then((r) => r.json());

Redirect to payment

JavaScript
// Send your end-user to:
window.location.href = order.data.payment_url;
// Or surface it as a "Pay" button — the URL is a Stripe Checkout session.

Poll for status

JavaScript
async function waitForDelivery(orderId) {
  while (true) {
    const { data } = await fetch(
      `https://dansugc.com/api/v1/orders/${orderId}`,
      { headers }
    ).then((r) => r.json());
    if (data.status === "delivered") return data;
    if (data.status === "canceled") throw new Error("Order canceled");
    await new Promise((r) => setTimeout(r, 60_000)); // 1 min, well under rate limit
  }
}

Download deliverables

JavaScript
const { data: files } = await fetch(
  `https://dansugc.com/api/v1/orders/${order.data.id}/files`,
  { headers }
).then((r) => r.json());

for (const f of files) {
  // f.signed_url expires in 15 minutes — download or re-mint as needed.
  console.log(f.filename, f.size_bytes, f.signed_url);
}

That's the whole loop.

· · ·

05Endpoint Reference

All endpoints are prefixed with https://dansugc.com/api/v1. All responses are JSON. All requests require Authorization: Bearer dsk_.... All write endpoints accept (and honour) an Idempotency-Key request header.

GET /models

Scope: orders:read

List verified creators. Use this to populate your catalog UI.

Query params:

ParamTypeDescription
creator_typeenumstandard | viral | couples
genderstringfemale | male | couple | other
nichestringFilter by niche slug
languagestringISO 639-1 code, e.g. en
searchstringFull-text search across name and bio
pageint1-indexed, default 1
limitintDefault 24, max 100

Sample request:

GET /api/v1/models?creator_type=viral&gender=female&limit=2
Authorization: Bearer dsk_live_abc123

Sample response:

JSON
{
  "data": [
    {
      "id": "9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e",
      "name": "Sarah M.",
      "slug": "sarah-m",
      "creator_type": "viral",
      "multiplier": "1.50",
      "gender": "female",
      "age_range": "25-34",
      "languages": ["en"],
      "niches": ["beauty", "wellness"],
      "cover_photo_url": "https://cdn.dansugc.com/models/sarah-m/cover.jpg",
      "video_examples": [
        {
          "id": "ex-1",
          "video_url": "https://cdn.dansugc.com/models/sarah-m/ex1.mp4",
          "thumbnail_url": "https://cdn.dansugc.com/models/sarah-m/ex1.jpg",
          "featured": true,
          "format_id": 412
        }
      ],
      "photo_unit_price": "35.00"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 2,
    "total_pages": 9,
    "has_more": true
  }
}

photo_unit_price reflects the creator's model_pricing.custom_price for photos when set, otherwise the platform default. Use this exact value when quoting photo line items for this creator.

GET /models/{id}

Scope: orders:read

Returns one model with the full video_examples array. Same shape as the list endpoint, no pagination envelope.

404 response:

JSON
{ "error": { "code": "not_found", "message": "Model not found" } }

GET /formats

Scope: orders:read

List curated TikTok-style format templates.

Query params:

ParamTypeDescription
difficultyint 1–10Exact tier
min_difficulty / max_difficultyintTier range
nichestringFilter by niche slug
categorystringFilter by category slug
searchstringFull-text search across format name and TikTok caption
page / limitintPagination — default 24, max 100

Sample response:

JSON
{
  "data": [
    {
      "id": 412,
      "display_name": "Honest Skincare Reaction",
      "title": "you HAVE to try this serum",
      "username": "creator_handle",
      "tiktok_url": "https://www.tiktok.com/@creator_handle/video/...",
      "thumbnail_url": "https://cdn.dansugc.com/formats/412.jpg",
      "video_url": "https://cdn.dansugc.com/formats/412.mp4",
      "difficulty": 5,
      "tier_name": "The \"Problem/Solution\"",
      "base_price": "55.00",
      "min_order_videos": 5,
      "niche": "beauty",
      "category": "reaction"
    }
  ],
  "pagination": { "page": 1, "limit": 24, "total_pages": 4, "has_more": true }
}

GET /pricing/tiers

Scope: pricing:read

Returns the canonical pricing tier table plus creator-type multipliers. Cache this client-side — values change at most a few times per year and are version-stamped via the ETag response header.

Sample response:

JSON
{
  "data": {
    "tiers": [
      { "level": 1, "name": "Basic Face Reaction", "base_price": "8.00", "earnings": "4.00" },
      { "level": 2, "name": "Lipsync / Singing", "base_price": "15.00", "earnings": "7.50" },
      { "level": 3, "name": "Script Reading / App Demo", "base_price": "20.00", "earnings": "10.00" },
      { "level": 4, "name": "Reaction + App Demo", "base_price": "30.00", "earnings": "15.00" },
      { "level": 5, "name": "The \"Problem/Solution\"", "base_price": "55.00", "earnings": "27.50" },
      { "level": 6, "name": "Aesthetic Lifestyle", "base_price": "85.00", "earnings": "42.50" },
      { "level": 7, "name": "High-Conversion Ad", "base_price": "120.00", "earnings": "60.00" },
      { "level": 8, "name": "Product Deep Dive", "base_price": "160.00", "earnings": "80.00" },
      { "level": 9, "name": "Brand Storytelling", "base_price": "205.00", "earnings": "102.50" },
      { "level": 10, "name": "Premium Campaign", "base_price": "250.00", "earnings": "125.00" }
    ],
    "creator_types": [
      { "slug": "standard", "name": "Standard Creator", "multiplier": "1.00" },
      { "slug": "viral", "name": "Viral Creator", "multiplier": "1.50" },
      { "slug": "couples", "name": "Couples", "multiplier": "2.00" }
    ],
    "addons": {
      "video_editing": { "price_per_video": "20.00" },
      "video_demo": { "price_per_video": "20.00" }
    },
    "talking_head_words_per_minute": 150
  }
}

earnings is the creator's per-video payout at the base multiplier. It's exposed for transparency and is not load-bearing for partner pricing.

POST /pricing/quote

Scope: pricing:read

Returns a fully-computed quote with one entry per creator. Use this to render a price preview before committing the user to an order. Result is not reserved — it's a pure calculation. Re-quote before submitting if anything in your UI changes.

Request body:

JSON
{
  "model_ids": [
    "9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e",
    "a1b2c3d4-e5f6-7890-1234-567890abcdef"
  ],
  "items": [
    {
      "format_id": 412,
      "quantity": 5,
      "scripts": []
    }
  ],
  "addons": {
    "video_editing": true,
    "video_demo": false,
    "photos": 3
  }
}

Sample response:

JSON
{
  "data": {
    "creators": [
      {
        "model_id": "9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e",
        "model_name": "Sarah M.",
        "multiplier": "1.50",
        "format_lines": [
          {
            "format_id": 412,
            "display_name": "Honest Skincare Reaction",
            "quantity": 5,
            "unit_price": "82.50",
            "subtotal": "412.50"
          }
        ],
        "editing_subtotal": "100.00",
        "demo_subtotal": "0.00",
        "photo_unit_price": "35.00",
        "photo_subtotal": "105.00",
        "total": "617.50"
      },
      {
        "model_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
        "model_name": "Jane D.",
        "multiplier": "1.00",
        "format_lines": [
          {
            "format_id": 412,
            "display_name": "Honest Skincare Reaction",
            "quantity": 5,
            "unit_price": "55.00",
            "subtotal": "275.00"
          }
        ],
        "editing_subtotal": "100.00",
        "demo_subtotal": "0.00",
        "photo_unit_price": "25.00",
        "photo_subtotal": "75.00",
        "total": "450.00"
      }
    ],
    "total_videos": 10,
    "grand_total": "1067.50",
    "currency": "USD"
  }
}

400 — minimum not met:

JSON
{
  "error": {
    "code": "format_minimum_not_met",
    "message": "Format \"Honest Skincare Reaction\" requires at least 5 videos per creator (got 3)",
    "format_id": 412,
    "min_required": 5
  }
}

POST /orders

Scope: orders:write

Creates one video_orders row per model_ids entry, then opens a single shared Stripe Checkout session that covers all rows. The response includes the payment URL.

Request body:

JSON
{
  "model_ids": ["9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e"],
  "customer_email": "buyer@acme.com",
  "promotion_details": "Honest reaction to our new skincare serum. Mention 'glow' once.",
  "additional_instructions": "Vertical 9:16. Hook in first 1.5s. No music — voiceover only.",
  "items": [
    {
      "format_id": 412,
      "quantity": 5,
      "scripts": []
    }
  ],
  "addons": {
    "video_editing": false,
    "video_editing_notes": null,
    "video_demo": false,
    "video_demo_notes": null,
    "photos": 0,
    "photo_notes": null
  },
  "external_reference": "order_acme_2026_001"
}

Required fields: model_ids (length ≥ 1), customer_email, promotion_details, items (length ≥ 1, each with format_id and quantity).

Optional fields: additional_instructions, addons.*, external_reference (free-form string ≤ 128 chars, stored on every created order row for your own reconciliation).

Sample response (multi-model — 2 creators, 1 payment):

JSON
{
  "data": {
    "orders": [
      {
        "id": "ord_01HW3KJ8E9K0YR6Z3T1F8XQ2A0",
        "model_id": "9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e",
        "model_name": "Sarah M.",
        "status": "pending_payment",
        "payment_status": "unpaid",
        "total_amount": "617.50",
        "total_videos": 5,
        "items": [
          {
            "format_id": 412,
            "video_type_name": "Honest Skincare Reaction",
            "quantity": 5,
            "unit_price": "82.50"
          }
        ],
        "external_reference": "order_acme_2026_001",
        "created_at": "2026-05-29T14:00:00Z"
      },
      {
        "id": "ord_01HW3KJ8E9K0YR6Z3T1F8XQ2A1",
        "model_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
        "model_name": "Jane D.",
        "status": "pending_payment",
        "payment_status": "unpaid",
        "total_amount": "450.00",
        "total_videos": 5,
        "items": [
          {
            "format_id": 412,
            "video_type_name": "Honest Skincare Reaction",
            "quantity": 5,
            "unit_price": "55.00"
          }
        ],
        "external_reference": "order_acme_2026_001",
        "created_at": "2026-05-29T14:00:00Z"
      }
    ],
    "checkout_session_id": "cs_test_a1B2c3...",
    "payment_url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3...",
    "grand_total": "1067.50",
    "currency": "USD"
  }
}

Validation errors:

JSON
{ "error": { "code": "missing_field", "message": "customer_email is required" } }
{ "error": { "code": "invalid_model", "message": "Model 9c3f... not found or not bookable" } }
{ "error": { "code": "invalid_format", "message": "Format 999 not found" } }
{ "error": { "code": "format_minimum_not_met", "message": "...", "format_id": 412, "min_required": 5 } }

Until the Stripe Checkout session is completed the orders sit at status: "pending_payment". If your user abandons the checkout, the orders remain in that state and never advance — they will be auto-canceled after 24 hours.

GET /orders

Scope: orders:read

List orders created by your API key.

Query params:

ParamTypeDescription
statusenumpending_payment | processing | in_progress | delivered | canceled
external_referencestringExact match — useful for reconciliation
created_after / created_beforeISO 8601Date range
page / limitintDefault 24, max 100

Sample response: Same envelope as GET /orders/{id}, wrapped in { "data": [...], "pagination": {...} }.

GET /orders/{id}

Scope: orders:read

Returns a single order with current status, payment state, and items.

Sample response:

JSON
{
  "data": {
    "id": "ord_01HW3KJ8E9K0YR6Z3T1F8XQ2A0",
    "model_id": "9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e",
    "model_name": "Sarah M.",
    "status": "in_progress",
    "payment_status": "paid",
    "total_amount": "617.50",
    "total_videos": 5,
    "items": [
      {
        "format_id": 412,
        "video_type_name": "Honest Skincare Reaction",
        "quantity": 5,
        "unit_price": "82.50",
        "creator_earnings_per_video": "41.25"
      }
    ],
    "addons": {
      "video_editing": true,
      "video_editing_notes": "Match cuts to beat drops",
      "photos": 3
    },
    "external_reference": "order_acme_2026_001",
    "customer_email": "buyer@acme.com",
    "promotion_details": "Honest reaction to our new skincare serum...",
    "files_ready": false,
    "created_at": "2026-05-29T14:00:00Z",
    "paid_at": "2026-05-29T14:02:11Z",
    "delivered_at": null
  }
}

files_ready flips to true the moment the order enters status delivered. Use it as a cheap polling signal before hitting /files.

GET /orders/{id}/files

Scope: deliverables:read

Returns the delivery manifest for an order, with short-lived signed URLs. Only available once status is delivered.

Sample response:

JSON
{
  "data": {
    "order_id": "ord_01HW3KJ8E9K0YR6Z3T1F8XQ2A0",
    "delivered_at": "2026-06-01T19:24:00Z",
    "files": [
      {
        "filename": "creator_sarah_clip_01.mp4",
        "kind": "video",
        "size_bytes": 14592120,
        "content_type": "video/mp4",
        "signed_url": "https://pub-70f9e589b1c640b49218874baf1c733f.r2.dev/custom-orders/ord_.../clip_01.mp4?X-Amz-Signature=...",
        "expires_at": "2026-06-01T19:39:00Z"
      },
      {
        "filename": "photo_01.jpg",
        "kind": "image",
        "size_bytes": 2841120,
        "content_type": "image/jpeg",
        "signed_url": "https://pub-70f9e589b1c640b49218874baf1c733f.r2.dev/custom-orders/ord_.../photo_01.jpg?X-Amz-Signature=...",
        "expires_at": "2026-06-01T19:39:00Z"
      }
    ]
  }
}

Signed URLs expire 15 minutes after issue. To download files server-side, fetch the manifest and stream within that window. To hand a URL to an end-user's browser, either re-mint by hitting this endpoint just before serving the page, or proxy the file through your own server.

409 — not yet delivered:

JSON
{
  "error": {
    "code": "files_not_ready",
    "message": "Order is currently in_progress. Files will be available when status is delivered.",
    "current_status": "in_progress"
  }
}
· · ·

06Integration Patterns

Pattern A — Quote → confirm → order (standard UI flow)

Render a price preview, let the user confirm, then create the order.

JavaScript
const BASE = "https://dansugc.com/api/v1";
const headers = {
  Authorization: `Bearer ${process.env.DSK_API_KEY}`,
  "Content-Type": "application/json",
};

// 1. User picks a format + creator in your UI
const formatId = 412;
const modelId = "9c3f8f80-1f6a-4b7e-9e7c-2c2a9d2a6b0e";

// 2. Show a live quote
const quote = await fetch(`${BASE}/pricing/quote`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    model_ids: [modelId],
    items: [{ format_id: formatId, quantity: 5 }],
    addons: { video_editing: true, video_demo: false, photos: 0 },
  }),
}).then((r) => r.json());

renderPriceSummary(quote.data); // your UI

// 3. On confirm, submit the order
const order = await fetch(`${BASE}/orders`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    model_ids: [modelId],
    customer_email: currentUser.email,
    promotion_details: form.brief,
    items: [{ format_id: formatId, quantity: 5 }],
    addons: { video_editing: true, video_demo: false, photos: 0 },
    external_reference: `acme_${currentUser.id}_${Date.now()}`,
  }),
}).then((r) => r.json());

// 4. Send the user to Stripe
window.location.href = order.data.payment_url;
Python
import os, uuid, requests
BASE = "https://dansugc.com/api/v1"
H = {"Authorization": f"Bearer {os.environ['DSK_API_KEY']}",
     "Content-Type": "application/json"}

quote = requests.post(f"{BASE}/pricing/quote", headers=H, json={
    "model_ids": [model_id],
    "items": [{"format_id": format_id, "quantity": 5}],
    "addons": {"video_editing": True, "video_demo": False, "photos": 0},
}).json()["data"]

order = requests.post(f"{BASE}/orders",
    headers={**H, "Idempotency-Key": str(uuid.uuid4())},
    json={
        "model_ids": [model_id],
        "customer_email": user_email,
        "promotion_details": brief,
        "items": [{"format_id": format_id, "quantity": 5}],
        "addons": {"video_editing": True, "video_demo": False, "photos": 0},
        "external_reference": f"acme_{user_id}_{int(time.time())}",
    }).json()["data"]

redirect(order["payment_url"])

Pattern B — Bulk catalog browse + multi-model order

Pull the full creator and format catalog once, persist locally, then submit a single multi-model order against a hand-picked roster.

JavaScript
async function paginateAll(path) {
  const out = [];
  let page = 1;
  while (true) {
    const res = await fetch(`${BASE}${path}?page=${page}&limit=100`, { headers })
      .then((r) => r.json());
    out.push(...res.data);
    if (!res.pagination.has_more) break;
    page++;
    await new Promise((r) => setTimeout(r, 1100)); // stay under 60 req/min
  }
  return out;
}

const allModels = await paginateAll("/models");
const allFormats = await paginateAll("/formats");

// Pick 3 viral creators + 1 standard
const roster = [
  ...allModels.filter((m) => m.creator_type === "viral").slice(0, 3),
  allModels.find((m) => m.creator_type === "standard"),
].map((m) => m.id);

// Submit one multi-model order — Stripe Checkout will charge once for all 4.
const order = await fetch(`${BASE}/orders`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    model_ids: roster,
    customer_email: "campaigns@acme.com",
    promotion_details: "Q3 skincare launch — pick whichever angle feels native.",
    items: [
      { format_id: 412, quantity: 5 },
      { format_id: 587, quantity: 5 },
    ],
    addons: { video_editing: true, video_demo: false, photos: 0 },
  }),
}).then((r) => r.json());

console.log(`Created ${order.data.orders.length} orders. Pay once at ${order.data.payment_url}`);

Pattern C — Polling for status + downloading deliverables

A robust poll-and-download loop.

Python
import time, requests

def wait_and_download(order_id, dest_dir):
    while True:
        r = requests.get(f"{BASE}/orders/{order_id}", headers=H).json()["data"]
        if r["status"] == "delivered":
            break
        if r["status"] == "canceled":
            raise RuntimeError(f"Order {order_id} canceled")
        time.sleep(60)  # 1 min — comfortably under 60 req/min

    files = requests.get(f"{BASE}/orders/{order_id}/files",
                        headers=H).json()["data"]["files"]
    for f in files:
        # Signed URLs expire in 15 min — download promptly.
        with requests.get(f["signed_url"], stream=True) as resp:
            resp.raise_for_status()
            with open(f"{dest_dir}/{f['filename']}", "wb") as fh:
                for chunk in resp.iter_content(1 << 20):
                    fh.write(chunk)
        print(f"Saved {f['filename']} ({f['size_bytes']} bytes)")
JavaScript
async function waitAndDownload(orderId, fetchFile) {
  while (true) {
    const { data } = await fetch(`${BASE}/orders/${orderId}`, { headers })
      .then((r) => r.json());
    if (data.status === "delivered") break;
    if (data.status === "canceled") throw new Error("Canceled");
    await new Promise((r) => setTimeout(r, 60_000));
  }

  const { data: manifest } = await fetch(`${BASE}/orders/${orderId}/files`, { headers })
    .then((r) => r.json());

  for (const f of manifest.files) {
    await fetchFile(f.filename, f.signed_url); // your downloader
  }
}

Pattern D — Subscription-driven recurring orders

A creator's automation tool places one fresh weekly order on behalf of each of its customers. Each customer's billing happens at the Stripe Checkout step — your platform never touches money.

JavaScript
// Run weekly via cron / scheduled function
async function placeWeeklyOrder(customer) {
  // 1. Pull the current quote so we can show the customer their cost up front
  const quote = await fetch(`${BASE}/pricing/quote`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      model_ids: customer.preferred_model_ids,
      items: customer.weekly_items, // e.g. [{ format_id: 412, quantity: 5 }]
      addons: customer.addons,
    }),
  }).then((r) => r.json());

  // 2. Notify the customer via email/Slack with the quote.data.grand_total
  await notifyCustomer(customer, quote.data);

  // 3. On their confirmation (clicking a link), create the order. Stable
  //    Idempotency-Key per week per customer ensures double-clicks don't
  //    create duplicate orders.
  const idempotencyKey = `acme_${customer.id}_${isoWeek(new Date())}`;
  const order = await fetch(`${BASE}/orders`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": idempotencyKey },
    body: JSON.stringify({
      model_ids: customer.preferred_model_ids,
      customer_email: customer.email,
      promotion_details: customer.weekly_brief,
      items: customer.weekly_items,
      addons: customer.addons,
      external_reference: idempotencyKey,
    }),
  }).then((r) => r.json());

  return order.data.payment_url;
}

The Idempotency-Key here doubles as the external_reference, so even if the cron job re-fires the same week you get one canonical order back, not two.

Pattern E — Production download helper (polling + re-host)

Pattern C is the happy-path version. In production you'll want a download helper that:

  • Streams large videos to disk (don't .blob() a 200 MB file into memory)
  • Refreshes the manifest if a signed URL expires mid-download (?ttl= is only 15 min by default)
  • Retries transient failures with bounded attempts
  • Re-hosts the bytes on your own CDN so your end-users don't depend on our signed URLs

This is the function you call once you know an order exists; it blocks until the files are saved.

JavaScript
// Production-grade download helper.
// Drop into your server. Swap `streamToPartnerCdn` for your real uploader.
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";

async function downloadOrderDeliverables({
  orderId,
  destDir,
  pollIntervalMs = 60_000,         // 60s respects 60 req/min cap
  pollBudgetMs = 7 * 24 * 60 * 60_000,  // 7 days — orders deliver in 48-72h
  signedUrlTtl = 900,              // 15 min, max 3600
}) {
  const t0 = Date.now();
  let order;

  // 1) Poll /orders/{id} until status === "delivered" or "canceled".
  while (Date.now() - t0 < pollBudgetMs) {
    const res = await fetch(`${BASE}/orders/${orderId}`, { headers });
    if (!res.ok) throw new Error(`Order fetch failed: ${res.status}`);
    order = (await res.json()).data;
    if (order.status === "delivered") break;
    if (order.status === "canceled") throw new Error(`Order ${orderId} canceled`);
    await new Promise((r) => setTimeout(r, pollIntervalMs));
  }
  if (order?.status !== "delivered") {
    throw new Error(`Poll budget elapsed; last status: ${order?.status}`);
  }

  // 2) Fetch the deliverable manifest (signed URLs, TTL clock starts now).
  let manifest = await fetchManifest(orderId, signedUrlTtl);
  await fs.mkdir(destDir, { recursive: true });

  // 3) Download each file. On 403/410 (URL expired), re-fetch the manifest
  //    and use the fresh URL for the same filename.
  const results = [];
  for (const file of manifest.files) {
    for (let attempt = 1; attempt <= 3; attempt++) {
      try {
        const resp = await fetch(file.signed_url);
        if (resp.status === 403 || resp.status === 410) {
          manifest = await fetchManifest(orderId, signedUrlTtl);
          const fresh = manifest.files.find((f) => f.filename === file.filename);
          if (!fresh) throw new Error(`File disappeared: ${file.filename}`);
          file.signed_url = fresh.signed_url;
          continue; // retry with new URL
        }
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        // Stream to disk — never buffers the whole video in memory.
        await pipeline(
          Readable.fromWeb(resp.body),
          createWriteStream(`${destDir}/${file.filename}`)
        );
        // Then re-host on your CDN. Keep the file local OR delete after upload.
        const cdnUrl = await streamToPartnerCdn(`${destDir}/${file.filename}`, {
          filename: file.filename,
          contentType: file.content_type,
        });
        results.push({ filename: file.filename, size_bytes: file.size_bytes, cdn_url: cdnUrl });
        break;
      } catch (err) {
        if (attempt === 3) throw err;
        await new Promise((r) => setTimeout(r, 1000 * attempt));
      }
    }
  }

  return { order_id: orderId, files: results, delivered_at: order.delivered_at };
}

async function fetchManifest(orderId, ttl) {
  const r = await fetch(`${BASE}/orders/${orderId}/files?ttl=${ttl}`, { headers });
  if (!r.ok) throw new Error(`Manifest fetch failed: ${r.status}`);
  return (await r.json()).data;
}
Python
import os, time, requests

def download_order_deliverables(
    order_id,
    dest_dir,
    poll_interval_s=60,
    poll_budget_s=7 * 24 * 60 * 60,
    signed_url_ttl=900,
    stream_to_cdn=None,  # callable(local_path, filename, content_type) -> cdn_url
):
    t0 = time.time()
    order = None

    # 1) Poll until terminal status.
    while time.time() - t0 < poll_budget_s:
        r = requests.get(f"{BASE}/orders/{order_id}", headers=H, timeout=30)
        r.raise_for_status()
        order = r.json()["data"]
        if order["status"] == "delivered":
            break
        if order["status"] == "canceled":
            raise RuntimeError(f"Order {order_id} canceled")
        time.sleep(poll_interval_s)
    if not order or order["status"] != "delivered":
        raise RuntimeError(f"Poll budget elapsed; last status: {order and order['status']}")

    # 2) Fetch manifest with signed URLs.
    manifest = _fetch_manifest(order_id, signed_url_ttl)
    os.makedirs(dest_dir, exist_ok=True)

    # 3) Stream + refresh-on-expiry + retry per file.
    results = []
    for file in manifest["files"]:
        for attempt in range(1, 4):
            try:
                with requests.get(file["signed_url"], stream=True, timeout=300) as resp:
                    if resp.status_code in (403, 410):
                        manifest = _fetch_manifest(order_id, signed_url_ttl)
                        fresh = next((f for f in manifest["files"]
                                      if f["filename"] == file["filename"]), None)
                        if not fresh:
                            raise RuntimeError(f"File disappeared: {file['filename']}")
                        file["signed_url"] = fresh["signed_url"]
                        continue
                    resp.raise_for_status()
                    local_path = f"{dest_dir}/{file['filename']}"
                    with open(local_path, "wb") as fh:
                        for chunk in resp.iter_content(1 << 20):
                            fh.write(chunk)
                cdn_url = stream_to_cdn(local_path, file["filename"], file["content_type"]) \
                    if stream_to_cdn else None
                results.append({
                    "filename": file["filename"],
                    "size_bytes": file["size_bytes"],
                    "cdn_url": cdn_url,
                })
                break
            except Exception:
                if attempt == 3:
                    raise
                time.sleep(attempt)  # linear backoff

    return {"order_id": order_id, "files": results, "delivered_at": order["delivered_at"]}


def _fetch_manifest(order_id, ttl):
    r = requests.get(f"{BASE}/orders/{order_id}/files?ttl={ttl}", headers=H, timeout=30)
    r.raise_for_status()
    return r.json()["data"]

Why re-host? Your end-users get permanent URLs you control, you can put them behind your own auth/analytics, and you stop depending on our signed-URL TTL. The signed URLs we issue should be considered a transport — pull the bytes once, store them on your side.

TTL trade-offs. Default 15 min works for sequential downloads. If you parallelize across many files, bump ?ttl=3600 (1 hour cap). Each /files call counts against your 60 req/min rate limit, so re-fetching the manifest on every file is the slow path — only do it when a URL actually 403s.

Webhooks in v1.1 will replace the polling loop entirely: order.delivered arrives with the same manifest in the payload, and you skip steps 1 and 2.

· · ·

07Pricing model — worked examples

All examples use decimal math. Multiplier is applied to the tier base price before multiplying by quantity.

Example 1 — Single creator, 10 videos, Tier 5 (basic)

  • Creator: standard (multiplier 1.00)
  • Format: Tier 5, base price $55.00
  • Quantity: 10
  • Add-ons: none
unit_price = 55.00 × 1.00          = $55.00
subtotal   = 55.00 × 10            = $550.00
grand_total                         = $550.00

Example 2 — Multi-creator (viral + standard), Tier 3 with 90-word scripts

  • Creator A: viral (1.50×). Creator B: standard (1.00×).
  • Format: Tier 3, base price $20.00. scripts array supplied, longest is 90 words.
  • Quantity per creator: 5
  • Add-ons: none

90 words → ceil(90 / 150) = 1 minute → bills at the 1-minute floor.

Creator A (viral):
  unit_price = 20.00 × 1.50 × 1    = $30.00
  subtotal   = 30.00 × 5           = $150.00

Creator B (standard):
  unit_price = 20.00 × 1.00 × 1    = $20.00
  subtotal   = 20.00 × 5           = $100.00

grand_total                         = $250.00

Now bump the longest script to 220 words: ceil(220 / 150) = 2 minutes.

Creator A (viral, 2 min):
  unit_price = 20.00 × 1.50 × 2    = $60.00
  subtotal   = 60.00 × 5           = $300.00

Creator B (standard, 2 min):
  unit_price = 20.00 × 1.00 × 2    = $40.00
  subtotal   = 40.00 × 5           = $200.00

grand_total                         = $500.00

Example 3 — Add-ons stacked on a 10-video order

  • Creator: standard (1.00×)
  • Format: Tier 5, base $55.00, quantity 10
  • addons.video_editing = true, addons.video_demo = true
Format subtotal = 55.00 × 1.00 × 10 = $550.00
Editing         = 20.00 × 10        = $200.00
Demo            = 20.00 × 10        = $200.00
grand_total                         = $950.00

Add a second creator (viral, same items, same add-ons):

Creator A (standard):
  format subtotal = $550.00
  editing         = $200.00
  demo            = $200.00
  total           = $950.00

Creator B (viral):
  format subtotal = 55.00 × 1.50 × 10 = $825.00
  editing         = $200.00           (add-ons flat — multiplier does NOT apply)
  demo            = $200.00
  total           = $1,225.00

grand_total                          = $2,175.00

Add-on flat rates do not scale with the creator multiplier. Editing is always $20/video charged, regardless of whether the creator is viral or standard.

Example 4 — Photos: custom rate vs default

Scenario: 8 photos, no videos.

Creatormodel_pricing.custom_price (photos)Default video_types.pricePhoto unitSubtotal
Sarah M. (viral)$35.00$25.00$35.00$35.00 × 8 = $280.00
Jane D. (standard)unset$25.00$25.00$25.00 × 8 = $200.00

A multi-model order to both:

Sarah M.: 8 × $35.00 = $280.00
Jane D.:  8 × $25.00 = $200.00
grand_total          = $480.00

Photos always bill at the per-creator rate. The multiplier on creator_type does not apply to photos — the custom rate already encodes whatever premium that creator commands.

· · ·

08Status lifecycle

Internally, DansUGC tracks several fine-grained creator-side lanes (creator_to_accept, pending_video_editing, editing_in_progress, completed, etc.). The partner-facing API collapses these into four normalized statuses:

+------------------+      +-----------+      +-------------+      +-----------+
| pending_payment  | ---> | processing| ---> | in_progress | ---> | delivered |
+------------------+      +-----------+      +-------------+      +-----------+
        |                       |                  |
        v                       v                  v
   canceled (24h abandon)   canceled (refund)   canceled (manual)
Partner statusWhat it meansInternal lanes it covers
pending_paymentOrder created, Stripe Checkout not yet completednot_started + payment_status=unpaid
processingPaid, assigned creator(s) haven't started filming yetcreator_to_accept, accepted
in_progressCreator is filming, editing, or uploadingfilming, pending_video_editing, editing_in_progress
deliveredAll files uploaded, delivery_token mintedcompleted
canceledRefunded, abandoned, or manually canceledcanceled

Polling guidance:

  • Customers expect 2–3 business days from pending_payment → delivered for standard orders.
  • Once paid, status transitions are typically: payment confirm (seconds) → processingin_progress (hours) → delivered (1–3 days).
  • Recommended poll interval: 60 seconds while the order is pending_payment or processing, 5 minutes while in_progress.
  • v1.1 will add outbound webhooks (order.paid, order.in_progress, order.delivered, order.canceled). Until then, polling is the only way to react to status changes. Plan for this — if your integration is push-driven, build the webhook listener now with the poller filling it in.
· · ·

09Idempotency

Every POST /orders accepts an Idempotency-Key header. Same key + same body within 24 hours returns the original response (same order IDs, same payment_url). Same key + a different body within 24 hours returns:

HTTP
HTTP/1.1 409 Conflict
JSON
{
  "error": {
    "code": "idempotency_conflict",
    "message": "An order was already created with this Idempotency-Key but the request body differs.",
    "original_order_ids": ["ord_01HW3KJ8E9K0YR6Z3T1F8XQ2A0"]
  }
}

Recommended pattern:

  • Use UUIDv4 for one-off "user clicked submit" flows.
  • Use a deterministic key (e.g. customer_id:isoweek) for scheduled or cron-driven orders. This makes a re-fire of the same cron job a no-op.

POST /pricing/quote is naturally idempotent (it's a pure calculation) and does not require the header.

· · ·

10Errors

All error responses share a single envelope:

JSON
{
  "error": {
    "code": "machine_readable_string",
    "message": "Human-readable explanation"
  }
}

Some errors include extra fields (e.g. format_id on format_minimum_not_met, current_status on files_not_ready). They're additive — clients can safely ignore unknown fields.

HTTPCodeMeaning
400missing_fieldA required field is null or absent
400invalid_fieldA field's value is malformed (e.g. unparseable email)
400invalid_formatformat_id not found
400invalid_modelmodel_id not found or not bookable
400format_minimum_not_metQuantity for a format is below min_order_videos
401unauthorizedMissing or invalid API key
403forbiddenAPI key lacks required scope
404not_foundOrder / model / format does not exist
409idempotency_conflictSame Idempotency-Key, different body
409files_not_readyTried to fetch /files before delivered
429rate_limitedExceeded 60 req/min for this key
500internal_errorServer-side fault. Retry with exponential backoff.

Rate-limit headers

Every response includes:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1748528400
Retry-After: 12          # only present on 429 responses

X-RateLimit-Reset is a Unix epoch second. On 429, sleep until Retry-After seconds have elapsed before retrying. The limit is 60 requests per minute per API key, applied as a rolling 60-second window. Polling once a minute per order is well within budget — bulk catalog jobs should add a 1.1-second pause between paginated calls.

· · ·

11What's not in v1

We've shipped a focused surface. The following are deliberately not in v1 — plan around them.

  • Outbound webhooks. Coming in v1.1 (order.paid, order.in_progress, order.delivered, order.canceled). For now, poll GET /v1/orders/{id}. Build your listener now and route the polled status into it — swapping to webhooks at v1.1 will be a single config change.
  • Balance / credit payment via API. The platform has an internal credit balance, but the v1 API only supports Stripe Checkout as the payment mechanism. Every order returns a payment_url. If your end-user already has a DansUGC balance, they can still log into dansugc.com and pay from there — but the API will not deduct credits.
  • Partial refunds via API. Refunds are handled manually by DansUGC support. Contact us if a customer needs one.
  • Customer self-serve key issuance. v1 keys are admin-issued only. Partners cannot mint keys for their end-users programmatically. If you need per-customer key isolation today, email us and we'll provision sub-keys manually. Per-customer key issuance via API is planned for v1.2.
  • Sandbox / test mode. The dsu_test_... prefix is reserved but not yet wired. There is no test mode for the Custom Orders API in v1. Use small real orders (e.g. one Tier 1 video at $8) for end-to-end verification.
  • Order modification / cancellation via API. Once an order is processing or later, the items are locked. Cancellation before fulfillment requires emailing support — automated cancel is planned for v1.1.
  • Picking specific creators in a multi-model order. All model_ids you pass receive the same items and same promotion_details. You cannot ask creator A for format 412 and creator B for format 587 in a single POST — submit two orders instead. (One order, mixed-format-across-creators is on the v1.2 roadmap.)
· · ·

12Getting access

API keys are admin-issued in v1. To request one:

Email: dan@dansugcmodels.com

Include in your request:

  1. Company name + website.
  2. Use case — one paragraph. What does your product do, and where does DansUGC fit in?
  3. Expected volume — orders/month at steady state, plus any one-time backlog.
  4. Integration timeline — when do you plan to go live?
  5. Required scopes — usually orders:read orders:write pricing:read deliverables:read for a full partner integration.
  6. Contact for ops — who do we email if an order needs intervention?

You'll receive a single dsk_live_... key (60 req/min, all requested scopes) plus a private Slack channel for direct support. Onboarding turnaround is one business day for partners with a public live product, longer for pre-launch integrations.

· · ·

Changelog

  • 2026-05-29 — v1.0. Initial public release of /models, /formats, /pricing/{tiers,quote}, /orders (CRUD), /orders/{id}/files. Stripe Checkout payment, idempotency, R2 signed-URL delivery.

Note on response shapes: A few endpoint response fields (e.g. external_reference on order rows, the exact addons envelope on GET /orders/{id}) are documented here as the locked v1 contract. If you observe a divergence between this document and live API behaviour before 2026-06-15, treat it as a bug and report it to dan@dansugcmodels.com — the doc wins.