Skip to content

Docsfrom key to first clip

Everything you need for a first render: a key, one request, the job, and the webhook.

Developer API access is by application.

Overview

REST over HTTPS, JSON in and out. Every generation is asynchronous.

Base URL
https://api.pershot.app/v1
Authentication
Authorization: Bearer followed by your key. Keys start with sk_test_ or sk_live_ and are shown once; we store only a hash.
Key permissions
Each key has scopes and, if you set one in the studio, an allowlist of models and tools. A key with an allowlist can use only what is on it, tools included: image upscale, background removal, Enhance, lip sync, outpaint and the image editor. Anything else answers 403 forbidden_model. 1080p or 4K with Enhance needs both the video model and Enhance.
Error shape
{ "error": { "code", "message", "param", "request_id" } }
IDs
Prefixed by type: job_, qt_, key_, up_.
For AI agents
Plain text for AI agents: inputs, legal values, every price and a request exampleseedance-2.0-fastseedance-2.0-miniqwen-image-2512-lightningqwen-image-2512qwen-image-edit-2511-lightningqwen-image-edit-2511

Real renders are not open yet. Leave your email on a model page and we write once, when they open.

Quickstart

Four steps, each with the response you should see.

  1. 01

    Get a key

    Developer API access is by application: request it under API keys in the studio. Once it is open, create a key there and copy it: the full key is shown once.

    Shell
    export API_KEY="sk_test_…"
  2. 02

    Send a request

    POST the shot with an Idempotency-Key and a max_credits cap. You get 202 and a queued job straight away, and the credits are held.

    POST /videos/generations
    curl https://api.pershot.app/v1/videos/generations \
      -H "Authorization: Bearer $API_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{
        "model": "seedance-2.0-fast",
        "prompt": "Perfume mist in hard side light. Slow push-in, black background, grain.",
        "duration": 5,
        "resolution": "720p",
        "aspect_ratio": "16:9",
        "generate_audio": true,
        "max_credits": 34
      }'
    You should see
    {
      "id": "job_01J9ZK4T8W",
      "object": "job",
      "kind": "video",
      "status": "queued",
      "money_status": "reserved",
      "queue_position": 2,
      "requested_model": "seedance-2.0-fast",
      "credits_quoted": 34,
      "credits_charged": null
    }
  3. 03

    Check the job

    GET the job until its status is succeeded. The output URL is signed and expires; download what you keep.

    GET /jobs/{id}
    curl https://api.pershot.app/v1/jobs/job_01J9ZK4T8W \
      -H "Authorization: Bearer $API_KEY"
    You should see
    {
      "id": "job_01J9ZK4T8W",
      "object": "job",
      "kind": "video",
      "status": "succeeded",
      "money_status": "settled",
      "requested_model": "seedance-2.0-fast",
      "resolved_model": "seedance-2.0-fast",
      "credits_quoted": 34,
      "credits_charged": 34,
      "outputs": [
        {
          "kind": "video",
          "url": "https://…/job_01J9ZK4T8W.mp4?sig=…",
          "width": 1280,
          "height": 720,
          "duration_s": 5
        }
      ],
      "error": null
    }
  4. 04

    Receive the webhook

    Pass webhook_url and we POST every state change to it, signed with a timestamp. Answer 2xx; failed deliveries retry with backoff.

    POST → webhook_url
    {
      "id": "evt_01J9ZKB1QX",
      "type": "job.succeeded",
      "created_at": "2026-09-29T14:02:31Z",
      "data": {
        "job": {
          "id": "job_01J9ZK4T8W",
          "object": "job",
          "status": "succeeded",
          "money_status": "settled",
          "credits_charged": 34,
          "outputs": [
            {
              "kind": "video",
              "url": "https://…/job_01J9ZK4T8W.mp4?sig=…",
              "width": 1280,
              "height": 720,
              "duration_s": 5
            }
          ]
        }
      }
    }

Endpoints

All paths are relative to the base URL.

MethodPathWhat it does
GET/v1/modelsModels, capabilities and status available to your account
GET/v1/pricingPublic prices; logged in, your account's prices
POST/v1/quotesPlan, line items, maximum charge and expiry (10 minutes)
POST/v1/videos/generationsGenerate a video; 202 and a job
POST/v1/images/generationsGenerate or edit an image; 202 and a job
GET/v1/jobs/{id}Status, outputs as signed links, actual cost
GET/v1/jobsYour jobs, newest first
POST/v1/jobs/{id}/cancelAsk to cancel; the held credits are refunded unless the model has already accepted the job
GET/v1/balanceAvailable and held credits, by bucket
GET/v1/usageUsage by day or model, as JSON or CSV
POST/v1/uploadsUpload a JPEG, PNG or WebP up to 10 MB

Quotes and caps

Every generation needs either a quote_id or a max_credits cap, never both. With a quote_id the job runs at that quote; with max_credits it runs only if the price is at or below the cap.

model: "auto" always needs max_credits. A quote is valid for 10 minutes; after that you get quote_expired.

Idempotency

Send an Idempotency-Key header (1 to 255 visible ASCII characters) on every generation request.

The same key within 24 hours returns the same job with HTTP 200 instead of 202, so retrying after a timeout never creates or charges a second job.

Webhooks

Pass webhook_url on a generation (or on a batch) to receive its state changes. Each event is a POST whose JSON body has id, type, created_at and data: data.job for job events, data.batch for batch.completed.

