DevelopersAPI guide

Build with the SmileToAI API

Run SmileToAI tools from your own code. Upload a file, check the price, start a run, then poll it or take a signed webhook. Runs spend your team's Sparks at the same rates as the app.

Base URL
api.smiletoai.com/v1
Auth
Bearer team key
Plans
Plus, Pro and Team
One run, call by callimage_upscale
  1. POST/v1/files/upload-url201
  2. POST/v1/files/{file_id}/complete200
  3. POST/v1/tools/image_upscale/estimate200
  4. POST/v1/tools/image_upscale/runs202 queued
  5. GET/v1/runs/{run_id}succeeded
  6. GET/v1/runs/{run_id}/outputs/{asset_id}/url200

Or skip the polling: a signed run.succeeded webhook arrives when the run ends.

Quickstart

Six steps from a new key to a finished file. The example upscales an image with image_upscale; every tool uses the same calls.

  1. Step 1: Create an API key

    In SmileToAI, open Settings › API keys and create a key. Owners and admins can create keys on Plus, Pro and Team. The full key is shown once, so put it in your secret manager before you close the dialog.

    Open Settings › API keys

  2. Step 2: Check the key

    Send it as a bearer token. /v1/me tells you which team, plan and key you are using.

    export SMILETOAI_KEY="stai_live_..."
    
    curl https://api.smiletoai.com/v1/me \
      -H "Authorization: Bearer $SMILETOAI_KEY"
    200Response
    {
      "object": "me",
      "team": {
        "id": "6f0c2d9a-3b1e-4f57-9a0d-2c8e5b7f1a64",
        "name": "Northwind Studio",
        "plan": "pro"
      },
      "api_key": {
        "id": "a3e1b0c4-5d2f-4e8a-b7c9-0f1e2d3c4b5a",
        "name": "Production",
        "prefix": "k7m2p9qa",
        "scopes": [
          "read",
          "run"
        ],
        "rate_limit_per_minute": 60
      },
      "sparks_available": 84210
    }
  3. Step 3: Upload the image

    Media inputs take file ids, not URLs. Ask for upload URLs, PUT the bytes, then complete the file; you get a file_id to reuse in estimates and runs. The three calls are in File uploads.

  4. Step 4: Check the price

    An estimate starts nothing and spends nothing. sparks is the expected price; reserve_sparks is what a run holds while it works.

    curl -X POST https://api.smiletoai.com/v1/tools/image_upscale/estimate \
      -H "Authorization: Bearer $SMILETOAI_KEY" \
      -H "Content-Type: application/json" \
      -d '{"inputs": {"image": ["9d41e2c7-0b8a-4f3e-a1d5-6c7b8e9f0a12"]}}'
    200Response
    {
      "object": "estimate",
      "tool_id": "image_upscale",
      "product_id": "image_upscale",
      "sparks": 30,
      "reserve_sparks": 30
    }
  5. Step 5: Start the run

    The API answers 202 with a queued run. Send an Idempotency-Key: if you retry the request, you get the same run back instead of a second one.

    curl -X POST https://api.smiletoai.com/v1/tools/image_upscale/runs \
      -H "Authorization: Bearer $SMILETOAI_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: order-1042" \
      -d '{"inputs": {"image": ["9d41e2c7-0b8a-4f3e-a1d5-6c7b8e9f0a12"]}}'
  6. Step 6: Get the result

    Poll every 2 to 5 seconds until the status is succeeded, then ask for a download URL. Long video runs are easier with a webhook.

    export RUN_ID="8f2c0b7e-41d6-4c1a-9e55-2b7d1f0c9a31"
    
    # Repeat every few seconds until "status" is succeeded, failed or canceled
    curl https://api.smiletoai.com/v1/runs/$RUN_ID \
      -H "Authorization: Bearer $SMILETOAI_KEY"
    
    # Then ask for a short-lived download URL for an output
    curl https://api.smiletoai.com/v1/runs/$RUN_ID/outputs/$ASSET_ID/url \
      -H "Authorization: Bearer $SMILETOAI_KEY"

Authentication and keys

Every request carries a team API key in the Authorization header: Authorization: Bearer stai_live_…. A key has three parts:

A key reads stai_live_, then a 8-character prefix, an underscore and a 32-character secret.
Environment
stai_live_ in production. Keys made outside production start with stai_test_ and only work there.
Prefix
8 characters, listed in Settings so you can tell keys apart.
Secret
32 characters, shown once. Only a hash is kept, so a lost key cannot be recovered.
  • A key belongs to one team and acts as the member who created it. Its runs count toward that member in Settings › Team.
  • If that member leaves the team, the key stops working, and re-joining does not bring it back. Create a new key.
  • Owners and admins create and revoke keys in Settings › API keys, up to 25 active keys per team. A key cannot create or revoke keys.
  • Keys work on Plus, Pro and Team. If the team moves to Free, every call answers 403 plan_upgrade_required until it upgrades.
  • Every key starts with stai_, so secret scanners can spot a leaked SmileToAI key.

