Quickstart
One request makes an image. Send it with Prefer: wait=60 and the finished generation comes back in the same response, with a link to the file.
- Get a key. Make one in Sandstorm, or run the one-line login in a terminal. Either way you get an
sk_live_key. - Make a generation.
POST /v1/generationswith amodeland itsinput. - Use the output. Each output has a permanent
urland anidyou can pass straight into the next model.
curl https://sandbox.ads.dev/v1/generations \
-H "Authorization: Bearer $SANDSTORM_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: wait=60" \
-d '{
"model": "nano-banana-pro",
"input": { "prompt": "A ceramic mug on a sunlit bench, steam rising", "aspect_ratio": "4:5" }
}'
// npm install ads.dev
import { Sandstorm } from 'ads.dev'
const sandstorm = new Sandstorm()
const generation = await sandstorm.generations.run('nano-banana-pro', {
prompt: 'A ceramic mug on a sunlit bench, steam rising',
aspect_ratio: '4:5',
})
console.log(generation.output[0]?.url)
# pip install ads_dev
from ads_dev import Sandstorm
sandstorm = Sandstorm()
generation = sandstorm.generations.run(
"nano-banana-pro",
{"prompt": "A ceramic mug on a sunlit bench, steam rising", "aspect_ratio": "4:5"},
)
print(generation.output[0].url)
{
"id": "gen_6c1f0e9a7b2d4c55a1e3f08b9d2c4e71",
"object": "generation",
"model": "nano-banana-pro",
"status": "succeeded",
"output": [
{
"id": "asset_9c1e77aa",
"object": "asset",
"type": "image",
"name": "A Ceramic Mug On A Sunlit",
"url": "https://cdn.sandstormapp.com/…/9c1e77aa.png",
"content_type": "image/png",
"width": 1856,
"height": 2304,
"role": "main"
}
],
"error": null,
"credits": 6,
"created_at": "2026-10-20T15:04:11Z",
"started_at": "2026-10-20T15:04:12Z",
"completed_at": "2026-10-20T15:04:25Z"
}Get a key
A key belongs to your Sandstorm organization and acts on one of its brands. There are two ways to make one.
In a terminal, or by your agent
Run the login command. It opens your browser to sign in the way you always do, makes a key and saves it to .env.
The login command takes --print to print the key instead of saving it, --org and --brand to choose where it acts, and --name to label it. Revoke a key any time in Settings → API keys.
$ curl -fsSL https://sandbox.ads.dev/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs
Sandstorm API · make a key
1. Sign in to Sandstorm in your browser:
https://app.sandstormapp.com/auth/cli?port=57844
Waiting for you to sign in (Ctrl-C to stop)…
2. Signed in as you@yourbrand.com.
3. Made "CLI on studio-mac" for Crockett Coffee, brand Crockett Coffee.
Saved to .env as SANDSTORM_API_KEY (shown once; it is not printed here).
✓ The key works. It acts on Crockett Coffee.
Authentication
Send the key in the Authorization header on every request. Keep it on your server: anyone holding it can spend your credits.
AuthorizationheaderBearer sk_live_…. Required on everything except the model list.Sandstorm-Brandheader- A brand id, to act on another brand in the same organization. Without it, the key acts on the brand it was made on.
Idempotency-Keyheader- Any unique string on a POST. Retrying with the same key answers the first result instead of running twice; the SDKs send one for you.
Every response carries a Sandstorm-Request-Id. Include it when you contact support.
curl https://sandbox.ads.dev/v1/brand \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
{
"object": "brand",
"name": "Crockett Coffee",
"summary": "Small-batch coffee roasted in Austin and shipped the day it roasts."
}Generations
/v1/generationsEvery model takes the same request and returns the same object. Find a model’s inputs in Models and pricing, or as JSON Schema from GET /v1/models/{id}.
modelstring, required- A model id, such as
nano-banana-proorseedance-2.5/image-to-video. inputobject, required- The model’s inputs.
nameis optional everywhere: what the output is called in your library. webhook_urlstring- An https URL to send a signed event to when the generation finishes.
The generation
idstring- Starts with
gen_. statusstringqueued, thenrunning, thensucceeded,failedorcanceled.outputarray- The assets it made, each with
id,type,urland its size. The main file comes first. errorobject or nullcodeandmessagewhen it failed. Failed generations are free.creditsnumber or null- What it was charged, once it finished.
curl https://sandbox.ads.dev/v1/generations \
-H "Authorization: Bearer $SANDSTORM_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: wait=60" \
-d '{ "model": "nano-banana-2-lite", "input": { "prompt": "A ceramic mug on a sunlit bench, steam rising" } }'
{
"id": "gen_6c1f0e9a7b2d4c55a1e3f08b9d2c4e71",
"object": "generation",
"model": "nano-banana-2-lite",
"status": "succeeded",
"output": [
{
"id": "asset_9c1e77aa",
"object": "asset",
"type": "image",
"name": "A Ceramic Mug On A Sunlit",
"url": "https://cdn.sandstormapp.com/…/9c1e77aa.png",
"content_type": "image/png",
"width": 1856,
"height": 2304,
"role": "main"
}
],
"error": null,
"credits": 6,
"created_at": "2026-10-20T15:04:11Z",
"started_at": "2026-10-20T15:04:12Z",
"completed_at": "2026-10-20T15:04:25Z"
}Getting results
Images take seconds; video can take a few minutes. Pick whichever fits your app.
WaitPrefer: wait=60- Hold the request up to 200 seconds. If it finishes in time you get
200and the result; if not,202and the generation to poll.GET /v1/generations/{id}takes it too. PollGET /v1/generations/{id}- Every 2 to 5 seconds until
statusissucceeded,failedorcanceled. Webhookwebhook_url- Get
generation.succeeded,generation.failedorgeneration.canceledwhen it ends. See Webhooks.
/v1/generationsYour generations, newest first, 20 a page; status and model filter them and cursor pages on.
/v1/generations/{id}/cancelStops a generation. A queued one costs nothing. One the model is already making stops where the model allows it, and otherwise finishes and is charged.
curl https://sandbox.ads.dev/v1/generations/gen_6c1f0e9a7b2d4c55a1e3f08b9d2c4e71 \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
{
"id": "gen_6c1f0e9a7b2d4c55a1e3f08b9d2c4e71",
"object": "generation",
"model": "seedance-2.5/image-to-video",
"status": "running",
"output": [],
"error": null,
"credits": null,
"created_at": "2026-10-20T15:04:11Z",
"started_at": "2026-10-20T15:04:12Z",
"completed_at": null
}Media inputs
Any input that takes an image, video or audio file accepts either an asset id or a public https URL. A URL is imported into your library first, which adds a few seconds.
That makes chaining easy: make a still with an image model, then pass its output[0].id as start_image to a video model.
asset_…asset id- From a generation’s output,
GET /v1/assets, an offer’s photos or your brand kit. https://…URL- png, jpeg, webp, mp4, mov, mp3 or m4a, publicly reachable.
curl https://sandbox.ads.dev/v1/generations \
-H "Authorization: Bearer $SANDSTORM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.5/image-to-video",
"input": {
"start_image": "asset_9c1e77aa",
"prompt": "Slow push in as the steam curls up",
"resolution": "720p",
"duration": 5
},
"webhook_url": "https://yourapp.com/hooks/sandstorm"
}'
Webhooks
Pass webhook_url when you create a generation and you get one POST when it finishes. The body is an event, { id, type, created_at, data }, where data is the generation.
Each delivery is signed with Sandstorm-Signature: t=…,v1=…, an HMAC-SHA256 of t.body with your webhook secret. Check it before you trust the body, and reject old timestamps. A delivery that does not get a 2xx is retried 5 times over about 7 hours.
/v1/webhooks/secretYour secret, whsec_…. It stays the same for as long as the key lives.
import crypto from 'node:crypto'
// secret: from GET /v1/webhooks/secret, once
export function verify(secret, rawBody, header) {
const { t, v1 } = Object.fromEntries(
header.split(',').map((part) => part.split('=')),
)
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
return fresh && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(v1),
)
}
Models and pricing
16 image, 11 video and 7 audio models. Prices are per unit, and a call is charged once it succeeds, rounded up to the next $0.05. GET /v1/models answers all of this as JSON, with every model’s input schema, and needs no key.
| Model | Best for | Price |
|---|---|---|
| Nano Banana Pro | Photoreal images from a prompt: people, products and first frames for video. Up to 4K.Needs prompt |
from $0.30per image1K $0.302K $0.304K $0.60 |
| Nano Banana Pro Edit | Change an image or combine several by instruction: a new background, color or setting.Needs prompt images |
from $0.30per image1K $0.302K $0.304K $0.60 |
| Nano Banana 2 | Faster than Pro for many variations, and the only one that makes 4:1 and 8:1 banners.Needs prompt |
from $0.16per image1K $0.162K $0.244K $0.32 |
| Nano Banana 2 Edit | Many edits or variations of images you already have, faster than Pro.Needs prompt images |
from $0.16per image1K $0.162K $0.244K $0.32 |
| Nano Banana 2 Lite | The fastest image model, for concepts, previews and lots of options before a final.Needs prompt |
$0.084per image |
| Nano Banana 2 Lite Edit | Quick drafts of an edit, and many options to choose from.Needs prompt images |
$0.084per image |
| GPT Image 2.5 Flare | Images that carry copy and typography, follow an exact layout or need a transparent background.Needs prompt |
from $0.178per imageby output size |
| GPT Image 2.5 Flare Edit | Ads built around your product or logo images, with the copy set in the image. Up to 16 references.Needs prompt images |
from $0.178per imageby output size |
| GPT Image 2.5 Sunburst | The slower, precision GPT model, for intricate detail and final passes.Needs prompt |
from $0.178per imageby output size |
| GPT Image 2.5 Sunburst Edit | Precision edits from up to 16 reference images.Needs prompt images |
from $0.178per imageby output size |
| Seedream 5 Pro | Apparel and product imagery, swimwear included, and in-image text in 14 languages.Needs prompt |
from $0.09per image1.5K $0.092K $0.18 |
| Seedream 5 Pro Edit | Targeted changes that keep the rest of the frame, from up to 10 images.Needs prompt images |
from $0.09per image1.5K $0.092K $0.18 |
| Seedream 5 Lite | Faster than Pro, for drafts and 3K or 4K finals.Needs prompt |
$0.07per image |
| Seedream 5 Lite Edit | Fast edits from up to 14 images, at up to 4K.Needs prompt images |
$0.07per image |
| Seedream 5 Pro Layers | Split one image into editable layers: background, product, headline and logo.Needs image |
from $0.068per layer1.5K $0.0682K $0.135 |
| Topaz Upscale | Enlarge an image and recover sharpness without inventing content.Needs image |
$0.16per image |
| Seedance 2.5 | A clip from a prompt alone, with sound, speech and music. 4 to 30 seconds.Needs prompt |
from $0.129per secondby output size |
| Seedance 2.5 | Bring a still to life, or join two frames with motion. 4 to 30 seconds.Needs prompt start_image |
from $0.129per secondby output size |
| Seedance 2.5 | Motion that keeps the look of images, the movement of clips or the sound of audio you pass.Needs prompt |
from $0.129per secondby output size |
| Seedance 2.5 Finish | Finish a 480p draft at 1080p, within 7 days of making it.Needs draft |
from $0.129per secondby output size |
| Kling 3.0 Pro | A clip that opens on your image and can end on a second one.Needs start_image |
from $0.224per secondno audio $0.224audio $0.336 |
| Kling O3 Pro | Keeps a person, product or character true to up to 4 reference images. | from $0.224per secondno audio $0.224audio $0.28 |
| Gemini Omni Flash | A clip from a prompt, from a 360p draft to a 4K take.Needs prompt |
from $0.06per second360p $0.06720p $0.201080p $0.304k $0.60 |
| Gemini Omni Flash | A clip that opens on your image, from 360p to 4K.Needs prompt start_image |
from $0.06per second360p $0.06720p $0.201080p $0.304k $0.60 |
| Gemini Omni Flash | A clip guided by up to 10 images or 3 short clips.Needs prompt |
from $0.06per second360p $0.06720p $0.201080p $0.304k $0.60 |
| Gemini Omni Flash Edit | Change one thing in a clip of 10 seconds or less, such as the background or the weather.Needs prompt video |
from $0.06per second360p $0.06720p $0.201080p $0.304k $0.60 |
| sync-3 | Re-sync the speaker’s lips to new audio: dubs, new lines and fixed takes.Needs video audio |
$16.00per minute |
| ElevenLabs v4 | Speak a script aloud in any library voice.Needs text |
$0.16per 1K characters |
| ElevenLabs Music | Background tracks, jingles and songs, with or without vocals.Needs prompt |
$1.20per minute |
| ElevenLabs Sound Effects | Impacts, ambiences, foley and interface sounds from a description.Needs prompt |
$0.004per second |
| ElevenLabs Voice Changer | Re-voice a recording into another voice, keeping its timing and emotion.Needs audio voice |
$0.004per second |
| ElevenLabs Voice Isolator | Pull the voice out of a recording, removing noise, music and ambience.Needs audio |
$0.20per minute |
| MiniMax Music 2.6 | Songs sung from your lyrics, or instrumentals.Needs prompt |
$0.30per call |
| Sonilo | Instrumental music to the exact second, trained only on licensed music and cleared for ads.Needs prompt duration |
$0.005per second |
Brand kit
/v1/brandEverything Sandstorm knows about how the brand looks and sounds, as JSON: name, summary, voice, audience, positioning, the rules people at the brand stated, colors, type, fonts with file links, and logos with their asset ids and links.
A section the brand has not filled in is null. Its id is what Sandstorm-Brand takes.
curl https://sandbox.ads.dev/v1/brand \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
{
"object": "brand",
"id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
"name": "Crockett Coffee",
"summary": "Small-batch coffee roasted in Austin and shipped the day it roasts.",
"voice": "Warm, plain, a little dry. Never says artisanal.",
"audience": "Home brewers who buy whole beans by subscription.",
"positioning": "The neighborhood roaster, by mail.",
"preferences": "Real kitchens over studio sets.",
"rules": [
{
"kind": "taboo",
"text": "No latte art. No stock photos of beans pouring."
}
],
"colors": [
{
"hex": "#3b2a1a",
"role": "primary",
"name": null
},
{
"hex": "#e0a458",
"role": "accent",
"name": "Crema"
}
],
"typography": [
{
"role": "heading1",
"font_family": "Inter",
"font_size": "60px",
"font_weight": "700",
"letter_spacing": null,
"text_transform": null
}
],
"button": {
"text": "Shop beans",
"color": "#ffffff",
"background_color": "#3b2a1a",
"border_radius": "999px",
"font_family": "Inter",
"font_size": "16px",
"font_weight": "600"
},
"fonts": [
{
"family": "Inter",
"headings": true,
"body": true,
"files": [
{
"weight": "100 900",
"style": "normal",
"url": "https://cdn.sandstormapp.com/…/inter.woff2"
}
]
}
],
"logos": [
{
"id": "asset_fd5e5e52",
"variant": "icon",
"format": "png",
"width": 716,
"height": 543,
"url": "https://cdn.sandstormapp.com/…/fd5e5e52.png"
}
]
}Assets
/v1/assetsThe brand’s library, newest first. Images include a url; video and audio do too when they come from a generation.
typestringimage,videooraudio.offerstring- Only photos of this offer,
offer_…. likedboolean- Only assets the brand liked.
archivedboolean- Archived assets instead of live ones.
limitinteger- 1 to 50; 20 when left out.
cursorstringnext_cursorfrom the previous page.
/v1/assetsImport a file from a URL: { "url": "https://…", "name": "Hero shot" }.
curl "https://sandbox.ads.dev/v1/assets?type=image&limit=3" \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
{
"object": "list",
"data": [
{
"id": "asset_63b888ac",
"object": "asset",
"type": "image",
"name": "Spring Drop Hero",
"url": "https://cdn.sandstormapp.com/…/63b888ac.png",
"content_type": "image/png",
"width": 1080,
"height": 1920,
"caption": "Bag of beans on a kitchen counter",
"liked": true,
"offer": null,
"created_at": "2026-10-18T09:12:44Z"
}
],
"has_more": true,
"next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEwLTE4In0"
}Offers
/v1/offersThe products and services the brand sells, best match first with ?query=.
/v1/offers/{id}One offer in full: how to talk about it, its variants with prices, and photos from every angle, ready to pass into image models.
curl https://sandbox.ads.dev/v1/offers \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
Characters
/v1/charactersThe people, products, places and styles the brand keeps consistent across generations, with their reference images. Filter with ?query= or ?kind=.
curl https://sandbox.ads.dev/v1/characters \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
Voices
/v1/voicesReady-made ElevenLabs voices with a sample to play. Pass a voice’s id as voice to elevenlabs/voiceover. Filter with query, gender, age, accent and use_case.
curl "https://sandbox.ads.dev/v1/voices?use_case=advertisement&limit=3" \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
Competitor ads
Search the ads other brands run on Meta, LinkedIn and Google: copy, CTA, placements, run dates, and transcripts for video.
/v1/advertisersFind a brand by name, such as ?query=Liquid Death. Each advertiser comes with its ad counts.
/v1/adsFilter by advertiser, status, format, platform and dates for an instant answer. A free-text query searches every tracked ad by meaning and can take a minute.
/v1/ads/{id}One ad in full.
curl "https://sandbox.ads.dev/v1/advertisers?query=Liquid%20Death" \
-H "Authorization: Bearer $SANDSTORM_API_KEY"
Errors
Errors use HTTP status codes and one body shape. param names the part of the request at fault, and the message says how to fix it.
400invalid_request_error- The request does not match the schema. Typos get a suggestion.
401authentication_error- No key, or not a valid one.
402billing_error- Not enough credits for this call.
403permission_error- The key cannot act on that brand.
404not_found_error- No such model, generation or asset.
409invalid_request_error- The generation already finished, so it cannot be canceled.
422invalid_request_error- The Idempotency-Key was first sent with a different request.
429rate_limit_error- More than 300 requests a minute from one key, or 1,000 generations in flight at once.
503api_error- Sandstorm could not take the request. Retry it with the same Idempotency-Key.
{
"error": {
"type": "invalid_request_error",
"code": "invalid_input",
"message": "`input.promt` is not an input of this model. Did you mean `prompt`? GET /v1/models/{id} lists its inputs.",
"param": "input.promt",
"request_id": "req_f18c70b3b5c24b73b7ca652a"
}
}
Limits and billing
Rate300 per minute- Requests per key. Polling every few seconds stays well inside it, and
Prefer: waitneeds no polling at all. Queue1,000 in flight- Generations queued or running at once, per organization. Each starts as soon as a model has room, and room is shared fairly between organizations, so a big batch never holds up anyone else.
Billing$0.05 a credit- Calls draw from your organization’s Sandstorm credits, charged once a generation succeeds, rounded up to the next $0.05.
Failuresfree- Credits are held while a generation runs and released if it fails, or if you cancel it before the model starts.
Filespermanent- Every output is saved to your library with a link you can use directly in your app.
For agents
Claude Code, Cursor and Codex can build against the API on their own. Paste this prompt and they read the guide and schema from here, get a key if they need one, and start.
Use the Sandstorm API for image, video and audio generation in this project. Read https://sandbox.ads.dev/llms.txt first and https://sandbox.ads.dev/openapi.json for exact schemas. If SANDSTORM_API_KEY is not set, run: curl -fsSL https://sandbox.ads.dev/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs (it opens my browser to sign in and saves the key to .env). Never print the key. Generations are async: send Prefer: wait=60, or poll GET /v1/generations/{id} until status is succeeded, failed or canceled.