FOTOhub API: buy storage, upload files, and send generated media straight to your own bucket
Everything in this guide is done with a single API key. You never need to open the web console, except for the one card payment that funds your balance.
What you can build with this
- Buy and configure real AWS S3 storage from the API, paid per hour from your prepaid balance
- Upload and download your own files, either through presigned URLs or with any S3 client
- Generate images with Seedream 5.0 and videos with Seedance 2.0 Mini, and have every result land directly in your bucket
- Register a face once as a reusable asset and use it across many video generations
- List, inspect and delete only your own registered assets, with no visibility into anyone else's
Before you start
- Base URL:
https://apis.fotohub.app - Storage endpoint for direct uploads:
https://s3point.fotohub.app(AWS SigV4 signed requests only) - Every call is authenticated with the header
Authorization: Bearer fh_live_... - All prices are in US dollars, charged from your prepaid balance. There are no credits and no subscriptions. Credits exist only in the fotohub.app web app and cannot pay for API usage
- Your balance can never go below zero. When a request costs more than you hold, nothing is generated and nothing is charged
Step 1: check your balance and top up
curl -s https://apis.fotohub.app/v1/billing/balance \
-H "Authorization: Bearer $FOTOHUB_API_KEY"{
"wallet": {
"balance_usd": 25.0,
"pending_usd": 0.0,
"total_topped_up_usd": 25.0,
"currency": "USD"
},
"spend": {
"this_month_usd": 0.0,
"monthly_limit_usd": null,
"currency": "USD"
},
"billing_model": "prepaid_wallet_usd"
}Two things to know about the balance, both of which will save you debugging time:
- A partial balance answers
402with"error": "insufficient_funds"and ashortfall_usdfield telling you exactly how much more you need. Nothing is charged on a402, and the payload states"charged": falseso you never have to guess - A balance of exactly
0.00answers403with"error": "api_access_required", not402, because zero balance disables API access before the price of the request is even calculated. Treat both statuses as "needs funding". Keep a small buffer so a busy hour cannot take you to zero mid-integration
Top up at https://fotohub.app/console/wallet. That is the one step that needs a browser, because it is a card payment.
Step 2: price a bucket before you buy it
curl -s -X POST https://apis.fotohub.app/v1/storage/s3/estimate \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"region": "eu-central-1",
"storage_class": "STANDARD",
"storage_gb": 100,
"put_requests": 50000,
"get_requests": 200000,
"egress_gb": 50,
"period_days": 30
}'Notes on this call:
- The size field is
storage_gb, notquota_gb. This endpoint rejects unknown fields, so a typo returns422rather than a silently wrong number - The response carries
line_items,subtotal_usdandtotal_usd, so you can see which component (storage, requests, egress) actually dominates your cost STANDARDcosts about0.0245USD per GB per month. Cheaper classes areSTANDARD_IA,ONEZONE_IA,GLACIER_IR,GLACIERandDEEP_ARCHIVE- Storage is billed hourly on what you actually store, measured from CloudWatch, not on the capacity you declare. An empty 100 GB bucket costs almost nothing, so sizing generously is not expensive
quota_gbis therefore a spending cap, not a purchase. Set it to the most you ever want to be able to store- Requests and egress are billed on real counts too, and egress is tiered across all your buckets together
- There is also a small fixed fee per bucket per month, prorated hourly, which covers provisioning the bucket itself. Budget for it if you plan to run many small buckets rather than a few large ones
You can also read the raw price matrix with GET /v1/storage/s3/pricing, and the list of accepted regions with GET /v1/storage/s3/regions.
Step 3: buy the bucket
curl -s -X POST https://apis.fotohub.app/v1/storage/s3/buy \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "production-media",
"region": "eu-central-1",
"quota_gb": 100,
"default_storage_class": "STANDARD",
"versioning_enabled": false,
"encryption_type": "SSE-S3",
"block_public_access": true,
"billing_mode": "wallet"
}'The response, trimmed:
{
"bucket": {
"id": "8f2c1d90-4e77-4a63-9b1e-2f5a7c8d0e11",
"aws_bucket_name": "fh-cust-8f2c1d90-prod",
"display_name": "production-media",
"region": "eu-central-1",
"status": "active",
"quota_gb": 100
},
"access_key_id": "fhk_live_QcW8x...",
"secret_access_key": "fhk_sec_9Za2v...",
"endpoint": "https://s3point.fotohub.app",
"region": "eu-central-1",
"s3_uri": "s3://fh-cust-8f2c1d90-prod/",
"example_boto3": "...",
"warning": "Save your secret_access_key now, it will NEVER be shown again."
}Points that matter here:
- The bucket id is nested:
bucket.id, not a top levelid - This one call also gives you working S3 credentials. There is no separate step to create a key. Save
secret_access_keyimmediately, it is returned exactly once billing_mode: "wallet"debits a small safety reservation from your balance right away, roughly 1.5 days of the full quota at your storage class, with a minimum of0.15USD. Hourly billing then charges what you actually store. If your balance is short you get a402with the exact shortfall and no bucket is created- If provisioning fails at any step, the reservation is refunded automatically
- Provisioning runs a multi step saga and can take 30 to 60 seconds. Use a client timeout of at least 120 seconds
quota_gbis a cap, from 1 to 1048576. You are billed on what you store, not on this number, andPOST .../upgraderaises it later at no chargeblock_public_access: trueis already the default, so you can omit it. It is shown here because it is worth being explicit about- 16 AWS regions are accepted, not just
eu-central-1. CallGET /v1/storage/s3/regionsfor the current list and pick the one closest to your users - The credentials you receive are FOTOhub virtual keys (
fhk_live_andfhk_sec_). Raw AWS credentials never leave our servers - How many buckets you can own: a funded prepaid balance allows up to 25. Exceeding that returns
403with"error": "plan_gate_exceeded". That is a count limit, not a money problem, so topping up does not raise it
Save the bucket id, you will use it everywhere below:
export BUCKET_ID="8f2c1d90-4e77-4a63-9b1e-2f5a7c8d0e11"Step 4: give the bucket a short alias
An alias is a readable single label you can use in place of the UUID when routing generated output. It makes your integration code far easier to read, and it lets you repoint a name later without touching your code.
curl -s -X POST "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/aliases" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"alias": "prod-media", "public_prefixes": []}'Rules for aliases:
- The alias must be a single DNS label: 3 to 63 characters, lowercase letters, digits and hyphens, starting and ending with a letter or digit. No dots
- Reserved names such as
www,api,admin,s3,static,cdnandgatewayare refused with400and"error": "alias_reserved" public_prefixesis empty by default on purpose. Creating an alias publishes nothing. Making a prefix world readable is a separate, explicitPATCH- Alias count is capped per bucket. On an account that pays purely from the prepaid balance the cap is one alias per bucket, which is all this guide needs. Exceeding it returns
402with"error": "alias_limit_reached" - List them with
GET, change one withPATCH, remove one withDELETE, all on the same path plus/{alias}
From now on you can write "bucket": "prod-media" instead of the UUID.
Step 5: put your own files into the bucket
Two independent ways in. Use whichever fits your stack, they write to the same bucket.
Option A, presigned URLs. Nothing to store, no AWS SDK needed, works straight from a browser.
curl -s -X POST "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/objects/presign-upload" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "uploads/photo.jpg", "content_type": "image/jpeg", "expires_in": 3600}'{
"bucket_id": "8f2c1d90-4e77-4a63-9b1e-2f5a7c8d0e11",
"key": "uploads/photo.jpg",
"method": "PUT",
"url": "https://fh-cust-8f2c1d90-prod.s3.eu-central-1.amazonaws.com/uploads/photo.jpg?X-Amz-...",
"expires_in": 3600,
"headers": {"Content-Type": "image/jpeg"}
}Then PUT the bytes to that url, sending exactly the headers the response listed:
curl -X PUT "$PRESIGNED_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpgexpires_in accepts 60 seconds up to 7 days. Downloads work the same way through objects/presign-download.
Option B, the S3 credentials from step 3. Best for the AWS SDK, aws s3 sync, rclone, or any S3 compatible tool.
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://s3point.fotohub.app",
aws_access_key_id="fhk_live_QcW8x...",
aws_secret_access_key="fhk_sec_9Za2v...",
region_name="eu-central-1",
)
s3.upload_file("photo.jpg", "fh-cust-8f2c1d90-prod", "uploads/photo.jpg")Use the access_key_id, secret_access_key and bucket.aws_bucket_name from the buy response. s3point.fotohub.app is a transparent SigV4 proxy in front of AWS S3, so anything that speaks S3 works against it. An unsigned request returns AccessDenied with a note that requests must be signed with SigV4, which is expected behaviour and not a misconfiguration.
If you lost the secret, or you rotate credentials on a schedule:
curl -s -X POST "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/regenerate-keys" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "ci-pipeline"}'This is a rotation, not a required first step. It returns a new access_key_id and secret_access_key once, and revokes the previous pair. Anything still using the old keys stops working immediately, so roll it during a maintenance window. GET .../credentials lists your keys with the secrets masked, which is useful for auditing but never returns a usable secret.
For files above roughly 100 MB use multipart:
POST /v1/storage/s3/buckets/{bucket_id}/multipart/create {"key": "...", "content_type": "..."}
POST /v1/storage/s3/buckets/{bucket_id}/multipart/presign-part {"upload_id": "...", "key": "...", "part_number": 1}
POST /v1/storage/s3/buckets/{bucket_id}/multipart/complete {"upload_id": "...", "key": "...", "parts": [{"part_number": 1, "etag": "..."}]}
POST /v1/storage/s3/buckets/{bucket_id}/multipart/abort {"upload_id": "...", "key": "..."}Part numbers run 1 to 10000. Collect each part's ETag from its PUT response and send them all to complete, in order. Always abort an upload you gave up on, otherwise the incomplete parts keep occupying billable storage.
Listing and deleting objects:
curl -s -X POST "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/objects/list" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix": "uploads/", "max_keys": 100, "delimiter": "/"}'
curl -s -X POST "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/objects/delete" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"keys": ["uploads/photo.jpg"]}'The list response carries objects (each with key, size, last_modified, etag, storage_class), plus common_prefixes, is_truncated and next_continuation_token. Pass that token back as continuation_token to page through. Batch delete takes up to 1000 keys per call. There is also objects/copy for server side copies.
Step 6: configure the bucket
Four settings are available to an API key. All are optional, and all are readable with GET on the same path.
Block public access, recommended for anything private. Already on by default from step 3, so this is only needed if you want to change it:
curl -s -X PUT "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/public-access-block" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"block_public_acls": true, "ignore_public_acls": true,
"block_public_policy": true, "restrict_public_buckets": true}'CORS, if a browser reads or writes objects directly:
curl -s -X PUT "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/cors" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules": [{"id": "web",
"allowed_methods": ["GET", "HEAD", "PUT"],
"allowed_origins": ["https://yourapp.com"],
"allowed_headers": ["*"],
"expose_headers": ["ETag"],
"max_age_seconds": 3600}]}'Versioning, so an overwrite does not lose the previous file:
curl -s -X PUT "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/versioning" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" -d '{"status": "Enabled"}'Accepted values are Enabled and Suspended. Old versions keep occupying billable storage, so pair versioning with a lifecycle rule.
Lifecycle, to expire or archive automatically and keep the storage bill flat:
curl -s -X PUT "https://apis.fotohub.app/v1/storage/s3/buckets/$BUCKET_ID/lifecycle" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules": [
{"id": "expire-temp",
"status": "Enabled",
"filter": {"prefix": "tmp/"},
"expiration": {"days": 7}},
{"id": "archive-old-video",
"status": "Enabled",
"filter": {"prefix": "video/"},
"transitions": [{"days": 30, "storage_class": "STANDARD_IA"},
{"days": 180, "storage_class": "GLACIER_IR"}],
"abort_incomplete_multipart_upload": {"days_after_initiation": 3}}
]}'Lifecycle is the most nested body in the API, so a few notes:
- The rule identifier is
id, and the on/off field isstatus, which isEnabledorSuspended - The prefix goes inside
filter, and expiry insideexpiration: {"days": N}, not as flat fields - Each transition needs exactly one of
daysordate. Sending both, or neither, is a422 - Also available:
noncurrent_version_expiration: {"noncurrent_days": N}to clean up old versions, andabort_incomplete_multipart_uploadso an abandoned upload does not bill forever - At least one rule is required, up to 200
Other reads and changes available to an API key: GET .../buckets to list them all, GET .../buckets/{id} for detail, GET .../buckets/{id}/usage for bytes and object counts, GET .../buckets/{id}/metrics, and POST .../buckets/{id}/upgrade to raise quota_gb or change the default storage class.
Advanced settings stay in the web console by design: bucket policy, replication, CDN, access points, object lock, encryption keys, ownership and ACL, and bucket deletion. Each of those can expose or destroy data, so they require a signed in session rather than an API key. If you need one of them automated, contact us.
Step 7: send generated media straight to your bucket
This is the part that ties storage and generation together. Add an output block to any generation request and the result is written into your bucket.
"output": {
"bucket": "prod-media",
"prefix": "video/{YYYY}/{MM}/",
"visibility": "private"
}bucketaccepts either the bucket UUID or the alias from step 4prefixis a path template. Available tokens:{YYYY},{MM},{DD},{HH},{mm},{ss},{date},{model},{job_id},{user_id},{name},{index},{ext}- Ending the prefix with a slash means "put the file in this folder and keep its own filename", which is the safest form
- If you write a full key instead, include something unique such as
{job_id},{name}or{index}. A template likeimages/{date}/{model}.{ext}renders identically for every image in a batch. We do de-duplicate by appending-2,-3and so on, but a unique template is clearer visibilityisprivateorpublic- If the bucket reference is wrong, is not yours, or the template is invalid, the generation still succeeds and the file lands in FOTOhub storage with a signed URL. Delivery never fails a generation you already paid for, so a typo in a path costs you nothing
- Tenant isolation is rechecked at delivery time under your own account, so a guessed UUID or alias belonging to another customer resolves to nothing rather than to their bucket
The response tells you where the file is going:
"delivery": {
"kind": "console_bucket",
"routed_by": "request",
"bucket": "fh-cust-8f2c1d90-prod",
"region": "eu-central-1",
"keys": ["video/2026/08/a1b2c3.mp4"],
"status": "pending"
}status is always pending, because the copy into your bucket runs detached so you are not kept waiting on it. keys is the real destination, computed by the same code that performs the copy, so you can start watching for that exact key straight away.
If you want a default destination applied to every request from a key, without repeating the output block, that is configured per key in the web console. A per request output always takes precedence over it.
Step 8: generate an image with Seedream 5.0
Images are synchronous. The response comes back with the finished image and the exact amount charged.
curl -s -X POST https://apis.fotohub.app/v1/ai/generate/image \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "a plain grey ceramic mug on a white table, product photo, soft light",
"model": "seedream-5-0-260128",
"num_images": 1,
"aspect_ratio": "1:1",
"image_size": "2K",
"output": {"bucket": "prod-media", "prefix": "images/{date}/"}
}'{
"model": "seedream-5-0-260128",
"cost_usd": 0.0315,
"currency": "USD",
"billing": {"cost_usd": 0.0315, "balance_usd": 24.9685, "method": "wallet"},
"images": [{"url": "https://..."}],
"delivery": {"bucket": "fh-cust-...", "keys": ["images/2026-08-14/mug.png"], "status": "pending"}
}Points worth knowing:
- The field is
num_images, notn image_sizeaccepts1K,1.5K,2K,3Kand4K. It is also derived fromwidthandheightif you send those instead. Price scales with resolution, so an unsupported value is a400rather than a silent downgrade- Seedream is billed on output tokens, so the exact cost varies a little with the image.
cost_usdis the real amount taken from your balance, andbilling.balance_usdis what remains dola-seedream-5-0-pro-260628is the higher tier of the same family- If a generation fails, nothing is charged, and the response says so explicitly. You never have to guess whether you paid for a failure
Step 9: generate a video with Seedance 2.0 Mini
Videos are asynchronous and charged at submit time. You get a 202 with a job id, then you poll.
curl -s -X POST https://apis.fotohub.app/v1/ai/generate/video \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "a plain grey ceramic mug rotating slowly on a white table",
"model": "seedance-2-0-mini",
"duration": 5,
"resolution": "480p",
"aspect_ratio": "16:9",
"output": {"bucket": "prod-media", "prefix": "video/{YYYY}/{MM}/"}
}'{
"model": "seedance-2-0-mini",
"job_id": "99000f82-5135-400d-82e6-45a6482b3865",
"status": "polling",
"cost_usd": 0.177233,
"currency": "USD",
"billing": {
"cost_usd": 0.177233,
"balance_usd": 24.822767,
"method": "wallet",
"breakdown": {"rate_usd_per_second": 0.035447, "currency": "USD"}
},
"duration": 5,
"resolution": "480p",
"poll_url": "https://apis.fotohub.app/v1/ai/generate/video/99000f82-5135-400d-82e6-45a6482b3865"
}Then poll until the status is terminal:
curl -s "https://apis.fotohub.app/v1/ai/generate/video/$JOB_ID" \
-H "Authorization: Bearer $FOTOHUB_API_KEY"Statuses are queued, processing, polling, then either completed or failed. On completed you get video_url, thumbnail_url, completed_at and, when you routed the output, the delivery block with the keys in your bucket.
Video is priced per second of output, so billing.breakdown.rate_usd_per_second times duration is the whole calculation. A 5 second 480p clip runs about 0.18 USD. That also means the price is the same whether the result is good or not, so keep test clips at the minimum duration.
One timing detail: status: "completed" means the video is rendered, not that the copy into your bucket has finished. The copy runs detached and typically lands within a minute. If you list objects immediately after completed you may not see the key yet, which is expected. Either poll your own bucket for the key from the delivery block, or use S3 event notifications.
Parameters and limits for seedance-2-0-mini:
duration: 4 to 15 secondsresolution:480por720p.seedance-2-0-proadditionally offers1080pand4Kaspect_ratio:16:9,9:16,1:1,4:3,3:4,21:9oradaptiveimage_urlfor image to video,last_frame_urlto pin the final framegenerate_audiofor a native soundtrackreference_imagesup to 9,reference_videosup to 3,reference_audiosup to 3asset_idsfor pre registered faces, see the next stepcallback_url, an https endpoint we POST once the job reaches a terminal state, with the same body as the poll response. Retried after 1, 2 and 4 seconds on a non 2xx response, then droppednegative_promptandseedare recorded on the job
Two rate limits to design around:
- Video submits: 5 per minute per key. Beyond that you get
429. Queue your work instead of firing everything at once - Video polls: 60 per minute. Poll every 5 to 10 seconds, which stays well inside the budget
Price scales with duration, resolution and audio, so read GET /v1/pricing rather than assuming a flat per clip rate. An unsupported duration or resolution is a 400, never a silent downgrade, precisely because price depends on it.
Step 10: register a face as a reusable asset
To keep the same person across many videos, register their photo once and reference it by id afterwards. This works with the Seedance 2.0 models.
First upload the photo, because registration only accepts a URL on a FOTOhub host:
curl -s -X POST https://apis.fotohub.app/v1/photos/upload \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: image/jpeg" \
-H "X-Filename: face.jpg" \
--data-binary @face.jpgSend raw bytes as shown, or JSON with {"file_base64": "...", "filename": "...", "mime_type": "..."}. Do not use multipart form uploads from a public client, they are challenged at the edge before they reach us. The response is the photo record including a url field. The upload itself costs nothing, you only pay hourly for the bytes stored. A 402 here means your balance is empty. This endpoint allows 20 requests per minute.
Then register the returned url:
curl -s -X POST https://apis.fotohub.app/v1/ai/assets/register \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"image_url": "https://s1.fotohub.app/storage/v1/object/...", "retention_hours": 720}'{
"asset_id": "a1b2c3d4",
"uri": "asset://a1b2c3d4",
"status": "Active",
"source_url": "https://s1.fotohub.app/...",
"retention_hours": 720,
"expires_at": "2026-09-13T10:00:00Z"
}Details that matter:
- Registration is free
image_urlmust be https and on one ofs1.fotohub.app,static.fotohub.app,fotohub.apporwww.fotohub.app. Third party URLs are refused with a400that names the allowed hosts. Upload first, then registerretention_hoursis optional, from 1 to 8760. When it elapses the asset is deleted both at the provider and on our side. Use it to bound how long biometric data is kept. Omit it to keep the face until you delete it yourself. Note that0is rejected rather than treated as "no retention"
List your assets:
curl -s "https://apis.fotohub.app/v1/ai/assets?limit=50&offset=0" \
-H "Authorization: Bearer $FOTOHUB_API_KEY"{
"assets": [
{
"asset_id": "a1b2c3d4",
"uri": "asset://a1b2c3d4",
"source_url": "https://s1.fotohub.app/...",
"asset_type": "Image",
"status": "Active",
"source": "public_api",
"retention_hours": 720,
"expires_at": "2026-09-13T10:00:00Z",
"purged_at": null,
"created_at": "2026-08-14T10:00:00Z"
}
],
"count": 1
}This list is filtered by account on the server. It contains only the assets registered with your own key, and never anyone else's, in the same way your assets are never visible to another customer. Requesting an asset id that belongs to someone else returns 404, deliberately indistinguishable from an id that never existed. limit accepts 1 to 200 and defaults to 50, offset pages through the rest, newest first.
Inspect one asset, or delete it:
curl -s "https://apis.fotohub.app/v1/ai/assets/a1b2c3d4" \
-H "Authorization: Bearer $FOTOHUB_API_KEY"
curl -s -X DELETE "https://apis.fotohub.app/v1/ai/assets/a1b2c3d4" \
-H "Authorization: Bearer $FOTOHUB_API_KEY"Step 11: generate from a registered asset
Pass the ids in asset_ids. Both asset://a1b2c3d4 and the bare a1b2c3d4 are accepted.
curl -s -X POST https://apis.fotohub.app/v1/ai/generate/video \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "the person smiles and waves at the camera",
"model": "seedance-2-0-mini",
"duration": 5,
"resolution": "720p",
"asset_ids": ["asset://a1b2c3d4"],
"output": {"bucket": "prod-media", "prefix": "video/{YYYY}/{MM}/"}
}'If any id in the list was not registered by your account, the whole request is refused with 403 before anything is charged. asset_ids is supported on the Seedance 2.0 family only.
Complete working example
This script runs the full flow: buy a bucket, alias it, set a lifecycle rule, generate an image and a video into it, then list what landed there.
"""FOTOhub end to end: storage, generation, delivery. Python 3.9+, needs httpx."""
import os
import time
import httpx
BASE = "https://apis.fotohub.app"
KEY = os.environ["FOTOHUB_API_KEY"]
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
ALIAS = "prod-media"
def check(r):
"""Turn the funding and quota errors into something actionable."""
if r.status_code == 402:
d = r.json().get("detail", {})
raise SystemExit(
f"Insufficient funds: need ${d.get('required_usd')}, "
f"have ${d.get('balance_usd')}, short ${d.get('shortfall_usd')}. "
f"Nothing was charged. Top up: {d.get('topup_url')}"
)
if r.status_code == 403 and "api_access" in r.text:
raise SystemExit("Balance is exactly $0.00, which disables API access. Top up first.")
if r.status_code == 403 and "plan_gate" in r.text:
raise SystemExit("Bucket count limit reached. This is not a funding problem.")
r.raise_for_status()
return r.json()
def balance(c):
return check(c.get(f"{BASE}/v1/billing/balance", headers=H))["wallet"]["balance_usd"]
# 180s, because provisioning a bucket is a saga and can take 30 to 60 seconds.
with httpx.Client(timeout=180) as c:
print(f"balance at start: ${balance(c)}")
# 1. Reuse a bucket if we already have one, otherwise buy.
buckets = check(c.get(f"{BASE}/v1/storage/s3/buckets", headers=H))
if buckets:
bucket_id = buckets[0]["id"]
print(f"reusing bucket {bucket_id} ({buckets[0]['aws_bucket_name']})")
else:
est = check(c.post(f"{BASE}/v1/storage/s3/estimate", headers=H, json={
"region": "eu-central-1",
"storage_class": "STANDARD",
"storage_gb": 10,
"period_days": 30,
}))
print(f"10 GB for 30 days: ${est['total_usd']}")
bought = check(c.post(f"{BASE}/v1/storage/s3/buy", headers=H, json={
"display_name": "production-media",
"region": "eu-central-1",
"quota_gb": 10,
"default_storage_class": "STANDARD",
"block_public_access": True,
"billing_mode": "wallet",
}))
# The id is nested under "bucket", and the S3 keys come back right here.
bucket_id = bought["bucket"]["id"]
print(f"bought {bucket_id} ({bought['bucket']['aws_bucket_name']})")
print(f" endpoint: {bought['endpoint']}")
print(f" access key: {bought['access_key_id']}")
print(f" secret: {bought['secret_access_key']} <-- SAVE THIS NOW")
# 2. Alias, so output routing below reads as a name instead of a UUID.
c.post(f"{BASE}/v1/storage/s3/buckets/{bucket_id}/aliases",
headers=H, json={"alias": ALIAS, "public_prefixes": []})
# 3. Expire scratch files so they stop billing after a week. Note the
# nesting: prefix goes in `filter`, expiry in `expiration`.
check(c.put(f"{BASE}/v1/storage/s3/buckets/{bucket_id}/lifecycle", headers=H, json={
"rules": [{"id": "expire-tmp",
"status": "Enabled",
"filter": {"prefix": "tmp/"},
"expiration": {"days": 7},
"abort_incomplete_multipart_upload": {"days_after_initiation": 3}}]
}))
# 4. Image: synchronous, charged on the response.
img = check(c.post(f"{BASE}/v1/ai/generate/image", headers=H, json={
"prompt": "a plain grey ceramic mug on a white table, product photo",
"model": "seedream-5-0-260128",
"num_images": 1,
"image_size": "2K",
# Trailing slash keeps the generated filename, which is already unique.
"output": {"bucket": ALIAS, "prefix": "images/{date}/"},
}))
print(f"image charged ${img['cost_usd']}, "
f"delivering to {img.get('delivery', {}).get('keys')}")
# 5. Video: asynchronous, charged at submit.
job = check(c.post(f"{BASE}/v1/ai/generate/video", headers=H, json={
"prompt": "a plain grey ceramic mug rotating slowly on a white table",
"model": "seedance-2-0-mini",
"duration": 5,
"resolution": "480p",
"aspect_ratio": "16:9",
"output": {"bucket": ALIAS, "prefix": "video/{YYYY}/{MM}/"},
}))
job_id = job["job_id"]
print(f"video {job_id} charged ${job['cost_usd']} up front, "
f"eta ~{job.get('estimated_seconds')}s")
# 6. Poll. Never faster than every 5s: polls are capped at 60 per minute.
state = {}
for _ in range(75):
time.sleep(8)
state = check(c.get(f"{BASE}/v1/ai/generate/video/{job_id}", headers=H))
if state["status"] in ("completed", "failed"):
break
print(f" {state['status']} {state.get('progress', 0)}%")
if state.get("status") == "completed":
print(f"video ready: {state.get('video_url')}")
want = (state.get("delivery") or {}).get("keys") or []
# "completed" means rendered, not delivered. The copy is detached, so
# wait for the key rather than assuming it is already there.
for _ in range(12):
listing = check(c.post(
f"{BASE}/v1/storage/s3/buckets/{bucket_id}/objects/list",
headers=H, json={"prefix": "video/", "max_keys": 200, "delimiter": ""}))
have = {o["key"] for o in listing["objects"]}
if all(k in have for k in want):
print(f"delivered to your bucket: {want}")
break
time.sleep(10)
else:
print(f"still copying, expected keys: {want}")
else:
# A failed job is refunded automatically, and the response states it.
print(f"video failed: {state.get('error')}, refunded: {state.get('refunded')}")
print(f"balance at end: ${balance(c)}")
# 7. What actually landed in the bucket.
objs = check(c.post(f"{BASE}/v1/storage/s3/buckets/{bucket_id}/objects/list",
headers=H, json={"prefix": "", "max_keys": 50, "delimiter": ""}))
for o in objs["objects"]:
print(f" {o['key']} {o['size']} bytes {o['storage_class']}")Error handling you should implement
Handle these five cases and your integration behaves correctly under all normal conditions.
402 insufficient_funds: you hold some money but not enough for this request. Readshortfall_usd, top up, retry. Nothing was charged, and the payload includes"charged": falseto say so plainly403 api_access_required: your balance is exactly zero, which disables API access. Top up. Check for this as well as402when detecting a funding problem403 plan_gate_exceeded: you reached your bucket count limit. Not a money problem, and topping up will not raise it429: you exceeded a rate limit. Video submits allow 5 per minute, video polls 60 per minute, uploads 20 per minute, most other endpoints 60 per minute. Back off and retry400: an invalid parameter, for example an unsupported resolution for the chosen model. The message names the accepted values. We reject rather than substitute, because price depends on these values
Also worth knowing: 404 on a bucket or asset id means "not found or not yours", and the two cases are deliberately indistinguishable. 410 on a bucket means it is being decommissioned, in which case reads and downloads still work during the grace period but writes do not.
What you are charged for, and what you are not
- Images and videos: charged per generation, at the moment of the request. The exact figure is
cost_usdon the response, andbilling.balance_usdshows what remains - Storage: charged hourly on the bytes you actually store, plus real request counts, plus tiered egress, plus a small fixed fee per bucket. Old object versions and abandoned multipart parts count as stored bytes
- Free: uploads, asset registration, listing, polling, balance checks, presigning, bucket configuration, and raising a quota
- Failed generations: not charged. If the failure happens after a charge, the refund is automatic and the response reports
refunded: true
Your balance can never go negative on a generation. That is enforced in the database rather than only in application code, so a burst of concurrent requests cannot overdraw the account. When several requests race for the last dollar, exactly one succeeds and the rest get a clean 402. We verified this on production: five parallel video submits against a balance funded for exactly one clip produced one 202 and four 402s, ending at 0.00 with no double spend.
Storage works differently, because the bytes are already on disk by the time the hourly job runs and there is nothing to refuse. If your balance cannot cover an hour of storage, the wallet stops at 0.00 and the remainder is recorded as arrears against the bucket. You then have a 72 hour grace window to top up. Reads and downloads keep working throughout. If the arrears are still unpaid when the window closes, the bucket is suspended, so keep enough balance to cover your storage if you are relying on it in production. Topping up clears the arrears on the next hourly run.
Where to look next
- Full API reference:
https://docs.fotohub.app - Model catalogue:
GET /v1/models, filter with?category=imageor?category=video - Current prices:
GET /v1/pricing - Your usage history:
GET /v1/usage - Balance and month to date spend:
GET /v1/billing/balance - Official SDKs:
pip install fotohub,npm install fotohub - Delivery to Your Bucket:
https://docs.fotohub.app/guides/bucket-deliverycovers destinations, per key routing rules and path templates in more depth