Keep keys on your server. Never put one in a browser or a mobile app: anyone who has it can spend your team's Sparks. If a key leaks, revoke it in Settings › API keys.

Scopes

New keys get read and run. A call outside a key's scopes answers 403 insufficient_scope.

Always onread
List tools, estimate, read runs, download outputs, GET /v1/me.
Defaultrun
Start and cancel runs, upload files.
Opt inwebhooks
Add, change, test and delete webhook endpoints. The key's creator must be an owner or admin.

Rate limits

Each key has its own limit, chosen when you create it. Requests draw from a bucket that holds a minute's worth, so short bursts are fine.

  • 30per min
  • 60per min · default
  • 120per min
  • 300per min

Over the limit, the API answers 429 rate_limited with a Retry-After header in seconds. Your team also has an hourly ceiling on new runs, shared by every key and the app; it answers 429 the same way, with Retry-After pointing at the next hour.

429Response
HTTP/1.1 429 Too Many Requests
Retry-After: 4
X-Request-Id: req_5c1e9a0b7d214e33

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 60 requests per minute exceeded; retry in 4s.",
    "request_id": "req_5c1e9a0b7d214e33",
    "details": {
      "limit_per_minute": 60
    }
  }
}

Tools and estimates

GET /v1/tools lists what your key can run, with each tool's inputs and a price range. These are the tools on the API today:

Upscale imageimage_upscale
image→ image
Video transitionvideo_transition
clips, style (optional): fade, dissolve, wipe_left, wipe_right, duration_seconds (optional)→ video
Colour gradeimage_color_grade
image, temperature (optional), contrast (optional), saturation (optional), exposure (optional), grain (optional), preset (optional): natural, warm_film, cool_matte, bw_16mm, none→ image
Upscale videovideo_upscale
video, target (optional): 1080p, 4k→ video
Extract brand kitbrand_kit_extract
url (optional), files (optional), save (optional)→ json

Before you run, POST /v1/tools/{tool_id}/estimate prices the exact inputs you will send. Add duration_seconds for video tools. A tool your plan does not include lists with "allowed": false.

Runs and polling

A run moves through these statuses. The last three are final.

Run statuses: queued, then running, then one of succeeded, failed or canceled. A queued run can also be canceled.
  • Poll GET /v1/runs/{run_id} no faster than every 2 to 5 seconds, stop at a final status, and back off on a 429.
  • cost_sparks is the hold while the run works and the final charge once it ends. A failed or canceled run releases its hold.
  • Cancel a queued or running run with POST /v1/runs/{run_id}/cancel.
  • Download URLs are short-lived. Fetch the file straight away, and ask for a new URL if one expires.
  • You can read runs started with your team's keys. Runs from the app are not visible here.
The run objectExample values
{
  "object": "run",
  "id": "8f2c0b7e-41d6-4c1a-9e55-2b7d1f0c9a31",
  "tool_id": "image_upscale",
  "product_id": "image_upscale",
  "status": "succeeded",
  "progress": 1,
  "cost_sparks": 30,
  "outputs": [
    {
      "asset_id": "c71a5e02-9b4d-4e1f-8a3c-5d6e7f8091a2",
      "mime_type": "image/png",
      "width": 4096,
      "height": 4096,
      "duration_seconds": null,
      "size_bytes": 9420117,
      "file_name": "photo-upscaled.png"
    }
  ],
  "error": null,
  "api_key_id": "a3e1b0c4-5d2f-4e8a-b7c9-0f1e2d3c4b5a",
  "created_at": "2026-10-17T09:12:03Z",
  "started_at": "2026-10-17T09:12:05Z",
  "finished_at": "2026-10-17T09:12:41Z"
}

File uploads

Files go straight to storage in parts, so large videos upload without passing through the API.

  1. POST /v1/files/upload-url with the file's name, type and size. You get a file_id, an upload_id, the part size and one URL per part.
  2. PUT each part to its URL and keep the ETag response header.
  3. POST /v1/files/{file_id}/complete with the upload id and every part's number and ETag.
# 1. Ask for upload URLs
SIZE=$(stat -c%s photo.png)
curl -s -X POST https://api.smiletoai.com/v1/files/upload-url \
  -H "Authorization: Bearer $SMILETOAI_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"file_name\":\"photo.png\",\"mime_type\":\"image/png\",\"size_bytes\":$SIZE}" > upload.json
FILE_ID=$(jq -r .file_id upload.json)

# 2. PUT the part and keep its ETag
ETAG=$(curl -s -D - -o /dev/null -X PUT --upload-file photo.png \
  "$(jq -r '.parts[0].url' upload.json)" | awk 'tolower($1)=="etag:"{print $2}' | tr -d '\r')

# 3. Complete the file
curl -s -X POST https://api.smiletoai.com/v1/files/$FILE_ID/complete \
  -H "Authorization: Bearer $SMILETOAI_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"upload_id\":\"$(jq -r .upload_id upload.json)\",\"parts\":[{\"part_number\":1,\"etag\":$ETAG}]}"

