Setup guideAPI setup
0/4
  1. Choose API pathAPI is the route.
  2. Copy OpenAPI pathFirst machine contract is ready.
  3. Read API keysScopes and bearer auth are clear.
  4. Ready to runYou have enough context to call v1.
Get startedZebrafish API

Zebrafish API

Programmatic access to Zebrafish workflows via bearer tokens. Designed for CI pipelines, AI coding agents, and custom integrations.

Raw

Base URL: https://zebrafish.dev/api/v1

Auth: Authorization: Bearer <your-key>

OpenAPI spec: https://zebrafish.dev/api/v1/openapi.json

Machine-readable spec#

The full OpenAPI 3.1 document is served at https://zebrafish.dev/api/v1/openapi.json (unauthenticated — it's the public contract).

Use it to:

  • Generate typed clients (openapi-typescript, openapi-generator)

  • Import the surface into Bruno / Postman

  • Give Claude Code or Cursor the contract so they can call the API without guessing route shapes

If the docs and the OpenAPI document ever diverge, the OpenAPI document is canonical — docs are re-generated to match.

MCP#

Claude Code, Codex, and other MCP-capable agents can use Zebrafish through the hosted MCP server at https://zebrafish.dev/api/mcp. See docs/mcp.md for setup, interaction modes, available tools, coverage boundaries, and MCP-shaped error examples.

Quickstart (<5 min)#

Agent-assisted setup starts at https://zebrafish.dev/agent-onboarding?preset=authoring-agent. That URL opens the existing OAuth/sign-in flow and returns approved users to API key setup with an agent preset selected.

Manual setup:

  1. Sign in to Zebrafish.

  2. Navigate to Settings → API Keys.

  3. Click Create key, keep the Run published workflows preset, click Create.

  4. Copy the key or the generated agent onboarding prompt (shown once), then export it:

    BASH
    export ADFISH_API_KEY=adf_...
  5. List your workflows:

    BASH
    curl https://zebrafish.dev/api/v1/workflows \
      -H "Authorization: Bearer $ADFISH_API_KEY"

You should see a JSON response with your workflows. You're using the API.

Agent OAuth handoff#

Agents cannot bypass Clerk, the waitlist, or organization approval. They can open a deterministic handoff URL for the human browser:

TEXT
https://zebrafish.dev/agent-onboarding?preset=authoring-agent

Provider-specific OAuth links are also supported:

TEXT
https://zebrafish.dev/agent-onboarding?preset=authoring-agent&oauth_provider=google
https://zebrafish.dev/agent-onboarding?preset=authoring-agent&oauth_provider=github

After sign-in, approved users land on /settings/api-keys?create=1&preset=authoring-agent. The Create key modal is opened with the preset selected. The key creation API remains same-origin and session-protected; the secret is shown once in the browser, together with a copy-ready agent onboarding prompt. From that modal, users can continue to /settings/spend?source=agent-onboarding and buy prepaid credits through Stripe Checkout before giving the agent paid work.

Autonomous payment delegation#

Agents with a scoped API key can buy prepaid credits directly via billing:purchase_credits:

  • POST /api/v1/billing/credits/purchase — create a purchase (idempotent)

  • GET /api/v1/billing/credits/purchases/:id — poll purchase status

  • MCP tools purchase_credits and get_credit_purchase on the hosted MCP server (https://zebrafish.dev/api/mcp). The in-app session agent intentionally excludes these tools; humans buy credits at Settings → Spend.

Rails (v1):

RailWhen availableAgent behavior
fakeDev/CI/preview only. Requires AGENT_PAYMENTS_FAKE_PROVIDER and AGENT_PAYMENTS_FAKE_WEBHOOK_SECRET; production and unknown VERCEL_ENV values fail closed. Settles via POST /api/webhooks/agent-payments/fake.Returns pending_settlement; credits appear after the fake webhook confirms.
stripe_checkoutProduction and preview when Stripe credit checkout is configured: STRIPE_SECRET_KEY, STRIPE_PRICE_CREDIT_UNIT, and STRIPE_WEBHOOK_SECRET. The configured Price must pass the $1 USD per-credit preflight before Checkout creation.Always returns requires_action with a hosted checkout URL (action.type: "handoff_url"). A human or browser-use agent completes payment once. Settlement arrives through the existing Stripe webhook; credits appear only after settlement.

Ramp Agent Cards, Sponge wallets, and ordinary cards work today against stripe_checkout — no extra adapter code. x402, MPP, and ACP/UCP are future adapter targets; the provider boundary is shaped for them but not implemented.

Controls:

  • Scope gate: billing:purchase_credits (not included in default agent presets, and NOT inherited by admin keys — creating a key with this scope is the explicit purchase-enrollment act for that key).

  • API-key credential only (session tokens return 403 forbidden).

  • Per-key limits enforced in the database. Defaults when no agent_payment_limits row exists: 100 credits per purchase, 250 credits rolling daily, 1000 credits per calendar month (1 credit = $1). A row overrides all defaults; past expiresAt denies with limit_exceeded. Limits count at authorization time (purchase creation), like a card auth hold — a purchase created just before a window boundary and settled after it counts against the window it was created in, so settled spend can briefly reach ~2x a cap across one boundary.

  • Idempotency-Key header required. Same key + same payload replays the purchase (200, idempotent: true). Same key + different payload returns 409 idempotency_conflict.

  • Audit events (agent_payment_attempt, agent_payment_settled, agent_payment_failed) are written before provider calls and on state changes. Raw payerCredentialId is never stored — only a server-secret HMAC fingerprint.

Feature flag: when AGENT_PAYMENTS_ENABLED is off, purchase routes return 404 not_found (indistinguishable from a missing route) and MCP tools are hidden.

Scopes#

Each key has one or more scopes. Pick the narrowest set your use case needs. admin is a full-access scope and cannot be combined with other scopes — with one exception: admin does NOT include billing:purchase_credits. Purchasing credits spends real money, so it requires a key explicitly created with that scope; no key inherits it.

ScopeAllowsUse case
workflows:readGET /api/v1/workflows, GET /api/v1/workflows/:id, GET /api/v1/workflows/:id/authoring-context, GET /api/v1/workflow-authoring-guide, GET /api/v1/runs, GET /api/v1/runs/:id, GET /api/v1/runs/:id/debug, GET /api/v1/runs/:id/trace, GET /api/v1/runs/:id/events, POST /api/v1/campaigns/:id/assets/:assetId/urlDashboards, monitoring, read-only integrations
workflows:writeAll workflows:read access plus starter workflow creation, draft validation, and save endpoints: POST /api/v1/workflows/from-template, POST /api/v1/workflows/:id/draft/validate, POST /api/v1/workflows/:id/draft-revisions; also experimental MCP draft-authoring toolsAI coding agents that author workflow drafts
workflows:publishAll workflows:read access plus POST /api/v1/workflows/:id/revisions/:revisionId/publish; also the MCP publish toolAgents or services allowed to promote reviewed drafts
campaigns:readGET /api/v1/campaigns, GET /api/v1/campaigns/:id, GET /api/v1/campaigns/:id/knowledge, GET /api/v1/workflows/:id/campaign, GET /api/v1/campaigns/:id/workflowsAgents that need project context. Routes/scopes still use campaigns for compatibility.
campaigns:writeProject create/update/default plus workflow attachment endpoints; also grants campaigns:readAgents that manage project/workflow relationships
assets:readGET /api/v1/campaigns/:id/assetsAgents that inspect project reference files
assets:writeProject file presign/finalize endpoints; also grants assets:readAgents that upload project reference files
runs:triggerAll workflows:read routes + POST /api/v1/workflows/:id/runs; combine on the same key with workflows:write for POST /api/v1/workflows/:id/draft-test-runsCI pipelines, AI agents, automation
runs:managePOST /api/v1/runs/:id/cancel, POST /api/v1/runs/:id/approvalsOperators allowed to stop runs or answer approval gates
usage:readGET /api/v1/runs/:id/costsRead per-run cost breakdown
studio:readGET /api/v1/studio/generations, GET /api/v1/studio/generations/:idPoll and list studio generations
studio:writePOST /api/v1/studio/generations, POST /api/v1/studio/edits; also grants studio:readAgents that generate or edit media via the studio
models:readGET /api/v1/modelsAgents that pick models and build generation configs
models:writePOST /api/v1/models; also grants models:readAdmin-level keys that register org models (new models still require in-app verification)
billing:purchase_creditsPOST /api/v1/billing/credits/purchase, GET /api/v1/billing/credits/purchases/:id, MCP tools purchase_credits and get_credit_purchase (hosted MCP only)Autonomous agents topping up prepaid credits within hard spend limits
adminAll usage:read routes + GET /api/v1/usage + GET /api/v1/orgs/me + PATCH /api/v1/orgs/me + PATCH /api/v1/workflows/:idFull cost aggregate access; spend cap management; workflow rename scripts; avoid for long-lived agent/CI keys

Endpoints#

GET /api/v1/workflows — List workflows#

Requires: workflows:read scope.

Query params:

  • limit — integer, default 50, max 200

  • after — cursor; pass the id of the last item from the previous page

BASH
curl "https://zebrafish.dev/api/v1/workflows?limit=10" \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "data": [
    {
      "id": "wf_abc123",
      "name": "Marketing brief → post",
      "description": "Full pipeline: brief in, social posts out",
      "createdAt": "2026-03-15T10:00:00Z",
      "updatedAt": "2026-04-10T14:22:00Z",
      "version": 3
    }
  ],
  "pagination": {
    "limit": 10,
    "after": null,
    "hasMore": false
  }
}