Events

  • job.queued
  • job.processing
  • job.succeeded
  • job.failed
  • job.cancelled
  • batch.completed
  • Each delivery has an event id, a timestamp and a signature. Verify it before you trust the body.
  • A delivery counts when your endpoint answers 2xx within 10 seconds. Otherwise it is retried after 1 min, 5 min, 30 min, 2 h and 6 h, then marked failed; support can replay it. Redirects are not followed.
  • Webhook URLs must be https. Hosts on private, loopback or reserved addresses are refused, both when you submit and when we connect.

Headers

X-Signature
t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
X-Event-Id
The event id, also in the body. Retries and replays keep it, so store it and skip an id you have already handled.
Signing secret
One whsec_… secret per account, in the studio under API keys. The HMAC key is the whole secret, prefix included.
Rotation
After you rotate, the old secret keeps signing for 24 hours as a second v1 entry. Accept a delivery if any v1 matches.

Compute the HMAC over the raw body, not a re-serialized copy, compare in constant time, and refuse timestamps more than five minutes old.

Verify X-Signature
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received, before any JSON parsing.
export function verifyWebhook(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const signatures = [];
  for (const item of (header ?? "").split(",")) {
    const [key, value] = item.split("=", 2);
    if (key === "t") t = Number(value);
    if (key === "v1") signatures.push(Buffer.from(value, "hex"));
  }
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return signatures.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}

// verifyWebhook(req.headers["x-signature"], rawBody, process.env.WEBHOOK_SECRET)
// Answer 2xx quickly, and skip an X-Event-Id you have already handled.

Errors

Codes and HTTP statuses are fixed; the message is for people.

CodeHTTPWhen
invalid_request400A field is missing or out of range
unauthorized401No key, a revoked key, or a bad signature
insufficient_credits402The balance does not cover the hold
forbidden_model403The key's allowlist does not include this model or tool
region_not_supported403The model is not offered in your region
developer_api_closed403A developer key while developer API access is closed; access is by application
member_connections_paused403A member sign-in (MCP, connection code or command line) while connecting your own AI agent is paused
not_found404No such job, quote or upload in your account
quote_expired409The quote is older than 10 minutes
price_changed409The price moved above your cap
content_rejected422The content was refused; nothing is charged
rate_limited429Too many requests for this key
concurrency_limited429Too many jobs running for this account
internal_error500Something failed on our side
upstream_unavailable503The model is unavailable; retry later

An uncertain outcome from the model is not an error: the job reports status unknown and is checked before anything is resubmitted.

Limits

Requests
60 per minute per key by default.
Concurrency
3 jobs at once per account by default; ask to raise it.
Ceiling
The model's capacity is a hard ceiling. More keys do not add concurrency.

Images

POST /v1/images/generations takes the same headers. Pick a model id by tier and operation; edit models also need image_url, and generate models reject it.

Tiergenerateedit
Image MiniEconomy · draftqwen-image-2512-lightningqwen-image-edit-2511-lightning
Image FastRecommendedqwen-image-2512qwen-image-edit-2511

Limits by model

Every model's API page has the full table: the value, the unit, who checks it and what happens past it. GET /v1/models returns the same limits as JSON.

  • EnforcedWe check it and answer 400 before anything is quoted or held.
  • SupplierThe supplier checks it. A job it refuses fails, and nothing is charged.
  • RecommendedNobody checks it. Staying inside it gives better results.
ModelMain limitsFull table
seedance-2.0-fastSeedance 2.0 FastPrompt 2 to 2,000 characters · 4 to 15 s · up to 9 reference images · images 300 to 6,000 px per sideOpen the table
seedance-2.0-miniSeedance 2.0 MiniPrompt 2 to 2,000 characters · 4 to 15 s · up to 9 reference images · images 300 to 6,000 px per sideOpen the table
qwen-image-2512Qwen-Image-2512Prompt 2 to 2,000 characters · 1K or 2K · no source imageOpen the table
qwen-image-2512-lightningQwen-Image-2512 · Lightning 4-stepPrompt 2 to 2,000 characters · 1K or 2K · no source imageOpen the table
qwen-image-edit-2511Qwen-Image-Edit-2511Prompt 2 to 2,000 characters · 1K or 2K · one source image, JPEG, PNG or WebP up to 10 MBOpen the table
qwen-image-edit-2511-lightningQwen-Image-Edit-2511 · LightningPrompt 2 to 2,000 characters · 1K or 2K · one source image, JPEG, PNG or WebP up to 10 MBOpen the table

Uploads

POST /v1/uploads reads the type from the file itself. Upload links last 7 days.

FileFormatsLargest fileLongest
Image: first frame, reference or edit sourceJPEG, PNG, WebP10 MB–
Video: lip syncMP450 MB60 s
Audio: lip syncMP3, WAV, M4A10 MB60 s

We never fetch images from other hosts, so for those links the supplier checks the size, and a job it refuses is not charged. The same limits come back as JSON: GET /v1/models returns a limits object for every model.

Coming from fal, Replicate or WaveSpeed

Changing the base URL and the key is not enough: every request also needs the model id in the body, an Idempotency-Key header and a price cap (max_credits or a quote_id). The migration guide maps every field for Seedance 2.0 and Qwen-Image and lists what does not carry over.