Then pass the id in a media input, for example "image": ["9d41e2c7-0b8a-4f3e-a1d5-6c7b8e9f0a12"].

Webhooks

Instead of polling, let SmileToAI call your server when a run ends. Owners and admins add endpoints in Settings › API keys on the web, or with a key that has the webhooks scope. A team can have up to 10, on HTTPS.

run.succeeded
A run finished and its outputs are ready.
run.failed
A run stopped with an error. Its hold is released.
run.canceled
A run was canceled. Its hold is released.
ping
Sent when you press Send test.

Only runs started with your team's API keys send events. An endpoint receives the runs that finish while it is enabled, and nothing is replayed: after a pause, or for a new endpoint, read any run you missed with GET /v1/runs/{run_id}.

A delivery

What your endpoint receives
POST /hooks/smiletoai HTTP/1.1
Content-Type: application/json
User-Agent: SmileToAI-Webhooks/1
SmileToAI-Event: run.succeeded
SmileToAI-Delivery: 2b9e47d1-6a5c-4f8b-9e0d-3c2b1a0f9e8d
SmileToAI-Signature: t=1792230761,v1=5f8e0c4b…

{
  "id": "evt_4c1d9be07a2f",
  "object": "event",
  "type": "run.succeeded",
  "created_at": "2026-10-17T09:12:42Z",
  "data": {
    "run": {
      "object": "run",
      "id": "8f2c0b7e-41d6-4c1a-9e55-2b7d1f0c9a31",
      "tool_id": "image_upscale",
      "product_id": "image_upscale",
      "status": "succeeded",
      "progress": 1,
      "cost_sparks": 30,
      "outputs": [
        {
          "asset_id": "c71a5e02-9b4d-4e1f-8a3c-5d6e7f8091a2",
          "mime_type": "image/png",
          "width": 4096,
          "height": 4096,
          "duration_seconds": null,
          "size_bytes": 9420117,
          "file_name": "photo-upscaled.png"
        }
      ],
      "error": null,
      "api_key_id": "a3e1b0c4-5d2f-4e8a-b7c9-0f1e2d3c4b5a",
      "created_at": "2026-10-17T09:12:03Z",
      "started_at": "2026-10-17T09:12:05Z",
      "finished_at": "2026-10-17T09:12:41Z"
    }
  }
}

Retries

Answer with any 2xx within 10 seconds. Anything else, including a redirect, is tried again on this schedule:

  • 1 min
  • 5 min
  • 30 min
  • 2 h
  • 12 h
  • then given up

Every retry sends the same body and event id, so store the ids you have handled and skip repeats. Pausing an endpoint drops its pending deliveries.

Verify the signature

v1 is an HMAC-SHA256 of t, a dot and the raw body, keyed with the endpoint's signing secret (whsec_…, shown once when you add it). Check it against the raw bytes before you parse JSON, and reject old timestamps.

import crypto from "node:crypto";

// header: the SmileToAI-Signature header. rawBody: the request body as a Buffer, before JSON.parse.
export function verifySmileToAI(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map(item => item.split("=")));
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return false;
  }
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

Errors

Every error has the same shape, and every response carries an X-Request-Id header. Quote it when you contact support.

Error body
{
  "error": {
    "code": "insufficient_sparks",
    "message": "The team does not have enough Sparks for this run.",
    "request_id": "req_7d0a41c2e9b84f61"
  }
}

A 422 adds errors[], one entry per bad input. Some codes add details.

401api_key_required
No key, or a session token instead of a key.
401invalid_api_key
An unknown, malformed or revoked key, a key from another environment, or a key whose creator left the team.
402insufficient_sparks
The run's hold is more than the team's available Sparks.
403plan_upgrade_required
The team is on Free, or the tool needs a paid plan.
403consent_required
An identity tool's element has no active consent.
403insufficient_scope
The key lacks the scope named in details.required_scope.
404tool_not_found
No tool with that id is available over the API.
404run_not_found
An unknown run id, or a run your team's keys did not start.
404output_not_found
Not an output of that run, or no longer available.
409tool_execution_pipeline
The tool runs as a multi-step pipeline, which the API does not start.
409output_not_ready
The output is still being checked. Try again in a moment.
422invalid_request
An input is missing or invalid; errors[] names each one.
429rate_limited
Over the key's limit or the team's hourly run ceiling. Wait for Retry-After.
500internal_error
Something failed on our side. Retry with backoff, and quote request_id if it keeps happening.

Pricing in Sparks

The API has no price of its own. A run costs the same Sparks as the same tool in the app, from the same balance.

  • Included in Plus, Pro and Team. Free teams cannot create keys.
  • Check any price with an estimate before you run.
  • See spend per key in Settings › API keys (last 30 days) and per member in Settings › Team.
Compare plans

Versioning

This is version 1. Every path starts with /v1, and `/v1/openapi.json` always describes what is live.

Within v1 we add things without notice: new tools, new optional fields, new error codes and new event types, so ignore fields you do not know. A change that would break existing calls ships as a new version, and we announce it before v1 changes.