GET /api/v1/workflows/:id — Workflow detail#

Requires: workflows:read scope.

BASH
curl https://zebrafish.dev/api/v1/workflows/wf_abc123 \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "id": "wf_abc123",
  "name": "Marketing brief → post",
  "description": "Full pipeline: brief in, social posts out",
  "createdAt": "2026-03-15T10:00:00Z",
  "updatedAt": "2026-04-10T14:22:00Z",
  "version": 3
}

404 if the workflow is deleted, doesn't exist, or belongs to a different org.


PATCH /api/v1/workflows/:id — Rename workflow#

Requires: admin scope.

Updates the workflow display name and returns the updated workflow object. name must be a string from 1 to 128 characters with no leading or trailing whitespace.

BASH
curl -X PATCH https://zebrafish.dev/api/v1/workflows/wf_abc123 \
  -H "Authorization: Bearer $ADFISH_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Spring launch assets"}'

Node.js example:

TS
const res = await fetch(
  `https://zebrafish.dev/api/v1/workflows/${workflowId}`,
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.ADFISH_ADMIN_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name: "Spring launch assets" }),
  }
);

if (!res.ok) {
  const { error } = await res.json();
  if (error.type === "validation_error") {
    // error.details.code is one of:
    // "name_required", "name_too_long", "name_whitespace"
    throw new Error(`${error.details.field}: ${error.details.code}`);
  }
  throw new Error(error.message);
}

const workflow = await res.json();

Request body:

FieldTypeRequiredDescription
namestringYesNew workflow name. Must be 1-128 characters. Leading and trailing whitespace are rejected.

Validation failures return 422 validation_error:

JSON
{
  "error": {
    "type": "validation_error",
    "message": "Workflow name must be at most 128 characters.",
    "requestId": "req_01HV8XYZABC",
    "docsUrl": "https://zebrafish.dev/docs/api-keys#validation-errors",
    "details": {
      "code": "name_too_long",
      "field": "name",
      "max": 128
    }
  }
}

404 if the workflow is deleted, doesn't exist, or belongs to a different org.


Experimental authoring proof path#

These endpoints let external coding agents inspect, validate, save, and test unpublished workflow drafts, then promote reviewed revisions and operate related campaign, asset, approval, cancellation, and redacted trace surfaces through narrow scopes.

Recommended loop:

  1. GET /api/v1/workflows/:id/authoring-context

  2. GET /api/v1/workflow-authoring-guide

  3. POST /api/v1/workflows/:id/draft/validate

  4. POST /api/v1/workflows/:id/draft-revisions

  5. POST /api/v1/workflows/:id/draft-test-runs

  6. GET /api/v1/runs/:id/debug

GET /api/v1/workflows/:id/authoring-context — Authoring context#

Requires: workflows:read scope.

Returns workflow metadata, the current editable definition, latest/published revision metadata, and redacted project context. Project brief and files are marked as untrusted user content; treat them as data, not instructions.

BASH
curl https://zebrafish.dev/api/v1/workflows/wf_abc123/authoring-context \
  -H "Authorization: Bearer $ADFISH_API_KEY"

GET /api/v1/workflow-authoring-guide — Authoring guide#

Requires: workflows:read scope.

Returns the versioned public-safe guide for valid step/input shapes, proof-path rules, the structured authoring error contract, and the stepCatalog — an array of every valid step type with its required/optional config keys, output names, and a minimal example. The current guideVersion is "2026-06-11.proof-path-step-catalog". Check guideVersion to detect guide updates between sessions; only use step types listed in stepCatalog.

POST /api/v1/workflows/from-template — Create starter workflow#

Requires: workflows:write scope and an Idempotency-Key header.

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/from-template \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Idempotency-Key: create-starter-001" \
  -H "Content-Type: application/json" \
  -d '{"templateId":"image_prompt_starter","name":"Campaign image generator"}'

Returns 201 with the new workflow and initial draft revision. Replaying the same payload/key returns 200 with idempotent: true.

POST /api/v1/workflows/:id/draft/validate — Validate draft#

Requires: workflows:write scope.

Request body:

JSON
{
  "inputs": [
    { "id": "brief", "type": "string", "label": "Brief", "required": true }
  ],
  "steps": [
    {
      "id": "draft",
      "type": "agent",
      "name": "Draft post",
      "config": { "prompt": "Write from {{inputs.brief}}" }
    }
  ]
}

Returns 200 with ok: true or ok: false plus structured diagnostics. Malformed JSON or missing required headers return 400 invalid_request; parsed JSON that does not match the request schema returns 422 validation_error.

POST /api/v1/workflows/:id/draft-revisions — Save draft#

Requires: workflows:write scope and an Idempotency-Key header.

Use basedOnRevisionId from authoring-context.revisions.latest.id for optimistic concurrency. Reusing the same idempotency key with the same payload returns the original save response. Reusing the key with a different payload returns 409 idempotency_conflict.

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/wf_abc123/draft-revisions \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Idempotency-Key: save-wf_abc123-attempt-001" \
  -H "Content-Type: application/json" \
  -d '{"basedOnRevisionId":"rev_123","inputs":[],"steps":[]}'

