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) andmodels(verified creators). - Compute a quote with
POST /pricing/quote— multipliers, add-ons, and photo line items resolved server-side. - Submit
POST /orderswith one or moremodel_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}/filesonce status isdelivered.
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.
| Need | Product | Endpoint 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-spec | Custom 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):
| Level | Name | Base price | Notes |
|---|---|---|---|
| 1 | Basic Face Reaction | $8 | No talking; raw reaction |
| 2 | Lipsync / Singing | $15 | Matching audio, single take |
| 3 | Script Reading / App Demo | $20 | Talking-head — billed by minute |
| 4 | Reaction + App Demo | $30 | Picture-in-picture |
| 5 | The "Problem/Solution" | $55 | Script + demo + captions + music |
| 6 | Aesthetic Lifestyle | $85 | Product in environment, 5+ cuts |
| 7 | High-Conversion Ad | $120 | Hook testing, pro lighting |
| 8 | Product Deep Dive | $160 | Unboxing + voiceover + 4K |
| 9 | Brand Storytelling | $205 | Concepting + VFX |
| 10 | Premium Campaign | $250 | Full 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:
| Slug | Display | Multiplier |
|---|---|---|
standard | Standard Creator | 1.0× |
viral | Viral Creator | 1.5× |
couples | Couples | 2.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-on | Charged | Goes 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. |
photos | per-creator custom rate (model_pricing.custom_price) or default video_types.price | Per-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
export DSK_API_KEY=dsk_live_abc123...
curl https://dansugc.com/api/v1/models -H "Authorization: Bearer $DSK_API_KEY"List one format
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);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
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
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
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
// 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
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
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:
| Param | Type | Description |
|---|---|---|
creator_type | enum | standard | viral | couples |
gender | string | female | male | couple | other |
niche | string | Filter by niche slug |
language | string | ISO 639-1 code, e.g. en |
search | string | Full-text search across name and bio |
page | int | 1-indexed, default 1 |
limit | int | Default 24, max 100 |
Sample request:
GET /api/v1/models?creator_type=viral&gender=female&limit=2
Authorization: Bearer dsk_live_abc123
Sample response:
{
"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:
{ "error": { "code": "not_found", "message": "Model not found" } }GET /formats
Scope: orders:read
List curated TikTok-style format templates.
Query params:
| Param | Type | Description |
|---|---|---|
difficulty | int 1–10 | Exact tier |
min_difficulty / max_difficulty | int | Tier range |
niche | string | Filter by niche slug |
category | string | Filter by category slug |
search | string | Full-text search across format name and TikTok caption |
page / limit | int | Pagination — default 24, max 100 |
Sample response:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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):
{
"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:
{ "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:
| Param | Type | Description |
|---|---|---|
status | enum | pending_payment | processing | in_progress | delivered | canceled |
external_reference | string | Exact match — useful for reconciliation |
created_after / created_before | ISO 8601 | Date range |
page / limit | int | Default 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:
{
"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:
{
"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:
{
"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.
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;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.
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.
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)")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.
// 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.
// 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;
}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.
scriptsarray 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.
| Creator | model_pricing.custom_price (photos) | Default video_types.price | Photo unit | Subtotal |
|---|---|---|---|---|
| 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 status | What it means | Internal lanes it covers |
|---|---|---|
pending_payment | Order created, Stripe Checkout not yet completed | not_started + payment_status=unpaid |
processing | Paid, assigned creator(s) haven't started filming yet | creator_to_accept, accepted |
in_progress | Creator is filming, editing, or uploading | filming, pending_video_editing, editing_in_progress |
delivered | All files uploaded, delivery_token minted | completed |
canceled | Refunded, abandoned, or manually canceled | canceled |
Polling guidance:
- Customers expect 2–3 business days from
pending_payment → deliveredfor standard orders. - Once paid, status transitions are typically: payment confirm (seconds) →
processing→in_progress(hours) →delivered(1–3 days). - Recommended poll interval: 60 seconds while the order is
pending_paymentorprocessing, 5 minutes whilein_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/1.1 409 Conflict{
"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:
{
"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.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | missing_field | A required field is null or absent |
| 400 | invalid_field | A field's value is malformed (e.g. unparseable email) |
| 400 | invalid_format | format_id not found |
| 400 | invalid_model | model_id not found or not bookable |
| 400 | format_minimum_not_met | Quantity for a format is below min_order_videos |
| 401 | unauthorized | Missing or invalid API key |
| 403 | forbidden | API key lacks required scope |
| 404 | not_found | Order / model / format does not exist |
| 409 | idempotency_conflict | Same Idempotency-Key, different body |
| 409 | files_not_ready | Tried to fetch /files before delivered |
| 429 | rate_limited | Exceeded 60 req/min for this key |
| 500 | internal_error | Server-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, pollGET /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 intodansugc.comand 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
processingor 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_idsyou pass receive the sameitemsand samepromotion_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:
- Company name + website.
- Use case — one paragraph. What does your product do, and where does DansUGC fit in?
- Expected volume — orders/month at steady state, plus any one-time backlog.
- Integration timeline — when do you plan to go live?
- Required scopes — usually
orders:read orders:write pricing:read deliverables:readfor a full partner integration. - 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_referenceon order rows, the exactaddonsenvelope onGET /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 todan@dansugcmodels.com— the doc wins.