Make ads with an API key.

Generate images, video and voice on your brand with 34 models, read your brand kit and library, and search the ads your competitors run.

Or let your terminal or agent do it
curl -fsSL https://sandbox.ads.dev/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs
Signs you in, makes a key and saves it to .env.
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" }
  }'
200 OKexample
{
  "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"
}
34image, video and audio models, one request shape
1call for your whole brand kit, as JSON
3ad platforms of competitor ads to search
$0for a generation that fails
Try it with your keyPaste a key and every Try it button runs live. It stays in this browser.

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.

  1. Get a key. Make one in Sandstorm, or run the one-line login in a terminal. Either way you get an sk_live_ key.
  2. Make a generation. POST /v1/generations with a model and its input.
  3. Use the output. Each output has a permanent url and an id you 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" }
  }'
200 OKexample
{
  "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 Sandstorm

Open Settings → API keys, name the key and copy it. It is shown once.

Open API keys

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.

Terminal
$ 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.

Authorizationheader
Bearer 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.

Check your key
curl https://sandbox.ads.dev/v1/brand \
  -H "Authorization: Bearer $SANDSTORM_API_KEY"
200 OKexample
{
  "object": "brand",
  "name": "Crockett Coffee",
  "summary": "Small-batch coffee roasted in Austin and shipped the day it roasts."
}

Generations

POST/v1/generations

Every 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-pro or seedance-2.5/image-to-video.
inputobject, required
The model’s inputs. name is 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_.
statusstring
queued, then running, then succeeded, failed or canceled.
outputarray
The assets it made, each with id, type, url and its size. The main file comes first.
errorobject or null
code and message when it failed. Failed generations are free.
creditsnumber or null
What it was charged, once it finished.
Make an image
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" } }'
200 OKexample
{
  "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 200 and the result; if not, 202 and the generation to poll. GET /v1/generations/{id} takes it too.
PollGET /v1/generations/{id}
Every 2 to 5 seconds until status is succeeded, failed or canceled.
Webhookwebhook_url
Get generation.succeeded, generation.failed or generation.canceled when it ends. See Webhooks.
GET/v1/generations

Your generations, newest first, 20 a page; status and model filter them and cursor pages on.

POST/v1/generations/{id}/cancel

Stops 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.

Poll a generation
curl https://sandbox.ads.dev/v1/generations/gen_6c1f0e9a7b2d4c55a1e3f08b9d2c4e71 \
  -H "Authorization: Bearer $SANDSTORM_API_KEY"
200 OKexample
{
  "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.
Animate the image
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.

GET/v1/webhooks/secret

Your secret, whsec_…. It stays the same for as long as the key lives.

verify.js
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.

ModelBest forPrice
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

GET/v1/brand

Everything 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.

Read the brand kit
curl https://sandbox.ads.dev/v1/brand \
  -H "Authorization: Bearer $SANDSTORM_API_KEY"
200 OKexample
{
  "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

GET/v1/assets

The brand’s library, newest first. Images include a url; video and audio do too when they come from a generation.

typestring
image, video or audio.
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.
cursorstring
next_cursor from the previous page.
POST/v1/assets

Import a file from a URL: { "url": "https://…", "name": "Hero shot" }.

List images
curl "https://sandbox.ads.dev/v1/assets?type=image&limit=3" \
  -H "Authorization: Bearer $SANDSTORM_API_KEY"
200 OKexample
{
  "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

GET/v1/offers

The products and services the brand sells, best match first with ?query=.

GET/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.

List offers
curl https://sandbox.ads.dev/v1/offers \
  -H "Authorization: Bearer $SANDSTORM_API_KEY"

Characters

GET/v1/characters

The people, products, places and styles the brand keeps consistent across generations, with their reference images. Filter with ?query= or ?kind=.

List characters
curl https://sandbox.ads.dev/v1/characters \
  -H "Authorization: Bearer $SANDSTORM_API_KEY"

Voices

GET/v1/voices

Ready-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.

Find an ad voice
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.

GET/v1/advertisers

Find a brand by name, such as ?query=Liquid Death. Each advertiser comes with its ad counts.

GET/v1/ads

Filter 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.

GET/v1/ads/{id}

One ad in full.

Find an advertiser
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.
An error
{
  "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: wait needs 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.

Prompt
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.

Your first generation is one request away.

Get an API key