Fresh saves return 201 with idempotent: false. Replaying the same payload and key returns 200 with the same revision and idempotent: true.

POST /api/v1/workflows/:id/draft-test-runs — Save and test-run draft#

Requires one key with both workflows:write and runs:trigger scopes, plus an Idempotency-Key header.

The request body contains a revision object with basedOnRevisionId, inputs, and steps, plus optional run inputs. Reusing the same key with a different payload returns 409 idempotency_conflict.

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/wf_abc123/draft-test-runs \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Idempotency-Key: test-wf_abc123-attempt-001" \
  -H "Content-Type: application/json" \
  -d '{"revision":{"basedOnRevisionId":"rev_123","inputs":[{"id":"brief","type":"string","label":"Brief","required":true}],"steps":[{"id":"draft","type":"agent","name":"Draft post","config":{"prompt":"Write from {{inputs.brief}}"}}]},"inputs":{"brief":"Spring launch"}}'

Returns 201 with idempotent: false and redacted run debug detail when a new test run is dispatched. revision.steps must contain at least one step; empty drafts can be saved with draft-revisions, but they cannot be test-run. Replaying the same payload/key returns 200 with idempotent: true.

POST /api/v1/workflows/:id/revisions/:revisionId/publish — Publish revision#

Requires: workflows:publish scope and an Idempotency-Key header.

Set expectedLatestRevisionId to the current authoring-context.revisions.latest.id. The path revisionId must be the same latest revision. If another actor saves a newer revision first, the API returns 409 stale_revision instead of publishing the stale draft.

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/wf_abc123/revisions/rev_124/publish \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Idempotency-Key: publish-wf_abc123-rev_124" \
  -H "Content-Type: application/json" \
  -d '{"expectedLatestRevisionId":"rev_124"}'

Returns 200 with the published revision metadata.


Projects#

The human UI calls these workspaces Projects. The API keeps the existing /campaigns paths, campaignId fields, and campaigns:* scopes for compatibility until /projects aliases are introduced.

Project updates are intentionally one mutation per request: send only name, only brief with baseRevisionId, or only makeDefault. Mixed project updates are rejected with 400 invalid_request so clients cannot observe a failed request that partially changed project state.

GET /api/v1/campaigns — List projects#

Requires: campaigns:read scope.

Query params:

  • archived — boolean; when true, returns only archived projects. When omitted or false, returns only active projects.

BASH
curl "https://zebrafish.dev/api/v1/campaigns" \
  -H "Authorization: Bearer $ADFISH_API_KEY"

GET /api/v1/campaigns/:id — Get project#

Requires: campaigns:read scope.

Returns a single project. 404 for missing or cross-org projects.

PATCH /api/v1/campaigns/:id — Update project#

Requires: campaigns:write scope and an Idempotency-Key header.

Provide exactly one of name, brief (with baseRevisionId), or makeDefault: true per request. Mixed mutations are rejected with 400 invalid_request.

GET /api/v1/campaigns/:id/workflows — List project workflows#

Requires: campaigns:read scope.

Query params:

  • all — boolean; when true, returns all project-workflow attachment records. When omitted, returns only active assignments.

POST /api/v1/campaigns/:id/workflows — Bulk attach workflows#

Requires: campaigns:write scope and an Idempotency-Key header.

Attaches up to 200 workflows to a project.

GET /api/v1/workflows/:id/campaign — Get workflow project#

Requires: campaigns:read scope.

Returns the project attached to a workflow ({ campaignId: string | null }).

PUT /api/v1/workflows/:id/campaign — Attach project to workflow#

Requires: campaigns:write scope and an Idempotency-Key header.

Pass { campaignId: string } to attach or { campaignId: null } to detach.

Project Files#

Project file upload creation uses a shorter idempotency replay window than other public writes. The returned R2 presigned PUT URL expires after 15 minutes, so POST /api/v1/campaigns/:id/assets/presign stops replaying before that expiry and a later retry with the same key claims a fresh upload request instead of returning an expired URL.

GET /api/v1/campaigns/:id/assets — List project files#

Requires: assets:read scope.

POST /api/v1/campaigns/:id/assets/presign — Create presigned upload#

Requires: assets:write scope and an Idempotency-Key header.

Creates a pending asset record and a short-lived presigned R2 PUT URL. After uploading the file to the URL, call finalize to confirm the asset. Allowed MIME types: image PNG/JPEG/WebP/GIF, PDF, PPTX, plain text, and markdown.

POST /api/v1/campaigns/:id/assets/finalize — Finalize upload#

Requires: assets:write scope and an Idempotency-Key header.

Transitions the asset from pending to active.

POST /api/v1/campaigns/:id/assets/:assetId/url — Get asset download URL#

Requires: workflows:read scope.

Returns a short-lived presigned GET URL for a project file. Intended for use inside workflow step logic.

BASH
curl -X POST "https://zebrafish.dev/api/v1/campaigns/cmp_abc123/assets/ca_xyz456/url" \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "presignedGetUrl": "https://r2.example.com/bucket/ca_xyz456?X-Amz-Signature=...",
  "expiresIn": 300
}

The URL is valid for 300 seconds. Fetch the asset contents within that window. 404 if the project or file is not found in the org.

GET /api/v1/campaigns/:id/knowledge — Export OKF knowledge#

Requires: campaigns:read scope.

Returns the project's OKF brand knowledge bundle as concepts plus serialized markdown. Use ?format=markdown to download markdown.

Run Management#

GET /api/v1/runs/:id/debug — Redacted run debug detail#

Requires: workflows:read scope.

Returns run status, redacted run/step errors, timestamps, and step states for agent repair loops. It does not include Trigger tokens, provider internals, public access tokens, or raw step outputs.


GET /api/v1/runs/:runId/events — Run step events#

Requires: workflows:read scope.

Returns a cursor-paginated list of step events for a run. Use this endpoint to stream run progress without subscribing to a WebSocket. Poll until status is a terminal state (completed, failed, cancelled, dispatch_failed) and pagination.hasMore is false.

Query params:

  • after — integer event-id cursor (as a string). Pass the last data[n].id as after on the next request to get the next page. Omit to start from the first event.

  • limit — integer, default 50, max 200

Polling pattern:

BASH
after=""
while true; do
  url="https://zebrafish.dev/api/v1/runs/${RUN_ID}/events?limit=50"
  if [ -n "$after" ]; then
    url="${url}&after=${after}"
  fi
  response=$(curl -s "$url" -H "Authorization: Bearer $ADFISH_API_KEY")
  status=$(echo "$response" | jq -r '.status')
  echo "$response" | jq '.data[].eventType'
  has_more=$(echo "$response" | jq -r '.pagination.hasMore')
  after=$(echo "$response" | jq -r '.pagination.after // empty')
  if [ "$has_more" = "false" ] && \
     { [ "$status" = "completed" ] || [ "$status" = "failed" ] || \
       [ "$status" = "cancelled" ] || [ "$status" = "dispatch_failed" ]; }; then
    break
  fi
  sleep 1
done

Response:

JSON
{
  "status": "running",
  "data": [
    {
      "id": 1001,
      "stepId": "draft",
      "eventType": "step_started",
      "seq": 0,
      "payload": { "model": "claude-haiku-4-5" },
      "redactedKeys": [],
      "createdAt": "2026-06-11T10:00:01Z",
      "attemptId": null
    }
  ],
  "pagination": {
    "limit": 50,
    "after": "1001",
    "hasMore": false
  }
}

Field notes:

  • id — monotonically increasing integer. Use as the after cursor.

  • stepId — step ID within the workflow definition; null for run-level events.

  • payload — event payload with sensitive keys redacted. Check redactedKeys to know which keys were stripped.

  • attemptId — always [redacted_id] when present (Trigger.dev internal ID).

  • pagination.after — cursor to pass on the next poll. When events are returned, this is the last event id as a string. On an empty page after a supplied cursor, it echoes that cursor so pollers do not reset to the beginning. It is null only when no cursor has been established yet.

404 if the run is not found or belongs to a different org.


GET /api/v1/runs — List runs#

Requires: workflows:read scope.

Query params:

  • workflowId — filter to runs for a specific workflow

  • campaignId — filter to runs whose workflow is attached to the specified campaign. Returns 404 if the campaign is not found in the org; 422 if the value is present but empty.

  • status — one of queued, running, cancelling, cancelled, completed, failed, dispatch_failed

  • limit — integer, default 50, max 200

  • after — cursor; pass the id of the last item from the previous page

BASH
curl "https://zebrafish.dev/api/v1/runs?workflowId=wf_abc123&status=completed&limit=20" \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "data": [
    {
      "id": "run_xyz789",
      "workflowId": "wf_abc123",
      "status": "completed",
      "inputs": { "brand": "Acme", "brief": "Spring launch" },
      "startedAt": "2026-04-10T14:30:05Z",
      "completedAt": "2026-04-10T14:31:42Z",
      "cancelledAt": null,
      "error": null
    }
  ],
  "pagination": {
    "limit": 20,
    "after": null,
    "hasMore": false
  }
}

GET /api/v1/runs/:id — Run detail with steps#

Requires: workflows:read scope.

BASH
curl https://zebrafish.dev/api/v1/runs/run_xyz789 \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "id": "run_xyz789",
  "workflowId": "wf_abc123",
  "status": "completed",
  "inputs": {
    "brand": "Acme",
    "brief": "Launch campaign for the new widget"
  },
  "startedAt": "2026-04-10T14:30:05Z",
  "completedAt": "2026-04-10T14:31:42Z",
  "cancelledAt": null,
  "error": null
}

404 if the run doesn't exist or belongs to a different org.


POST /api/v1/workflows/:id/runs — Trigger a workflow run#

Requires: runs:trigger scope.

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/wf_abc123/runs \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inputs": {"brand": "Acme", "brief": "Spring launch"}}'

Request body:

FieldTypeRequiredDescription
inputsobjectRequired if the workflow defines any required inputs; otherwise optional. Missing required inputs return 422 validation_error with the field names.Key/value map matching the workflow's input definitions

Returns: 201 Created

Response:

JSON
{
  "id": "run_xyz789",
  "workflowId": "wf_abc123",
  "status": "queued",
  "inputs": { "brand": "Acme", "brief": "Spring launch" },
  "startedAt": null,
  "completedAt": null,
  "cancelledAt": null,
  "error": null
}

404 if the workflow doesn't exist or belongs to a different org. 409 if the workflow has no published revision.

For safe retries, include an Idempotency-Key header (see Idempotency).


Studio#

Programmatic access to the interactive media studio: generate one-off images, videos, and audio without authoring a workflow. Generations spend real money through the same reserve→settle billing path as workflow runs.

The studio is feature-flagged per deployment. When it is disabled, every /api/v1/studio/* endpoint returns 404 not_found — before authentication — so a disabled studio is indistinguishable from a missing route. The models endpoints below are NOT gated; the catalog exists independently.

POST /api/v1/studio/generations — Create a generation#

Requires: studio:write scope.

Body fields:

  • modelId — required; a curated model ID or a verified org model's endpoint ID (see GET /api/v1/models)

  • config — required; the FAL request config. Must satisfy the model's constraints from GET /api/v1/models

  • idempotencyKey — required, string of 8-128 chars, one key per submit (covers the whole variant batch). Resubmitting the same key returns the existing row(s) with 200 — a network retry can never double-spend. Note: unlike the workflow-run endpoint, this key lives in the BODY, not in an Idempotency-Key header

  • campaignId — optional; attach the generation(s) to a campaign scope

  • variants — optional integer 1-4; 2-4 creates a compare batch of rows sharing a batchId (acceptance is all-or-nothing for spend gating)

BASH
curl -X POST https://zebrafish.dev/api/v1/studio/generations \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "modelId": "openai/gpt-image-2",
    "config": { "model": "openai/gpt-image-2", "input": { "prompt": "A lighthouse at dawn", "num_images": 1 } },
    "idempotencyKey": "studio-submit-9f8e7d6c"
  }'

Single submit returns 201 with the generation row (200 on an idempotent replay). variants 2-4 returns the batch shape instead:

JSON
{
  "batchId": "batch_abc",
  "items": [ { "id": "sg_1", "status": "queued", "...": "..." } ]
}

Error kinds mirror the studio UI: not_found (unknown model, 404), validation_error (config fails the model's constraints, 422), spend_cap_exceeded / insufficient_credits (402), usage_gate_unavailable (503), dispatch_failed (502 — the row exists and is marked failed; rotate the idempotencyKey to retry).

POST /api/v1/studio/edits — Create a deterministic edit#

Requires: studio:write scope.

P1 supports deterministic local image edits (crop, adjust) over Library item ids, org media ids, or finalized Zebrafish R2 URLs. executionKind is inferred server-side; local edits return Studio generation rows with costUsdMicros: "0".

BASH
curl -X POST https://zebrafish.dev/api/v1/studio/edits \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "editRequest": {
      "version": 1,
      "inputs": { "source": { "kind": "libraryItem", "id": "lib_123" } },
      "op": {
        "version": 1,
        "kind": "local",
        "media": "image",
        "op": "crop",
        "params": { "left": 0, "top": 0, "width": 1080, "height": 1080 }
      }
    },
    "idempotencyKey": "studio-edit-9f8e7d6c"
  }'

Returns 201 with the Studio generation row (200 on an idempotent replay). Poll GET /api/v1/studio/generations/:id until status is terminal.

GET /api/v1/studio/generations/:id — Poll a generation#

Requires: studio:read scope.

Generations are async (video can take 10+ minutes). Poll this endpoint until status is terminal: succeeded, failed, or timed_out.

BASH
curl https://zebrafish.dev/api/v1/studio/generations/sg_1 \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response (succeeded):

JSON
{
  "id": "sg_1",
  "modelId": "openai/gpt-image-2",
  "category": "image",
  "status": "succeeded",
  "saveStatus": "saved",
  "outputUrl": "https://r2.example.com/studio/....png",
  "durableOutputUrl": "https://r2.example.com/studio/....png",
  "libraryItemId": "li_abc123",
  "costUsdMicros": "40000",
  "createdAt": "2026-06-12T10:00:00.000Z",
  "completedAt": "2026-06-12T10:05:00.000Z",
  "outputExpiresAt": null
}

outputUrl resolves to the durable R2 URL when rehosting succeeded (durableOutputUrl is set and outputExpiresAt is null). If rehosting is still in progress (saveStatus: "saving") or failed (save_failed), outputUrl falls back to the ephemeral FAL URL and outputExpiresAt marks when it expires (~7 days). libraryItemId links the generation to the org's Library once saved. 404 is terminal for pollers (pruned, never existed, or another org's row).

GET /api/v1/studio/generations — List generations#

Requires: studio:read scope.

Query params:

  • limit — integer, default 50, max 100

  • cursor — pass the previous page's nextCursor

  • campaignId — filter to one campaign's generations

Returns { "items": [...], "nextCursor": "..." | null }, newest first.


Models#

The model catalog behind the studio: curated profiles maintained by Zebrafish plus your org's own verified models.

GET /api/v1/models — List available models#

Requires: models:read scope.

BASH
curl https://zebrafish.dev/api/v1/models \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "data": [
    {
      "source": "curated",
      "modelId": "openai/gpt-image-2",
      "label": "GPT Image 2",
      "category": "text-to-image",
      "billingUnit": "images",
      "constraints": [ { "key": "prompt", "type": "string", "required": true } ],
      "pricing": {
        "source": "registry",
        "unit": "images",
        "pricePerUnitUsdMicros": "190000",
        "priceVersion": "2026-04",
        "note": "Registry price estimate. Actual billed cost is reconciled after generation."
      }
    }
  ]
}

pricing.source is metered_by_fal (with null pricePerUnitUsdMicros) when the price registry doesn't know the model — the generation is billed at FAL's metered rate and reconciled after completion. Never assume $0.

POST /api/v1/models — Register an org model#

Requires: models:write scope.

Adds a FAL endpoint to your org's catalog via the derived-profile flow: the endpoint's live OpenAPI schema is inspected and a typed profile is derived and saved, unverified and disabled. The model does NOT appear in GET /api/v1/models (and cannot be generated against) until an org admin runs the verify flow in the app's settings.

Body fields:

  • endpointId — required; the FAL endpoint ID to register

  • billingUnit — optional override (images, seconds, characters, requests) for models whose FAL docs disagree with the category default

BASH
curl -X POST https://zebrafish.dev/api/v1/models \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "endpointId": "fal-ai/some-new-model" }'

Returns 201 with { "inspection": ..., "model": ... } — the derived profile draft (plus warnings and a pricing estimate) and the persisted org model row.

Curated model IDs are rejected with 409 curated_model_collision — the curated catalog is read-only via the API. A model already registered for your org returns 409 model_already_registered. An unknown FAL endpoint returns 404; a FAL inspection failure returns 502 model_inspect_failed.


Agent credit purchases#

Autonomous agents buy prepaid credits with billing:purchase_credits. The surface is feature-flagged (AGENT_PAYMENTS_ENABLED); when disabled, routes return 404 not_found before authentication.

Credits are never written to the ledger until the payment provider confirms settlement. Poll GET /api/v1/billing/credits/purchases/:id after requires_action or pending_settlement until status is confirmed, failed, or expired.

POST /api/v1/billing/credits/purchase — Create a credit purchase#

Requires: billing:purchase_credits scope and an API key credential.

Idempotency-Key header is required (400 if missing or too long).

Request body:

FieldTypeRequiredDescription
creditsintegerYesCredits to buy. 1–10000. 1 credit = $1 USD.
railstringYes"fake" (dev/CI only) or "stripe_checkout".
payerCredentialIdstringNoOpaque payer reference (max 200 chars). Stored as a server-secret HMAC fingerprint only.
targetOrgIdstringNoMust equal the key's org if present; else 403 cross_org_denied.
agentContextobjectNoOptional { agentId?, taskId?, reason? } (each max 500 chars).
BASH
curl -X POST https://zebrafish.dev/api/v1/billing/credits/purchase \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Idempotency-Key: agent-purchase-$(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "credits": 25,
    "rail": "stripe_checkout",
    "agentContext": {
      "agentId": "ci-bot",
      "reason": "Top up before studio batch"
    }
  }'

Fresh purchase (201 Created):

JSON
{
  "purchase": {
    "id": "cp_550e8400-e29b-41d4-a716-446655440000",
    "status": "requires_action",
    "credits": 25,
    "amountUsd": "25.00",
    "rail": "stripe_checkout",
    "action": {
      "type": "handoff_url",
      "url": "https://checkout.stripe.com/c/pay/cs_test_..."
    },
    "receiptUrl": null,
    "failureReason": null,
    "createdAt": "2026-07-08T12:00:00.000Z",
    "confirmedAt": null
  },
  "idempotent": false
}

fake rail in dev/CI returns status: "pending_settlement" and action: null. Settle it with POST /api/webhooks/agent-payments/fake (bearer AGENT_PAYMENTS_FAKE_WEBHOOK_SECRET).

Idempotent replay (same org, API key, Idempotency-Key, and request hash): 200 with "idempotent": true and the current purchase state.

Statuserror.typeWhen
403limit_exceededPer-purchase, rolling daily, or monthly cap exceeded, rail not allowed, or limits expired
403cross_org_deniedtargetOrgId differs from the key's org
403forbiddenCredential is not an API key
400invalid_requestMissing/invalid Idempotency-Key or malformed JSON
409idempotency_conflictSame Idempotency-Key with a different request body
422validation_errorParsed JSON body failed schema validation, or credits/rail are invalid
502provider_errorPayment provider failed after the purchase row was created

403 limit_exceeded includes remaining budgets:

JSON
{
  "error": {
    "type": "limit_exceeded",
    "message": "Agent payment exceeds the rolling daily limit.",
    "requestId": "req_01HV8XYZABC",
    "details": {
      "maxPurchaseUsd": "100.00",
      "remainingDailyUsd": "12.00",
      "remainingMonthlyUsd": "450.00",
      "reason": "daily_limit_exceeded"
    }
  }
}

reason is one of: limits_expired, rail_not_allowed, max_purchase_exceeded, daily_limit_exceeded, monthly_limit_exceeded.


GET /api/v1/billing/credits/purchases/:id — Get a credit purchase#

Requires: billing:purchase_credits scope.

Poll after purchase_credits when status is requires_action or pending_settlement.

BASH
curl https://zebrafish.dev/api/v1/billing/credits/purchases/cp_550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Response:

JSON
{
  "purchase": {
    "id": "cp_550e8400-e29b-41d4-a716-446655440000",
    "status": "confirmed",
    "credits": 25,
    "amountUsd": "25.00",
    "rail": "stripe_checkout",
    "action": null,
    "receiptUrl": null,
    "failureReason": null,
    "createdAt": "2026-07-08T12:00:00.000Z",
    "confirmedAt": "2026-07-08T12:01:30.000Z"
  }
}

404 if the purchase does not exist, belongs to another org, or agent payments are disabled for this deployment.


Cost & usage#

Track spend per run and aggregate spend by day/provider via two endpoints.

GET /api/v1/runs/:id/costs — Per-run cost detail#

Requires: usage:read scope (or admin).

Returns per-step cost breakdown for a single run. Returns 404 for both not-found and cross-org lookups to prevent run-ID enumeration.

BASH
curl https://zebrafish.dev/api/v1/runs/run_xyz789/costs \
  -H "Authorization: Bearer $ADFISH_API_KEY"

Node.js example:

TS
const res = await fetch(
  `https://zebrafish.dev/api/v1/runs/${runId}/costs`,
  {
    headers: { Authorization: `Bearer ${process.env.ADFISH_API_KEY}` },
  }
);
const costs = await res.json();

Response:

JSON
{
  "runId": "run_xyz789",
  "workflowId": "wf_abc123",
  "totalProviderCostUsdMicros": "1500000",
  "totalProviderCostUsd": "1.500000",
  "currency": "USD",
  "rollupComplete": true,
  "steps": [
    {
      "stepId": "step_001",
      "provider": "anthropic",
      "model": "claude-haiku-4-5",
      "providerCostUsdMicros": "1500000",
      "providerCostUsd": "1.500000",
      "inputTokens": 1200,
      "outputTokens": 350,
      "cacheReadTokens": null,
      "cacheWriteTokens": null,
      "quantity": null,
      "unit": "tokens",
      "chargeStatus": "charged",
      "durationMs": 1840,
      "createdAt": "2026-04-17T12:01:00.000Z"
    }
  ]
}

Field notes:

  • totalProviderCostUsdMicros / totalProviderCostUsd — serialized as strings (not integers) to avoid JSON integer overflow on large values. 1 USD = 1,000,000 micros.

  • rollupComplete: false — at least one step's cost is still being computed (e.g. late provider webhook or unknown-model registry miss). Poll with backoff if you need the final figure; reconciliation runs every 24 hours.

  • warning — present when cost data is partial or entirely unavailable. Describes the gap (e.g. registry miss count, all-null scenario).

  • providerCostUsdMicros: null — the model was not found in the price registry at the time of the call. This is a transient condition for new models; registry updates are deployed and reconciliation fills the gaps.

  • chargeStatus — disposition: charged (billable), free_tier, pending (awaiting provider confirmation), waived (manually waived).

  • cacheReadTokens / cacheWriteTokens — non-null only for Anthropic calls that use prompt caching. Costs are already incorporated into providerCostUsdMicros at the provider-discounted rate.


GET /api/v1/usage — Aggregate usage by day and provider#

Requires: admin scope.

Returns daily cost aggregates grouped by provider. Useful for billing dashboards and trend analysis. Results are paginated (default 90 rows/page, max 366).

BASH
curl "https://zebrafish.dev/api/v1/usage?from=2026-04-01&to=2026-04-30" \
  -H "Authorization: Bearer $ADFISH_ADMIN_KEY"

Node.js example:

TS
const params = new URLSearchParams({
  from: "2026-04-01",
  to: "2026-04-30",
});
const res = await fetch(
  `https://zebrafish.dev/api/v1/usage?${params}`,
  {
    headers: { Authorization: `Bearer ${process.env.ADFISH_ADMIN_KEY}` },
  }
);
const { data, pagination, meta } = await res.json();

Query params:

ParamTypeRequiredDescription
fromYYYY-MM-DDYesStart date (inclusive)
toYYYY-MM-DDYesEnd date (inclusive). Must be after from; max range 366 days.
providerstringNoFilter to one provider: fal, anthropic, openai, google
limitintegerNoRows per page, default 90, max 366
afterstringNoPagination cursor from previous pagination.after

Response:

JSON
{
  "data": [
    {
      "date": "2026-04-17",
      "provider": "anthropic",
      "providerCostUsdMicros": "1500000",
      "providerCostUsd": "1.500000",
      "currency": "USD",
      "stepCount": 42,
      "rollupComplete": true
    }
  ],
  "pagination": {
    "limit": 90,
    "after": null,
    "hasMore": false
  },
  "meta": {
    "from": "2026-04-01",
    "to": "2026-04-30",
    "queryTime": "2026-04-17T12:00:00.000Z"
  }
}

Field notes:

  • Rows are ordered date DESC, provider ASC.

  • rollupComplete: false on a row means at least one step_costs row in that day/provider bucket had a registry miss. The providerCostUsdMicros value is still the sum of priced steps; it will increase after reconciliation runs.

  • The meta.queryTime reflects server time at query execution — useful for cache-expiry calculations.


Spend cap#

Admins can configure a monthly spend cap to prevent runaway billing. All spend-cap endpoints require admin scope.

GET /api/v1/orgs/me — Org identity and spend cap#

Requires: admin scope.

Returns your org's identity, current spend cap configuration, month-to-date (MTD) settled spend, and active reservation totals.

The org is determined from your API key credential — there is no ?orgId query param. Cross-org lookup is not supported.

BASH
curl https://zebrafish.dev/api/v1/orgs/me \
  -H "Authorization: Bearer $ADFISH_ADMIN_KEY"

Node.js example:

TS
const res = await fetch("https://zebrafish.dev/api/v1/orgs/me", {
  headers: { Authorization: `Bearer ${process.env.ADFISH_ADMIN_KEY}` },
});
const org = await res.json();

Response:

JSON
{
  "orgId": "org_abc123",
  "name": "Acme Corp",
  "slug": "acme-corp",
  "plan": "pro",
  "spendCap": {
    "monthlySpendCapUsdMicros": "100000000",
    "monthlySpendCapUsd": "100.000000",
    "warnPct": 80,
    "warnedAt": null
  },
  "monthToDate": {
    "spendUsdMicros": "45000000",
    "spendUsd": "45.000000",
    "reservedUsdMicros": "5000000",
    "reservedUsd": "5.000000",
    "percentUsed": 50
  },
  "currency": "USD"
}

Field notes:

  • spendCap.monthlySpendCapUsdMicros — cap in USD micros as a string (1 USD = 1,000,000 micros). null when uncapped.

  • spendCap.warnPct — warning threshold (0-100). An alert fires when (spend + reserved) / cap >= warnPct/100.

  • spendCap.warnedAt — ISO 8601 timestamp of the last warning, or null if no warning has fired.

  • monthToDate.spendUsdMicros — settled spend since the first of the current calendar month (UTC).

  • monthToDate.reservedUsdMicros — sum of held in-flight reservations (active, settlement_pending, cancelling); not yet settled.

  • monthToDate.percentUsed(spend + reserved) / cap * 100. null when uncapped.

  • All micros values are serialized as decimal strings to avoid JSON integer overflow.

Drill-down: ?expand=reservations#

Pass ?expand=reservations to include the held reservation list (up to 50 most recent entries). Useful for debugging stuck or over-committed runs.

BASH
curl "https://zebrafish.dev/api/v1/orgs/me?expand=reservations" \
  -H "Authorization: Bearer $ADFISH_ADMIN_KEY"

Additional field in response:

JSON
{
  "activeReservations": [
    {
      "runId": "run_xyz789",
      "state": "settlement_pending",
      "reservedUsdMicros": "5000000",
      "reservedUsd": "5.000000",
      "reservedAt": "2026-04-17T10:00:00.000Z",
      "expiresAt": "2026-04-17T11:00:00.000Z"
    }
  ]
}

Active reservations expire after 60 minutes and are released automatically; settlement_pending and cancelling reservations stay held until settlement or release completes.


PATCH /api/v1/orgs/me — Update spend cap#

Requires: admin scope.

Updates the monthly spend cap and/or warning threshold. All fields are optional — omit any field to leave it unchanged.

BASH
curl -X PATCH https://zebrafish.dev/api/v1/orgs/me \
  -H "Authorization: Bearer $ADFISH_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monthlySpendCapUsdMicros": "100000000", "monthlySpendCapWarnPct": 80}'

Node.js example:

TS
const res = await fetch("https://zebrafish.dev/api/v1/orgs/me", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.ADFISH_ADMIN_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    monthlySpendCapUsdMicros: "100000000", // $100.00
    monthlySpendCapWarnPct: 80,
  }),
});
const org = await res.json();

Request body:

FieldTypeDescription
monthlySpendCapUsdMicrosstring | nullCap in USD micros as a decimal string. Pass null to remove the cap entirely. Must be a positive integer string.
monthlySpendCapWarnPctintegerWarning threshold 0-100. Fires when (spend + reserved) / cap >= this percentage.

Returns the updated GET shape on success.

To remove the cap entirely:

BASH
curl -X PATCH https://zebrafish.dev/api/v1/orgs/me \
  -H "Authorization: Bearer $ADFISH_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monthlySpendCapUsdMicros": null}'

422 if monthlySpendCapUsdMicros is not a valid integer string or is ≤ 0. 422 if monthlySpendCapWarnPct is not an integer between 0 and 100.


402 insufficient_credits#

When a run needs more prepaid usage balance than the org has available, POST /api/v1/workflows/:id/runs returns 402 Payment Required and does not create the run.

JSON
{
  "error": {
    "type": "insufficient_credits",
    "message": "Insufficient prepaid usage balance.",
    "requestId": "req_01HV8XYZABC",
    "docsUrl": "https://zebrafish.dev/docs/api-keys#prepaid-usage-balance",
    "details": {
      "requiredUsdMicros": "1250000",
      "availableUsdMicros": "500000"
    }
  }
}

Add balance from Settings → Spend & Billing, then retry with a new Idempotency-Key.


402 spend_cap_exceeded#

When a run is triggered and the org's monthly spend cap would be exceeded, POST /api/v1/workflows/:id/runs returns 402 Payment Required:

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/wf_abc123/runs \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inputs": {"brand": "Acme", "brief": "Spring launch"}}'
# → HTTP 402

Response:

JSON
{
  "error": {
    "type": "spend_cap_exceeded",
    "message": "Monthly spend cap exceeded. This run was not created.",
    "requestId": "req_01HV8XYZABC",
    "docsUrl": "https://zebrafish.dev/docs/api-keys#spend-cap",
    "details": {
      "orgId": "org_abc123",
      "monthlySpendCapUsdMicros": "100000000",
      "currentSpendUsdMicros": "95000000",
      "reservedUsdMicros": "8000000",
      "projectedUsdMicros": "103000000"
    }
  }
}

To handle a 402 in your integration:

TS
const res = await fetch(
  `https://zebrafish.dev/api/v1/workflows/${workflowId}/runs`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ADFISH_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ inputs }),
  }
);

if (res.status === 402) {
  const { error } = await res.json();
  // error.type === "spend_cap_exceeded"
  // error.details.projectedUsdMicros — what the projected spend would be
  // error.details.monthlySpendCapUsdMicros — the current cap
  console.error("Spend cap exceeded:", error.message);
  // Raise the cap via PATCH /api/v1/orgs/me, or wait for next billing cycle
  return;
}

To resolve a 402: either raise the cap via PATCH /api/v1/orgs/me, or wait for the current billing cycle to reset (first of the next calendar month, UTC).


Idempotency#

Retries on POST /api/v1/workflows/:id/runs are safe via the Idempotency-Key header:

BASH
curl -X POST https://zebrafish.dev/api/v1/workflows/wf_abc123/runs \
  -H "Authorization: Bearer $ADFISH_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"inputs": {"brand": "Acme", "brief": "Spring launch"}}'

Within 48 hours, repeating the same Idempotency-Key returns the original run rather than dispatching a new one. After 48 hours the key expires and a new run is created.

For public authoring and operations writes, Idempotency-Key is strongly recommended on all POST/PATCH/PUT endpoints and required on:

  • POST /api/v1/workflows/from-template

  • POST /api/v1/workflows/:id/draft-revisions

  • POST /api/v1/workflows/:id/draft-test-runs

  • POST /api/v1/workflows/:id/revisions/:revisionId/publish

  • campaign write endpoints

  • campaign asset presign/finalize endpoints

  • run cancel and approval-response endpoints

  • POST /api/v1/billing/credits/purchase

When the header is omitted on endpoints where it is not required (e.g. POST /api/v1/workflows/:id/runs), the server generates an idempotency key server-side. In that case retries are not deduplicated — each request that reaches the server creates a new operation. Send the header explicitly for safe retries.

Reuse a key only for retries of the exact same payload. Reusing the same key with a different payload returns 409 idempotency_conflict; issue a new key for each changed save or test attempt.

Most public mutation idempotency slots replay for 48 hours. Campaign asset presign slots are shorter because the returned upload URL itself expires after 15 minutes.

Use a stable, deterministic key when triggering from CI:

BASH
# GitHub Actions: tie the key to the run attempt so a re-run creates a
# new dispatch, but a network-retry does not.
Idempotency-Key: gha-${{ github.run_id }}-${{ github.run_attempt }}

Rate limits#

ScopePer keyPer org (aggregate)
All100 req/min1000 req/min
POST /api/v1/workflows/:id/draft-test-runs30 req/min300 req/min

The per-org limit applies across every API key in the org regardless of scope. An org with ten keys still shares one 1000 req/min ceiling — minting additional keys does not increase the org-level quota.

The per-IP pre-auth throttle is 100 req/min by default and 60 req/min for draft test-runs. When throttled, the response is 429 Too Many Requests and includes a Retry-After header (seconds until the window resets).

X-RateLimit headers#

Every successful authenticated response carries three headers reporting the per-key rate-limit state so polling clients can self-throttle before hitting a 429:

HeaderTypeDescription
X-RateLimit-LimitintegerPer-key ceiling for the current window (requests/min).
X-RateLimit-RemainingintegerRequests remaining in the current window for this key.
X-RateLimit-ResetintegerUnix timestamp (seconds) when the current window resets.

On 429 responses, the same headers reflect the limit that was exceeded (IP, per-org, or per-key). A Retry-After header is also present with the seconds to wait.

Errors#

All errors use this envelope:

JSON
{
  "error": {
    "type": "authentication_error",
    "message": "API key is invalid or has been revoked.",
    "requestId": "req_01HV8XYZABC",
    "docsUrl": "https://zebrafish.dev/docs/api-keys#errors"
  }
}

<a id="workflow-state"></a>

Workflow state#

Run-trigger routes fail before creating a run when the workflow is not runnable:

  • 409 conflict: the workflow has no published revision. Publish a revision before triggering /workflows/:id/runs.

  • 422 validation_error with details.code: "empty_workflow": the resolved published revision or saved draft has no steps. Add at least one step, then save or publish before triggering a run.

Statuserror.typeWhenFix
401authentication_errorMissing, malformed, revoked, or expired keyCheck the key in Settings → API Keys
403insufficient_scopeKey lacks the scope required by this routeRecreate the key with the needed scope
403limit_exceededAgent credit purchase exceeds per-key spend limitsLower amount or wait for rolling window reset; raise limits via agent_payment_limits
403cross_org_deniedtargetOrgId on a credit purchase differs from the key orgOmit targetOrgId or match the key's org
403forbiddenCredit purchase attempted with a non-API-key credentialUse an API key with billing:purchase_credits
400invalid_requestMalformed JSON, ambiguous credentials, or missing/invalid Idempotency-KeyFix the request and retry
404not_foundResource doesn't exist or belongs to another orgCheck the id
409conflictWorkflow has no published revisionPublish a revision before triggering runs
409idempotency_conflictIdempotency-Key was reused with a different public write payloadRetry with a new idempotency key
409stale_revisionbasedOnRevisionId is not the latest workflow revisionRead authoring context, merge, and retry with the latest revision id
409curated_model_collisionPOST /models with a curated model IDCurated models are read-only via the API — use them directly
409model_already_registeredPOST /models with a model your org already addedUse the existing org model
422validation_errorParsed JSON failed schema, workflow validation, or an empty workflow run requestCheck error.details for field-level codes
429rate_limit_exceededPer-IP, per-key, or per-org limit hitWait for Retry-After seconds
502model_inspect_failedFAL's catalog/schema API errored while inspecting a modelRetry; check the endpoint ID
502dispatch_failedA studio generation row was created but worker dispatch failedRotate the idempotencyKey and retry
502provider_errorAgent credit purchase provider failed after the purchase row was createdCheck provider configuration, then retry POST with the same Idempotency-Key; polling alone cannot advance pending_provider
503service_unavailableTemporary auth-backend outageRetry in 30 seconds
503usage_gate_unavailablePrepaid-balance check temporarily unavailableRetry shortly

Auth errors#

401 authentication_error — the key is missing, malformed, revoked, or expired. Check the key in Settings → API Keys. Re-generate if it was revoked.

403 insufficient_scope — the key exists but lacks the scope required by this route. The docsUrl in the error envelope points to the relevant scope in the Scopes table above. Recreate the key with the needed scope.

Not found#

404 not_found — the resource does not exist, has been deleted, or belongs to a different org. Zebrafish returns 404 (not 403) for cross-org lookups to prevent resource-id enumeration.

Validation errors#

422 validation_error — the request body parsed correctly but failed schema or workflow validation. Check error.details for field-level codes. Common codes:

  • name_required, name_too_long, name_whitespace — workflow/campaign name constraint failures

  • invalid_workflow — workflow definition failed validation; see error.details.diagnostics for step-level repair hints

  • unknown_step_type — a step type not in the authoring guide step catalog

Workflow state#

Workflow-state errors signal that an operation is not possible given the current workflow or revision state:

  • 409 stale_revision — the basedOnRevisionId you supplied is not the current latest revision. Read the authoring context, merge against the latest revision, and retry.

  • 409 conflict (type conflict, code no_published_revision) — the workflow has no published revision. Publish a revision before triggering production runs.

Stale revisions#

409 stale_revision is returned when an optimistic-concurrency check fails during a save or publish operation:

  • Save (POST /api/v1/workflows/:id/draft-revisions) — the supplied basedOnRevisionId is not the current latest revision. Another actor saved a newer revision after you read the authoring context.

  • Publish (POST /api/v1/workflows/:id/revisions/:revisionId/publish) — the supplied expectedLatestRevisionId does not match the current latest revision, or the revision selected for publish is no longer the latest.

Remedy: re-fetch the authoring context (GET /api/v1/workflows/:id/authoring-context), rebase your changes against the latest revision, and retry the operation with the new revision id. Use a fresh Idempotency-Key for the retry.

JSON
{
  "error": {
    "type": "stale_revision",
    "message": "Workflow was updated after the revision selected for publish.",
    "requestId": "req_01HV8XYZABC",
    "docsUrl": "https://zebrafish.dev/docs/api-keys#stale-revisions"
  }
}

Prepaid usage balance#

402 insufficient_credits — the org does not have enough prepaid usage balance to cover the estimated run cost. Add credits at Settings → Spend & Billing, then retry with a new Idempotency-Key. See the 402 sections below for full error shapes.

X-Request-Id#

Every response carries X-Request-Id: req_.... For error responses, the same value also appears as error.requestId in the JSON body — either location is canonical. Include it in bug reports so we can trace the request in our audit log.

Rotation#

v1 does not support grace-period rotation. To rotate without downtime:

  1. Create a new key with the same scope.

  2. Update your environment variable or CI secret to the new key.

  3. Deploy and verify the new key works.

  4. Return to Settings → API Keys and revoke the old key.

Grace-period rotation (both keys valid during transition) is planned for v2.

Environments#

v1 keys are production keys. There is no test-mode equivalent. For local development or CI testing, use Vercel preview environments — each PR gets its own ephemeral Neon branch and Clerk preview, and keys created there are isolated from production data.

A test-mode key tier is planned for v2.

Versioning and deprecation#

  • /api/v1 is stable. Changes within v1 are additive only — new fields, new query params, new endpoints.

  • Breaking changes (field removal, response shape changes, auth changes) go into /api/v2 at a new path.

  • v1 is supported for a minimum of 12 months after any v2 launch.

  • Deprecations are announced via Deprecation and Sunset response headers (RFC 8594) and on the GitHub Releases page when a tagged release ships.

Common integrations#

GitHub Actions#

YAML
- name: Trigger Zebrafish workflow
  env:
    ADFISH_API_KEY: ${{ secrets.ADFISH_API_KEY }}
  run: |
    curl -X POST https://zebrafish.dev/api/v1/workflows/${{ vars.WORKFLOW_ID }}/runs \
      -H "Authorization: Bearer $ADFISH_API_KEY" \
      -H "Idempotency-Key: gha-${{ github.run_id }}-${{ github.run_attempt }}" \
      -H "Content-Type: application/json" \
      -d '{"inputs": {"brand": "Acme", "brief": "Spring launch"}}'

Add ADFISH_API_KEY as a repository secret (Settings → Secrets and variables → Actions) and WORKFLOW_ID as a repository variable.

Claude Code / Cursor / Codex#

Export the key in your shell profile. Your AI agent can then call the API directly:

BASH
# ~/.zshrc or ~/.bashrc
export ADFISH_API_KEY=adf_...

Ask Claude Code, Cursor, or Codex to trigger a workflow run via curl — the agent will use $ADFISH_API_KEY from your environment automatically.

Node.js / fetch#

TS
const res = await fetch("https://zebrafish.dev/api/v1/workflows", {
  headers: { Authorization: `Bearer ${process.env.ADFISH_API_KEY}` },
});
const { data, pagination } = await res.json();

Trigger a run with idempotency:

TS
import { randomUUID } from "crypto";

const res = await fetch(
  `https://zebrafish.dev/api/v1/workflows/${workflowId}/runs`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ADFISH_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(),
    },
    body: JSON.stringify({ inputs: { brand: "Acme", brief: "Spring launch" } }),
  }
);
const { id: runId } = await res.json();

Bruno collection#

The bruno/adfish/v1/ directory in this repo contains pre-configured request files for all seven v1 routes. Import the bruno/adfish/ directory into the Bruno API client and select the v1 sub-collection. See bruno/adfish/v1/README.md for environment setup.