openapi: 3.1.0
info:
  title: Sunor API
  version: 1.0.0
  summary: Pay-as-you-go REST API for AI music generation powered by Suno and Udio.
  description: |
    Sunor is a developer-first REST API for AI music and audio generation.
    Access Suno and Udio models without separate upstream accounts.
    Pay-as-you-go credits, instant API keys, simple REST endpoints.

    **Base URL:** `https://sunor.cc`

    **Authentication:** all endpoints require an API key in the `x-api-key` header.
    Obtain one from the dashboard at https://sunor.cc/dashboard.

    **Credits:** 1 credit = $0.01 USD. Suno music = 10 credits, Udio music = 5 credits,
    Suno lyrics = 5 credits, upload = 1 credit, concat = 5 credits. Failed tasks are automatically refunded.
  contact:
    name: Sunor Support
    email: support@sunor.cc
    url: https://sunor.cc
  license:
    name: Terms of Service
    url: https://sunor.cc/terms

servers:
  - url: https://sunor.cc
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Tasks
    description: Submit and retrieve music, lyrics, upload, and concat tasks.
  - name: Account
    description: API key owner's credit balance and usage statistics.

paths:
  /api/v1/task:
    post:
      operationId: createTask
      summary: Create a task
      description: |
        Submit a music, lyrics, upload, or concat task. Returns immediately with
        a `task_id`; poll `GET /api/v1/task/{taskId}` for completion.
        Credits are frozen at submission and either settled on success or refunded on failure.
        Input is validated before any credits, task record, or upstream-provider
        side effect. Task creation does not currently support idempotency keys;
        treat an uncertain network or `5xx` outcome as unknown rather than
        automatically submitting the same payload again.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskCreateRequest'
            examples:
              music:
                summary: Music generation (custom mode)
                value:
                  model: suno
                  task_type: music
                  input:
                    prompt: "[Verse]\nWalking down the street tonight...\n[Chorus]\nWe're alive!"
                    tags: "pop, upbeat"
                    title: "Alive Tonight"
                    make_instrumental: false
              music_inspiration:
                summary: Music generation (inspiration mode)
                value:
                  model: suno
                  task_type: music
                  input:
                    gpt_description_prompt: "uplifting pop song about summer"
                    make_instrumental: false
              lyrics:
                summary: Lyrics only
                value:
                  model: suno
                  task_type: lyrics
                  input:
                    prompt: "a song about late-night coding sessions"
              upload:
                summary: Upload audio by URL
                value:
                  model: suno
                  task_type: upload
                  input:
                    url: "https://example.com/my-audio.mp3"
              concat:
                summary: Continue an existing clip
                value:
                  model: suno
                  task_type: concat
                  input:
                    clip_id: "abc-123-clip-id-from-prior-task"
      responses:
        '202':
          description: Task accepted, pending processing
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskSubmitResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /api/v1/task/{taskId}:
    get:
      operationId: getTask
      summary: Get task status and result
      description: |
        Retrieve a task by id. Poll this endpoint until `status` is one of
        `success`, `failure`, or `timeout`. Tasks older than 1 hour in `pending`
        or `running` state automatically transition to `timeout` with a refund.
      tags: [Tasks]
      parameters:
        - in: path
          name: taskId
          required: true
          schema:
            type: string
            format: uuid
          description: Task ID returned by `POST /api/v1/task`.
      responses:
        '200':
          description: Task details
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/account/balance:
    get:
      operationId: getBalance
      summary: Get credit balance
      description: Returns the API key owner's current balance. `frozen` is credits held for in-flight tasks.
      tags: [Account]
      responses:
        '200':
          description: Balance snapshot
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/account/usage:
    get:
      operationId: getUsage
      summary: Get usage statistics
      description: Lifetime task count and credit totals for the API key owner.
      tags: [Account]
      responses:
        '200':
          description: Usage statistics
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestId'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: API key issued in the dashboard. Format `sk_live_...`.

  headers:
    RequestId:
      description: Correlation identifier for this request. Supply a safe `X-Request-Id` value to propagate your own identifier, or let Sunor generate one.
      schema:
        type: string
        minLength: 1
        maxLength: 128
      example: req_agent_run_01JZ6Y3A7QH8N5M2K4R9T1V0WX
    RateLimitRemaining:
      description: Requests remaining in the API key's current rate-limit window. Early authentication failures may omit this header.
      schema:
        type: integer
        minimum: 0
      example: 119

  schemas:
    Error:
      type: object
      required: [code, error_code, message]
      properties:
        code:
          type: integer
          description: HTTP status code, echoed in the body for convenience.
          example: 400
        message:
          type: string
          description: Human-readable error message.
          example: "Missing required fields: model, task_type, input"
        error_code:
          type: string
          description: Stable machine-readable classification. Branch on this field instead of parsing `message`.
          enum:
            - invalid_json
            - invalid_request_body
            - missing_required_fields
            - invalid_model
            - unsupported_task_type
            - invalid_task_input
            - invalid_audio_format
            - audio_format_unavailable
            - missing_api_key
            - invalid_api_key
            - insufficient_credits
            - account_suspended
            - rate_limited
            - task_not_found
            - upstream_provider_error
            - internal_error
          example: invalid_task_input
        retry_after_seconds:
          type: integer
          minimum: 0
          description: Seconds to wait before retrying a rate-limited request. Present on 429 responses.

    TaskCreateRequest:
      type: object
      description: |
        Model/task-specific known fields are validated before any credits, task,
        or provider side effect. Additional root and input properties are
        accepted for backward/forward compatibility but are not interpreted as
        provider controls unless a variant explicitly prohibits them; clients
        should use documented fields only.
      oneOf:
        - $ref: '#/components/schemas/SunoMusicRequest'
        - $ref: '#/components/schemas/SunoLyricsRequest'
        - $ref: '#/components/schemas/SunoUploadRequest'
        - $ref: '#/components/schemas/SunoConcatRequest'
        - $ref: '#/components/schemas/UdioMusicRequest'

    SunoMusicRequest:
      type: object
      required: [model, task_type, input]
      additionalProperties: true
      properties:
        model:
          type: string
          const: suno
        task_type:
          type: string
          const: music
        input:
          oneOf:
            - $ref: '#/components/schemas/SunoMusicInspirationInput'
            - $ref: '#/components/schemas/SunoMusicCustomInput'
            - $ref: '#/components/schemas/SunoMusicContinuationInput'
        audio_format:
          type: string
          enum: [mp3]
          description: >-
            Pin the delivery format. Omit it and `audio_url` points at the
            provider's own file, in whatever format that provider produces —
            which is not fixed and changes when we route your request to a
            different provider. Send `mp3` and `audio_url` points at MP3
            whatever the source was; a source that is already MP3 is delivered
            as-is rather than re-encoded. The task response echoes this value
            back. Any other value is rejected with `invalid_audio_format`.

    SunoLyricsRequest:
      type: object
      required: [model, task_type, input]
      additionalProperties: true
      properties:
        model:
          type: string
          const: suno
        task_type:
          type: string
          const: lyrics
        input:
          $ref: '#/components/schemas/SunoLyricsInput'

    SunoUploadRequest:
      type: object
      required: [model, task_type, input]
      additionalProperties: true
      properties:
        model:
          type: string
          const: suno
        task_type:
          type: string
          const: upload
        input:
          $ref: '#/components/schemas/SunoUploadInput'
        audio_format:
          type: string
          enum: [mp3]
          description: >-
            Pin the delivery format. Omit it and `audio_url` points at the
            provider's own file, in whatever format that provider produces —
            which is not fixed and changes when we route your request to a
            different provider. Send `mp3` and `audio_url` points at MP3
            whatever the source was; a source that is already MP3 is delivered
            as-is rather than re-encoded. The task response echoes this value
            back. Any other value is rejected with `invalid_audio_format`.

    SunoConcatRequest:
      type: object
      required: [model, task_type, input]
      additionalProperties: true
      properties:
        model:
          type: string
          const: suno
        task_type:
          type: string
          const: concat
        input:
          $ref: '#/components/schemas/SunoConcatInput'
        audio_format:
          type: string
          enum: [mp3]
          description: >-
            Pin the delivery format. Omit it and `audio_url` points at the
            provider's own file, in whatever format that provider produces —
            which is not fixed and changes when we route your request to a
            different provider. Send `mp3` and `audio_url` points at MP3
            whatever the source was; a source that is already MP3 is delivered
            as-is rather than re-encoded. The task response echoes this value
            back. Any other value is rejected with `invalid_audio_format`.

    UdioMusicRequest:
      type: object
      required: [model, task_type, input]
      additionalProperties: true
      properties:
        model:
          type: string
          const: udio
        task_type:
          type: string
          const: music
        input:
          $ref: '#/components/schemas/UdioMusicInput'
        audio_format:
          type: string
          enum: [mp3]
          description: >-
            Pin the delivery format. Omit it and `audio_url` points at the
            provider's own file, in whatever format that provider produces —
            which is not fixed and changes when we route your request to a
            different provider. Send `mp3` and `audio_url` points at MP3
            whatever the source was; a source that is already MP3 is delivered
            as-is rather than re-encoded. The task response echoes this value
            back. Any other value is rejected with `invalid_audio_format`.

    SunoMusicInspirationInput:
      type: object
      required: [gpt_description_prompt]
      additionalProperties: true
      not:
        required: [continue_clip_id]
      properties:
        gpt_description_prompt:
          type: string
          minLength: 1
          pattern: '\S'
          description: Natural-language description used for inspiration mode.
        prompt:
          type: string
          description: Accepted for backward compatibility but ignored when `gpt_description_prompt` selects inspiration mode.
        tags:
          type: string
          description: Accepted for backward compatibility but ignored in inspiration mode.
        negative_tags:
          type: string
        title:
          description: Accepted for backward compatibility but ignored in inspiration mode.
        make_instrumental:
          type: boolean
          default: false
        model_version:
          $ref: '#/components/schemas/SunoModelVersion'

    SunoMusicCustomInput:
      type: object
      additionalProperties: true
      anyOf:
        - required: [prompt]
          properties:
            prompt:
              type: string
              pattern: '\S'
        - required: [tags]
          properties:
            tags:
              type: string
              pattern: '\S'
      not:
        anyOf:
          - required: [gpt_description_prompt]
          - required: [continue_clip_id]
      properties:
        prompt:
          type: string
          description: Custom lyrics. May be empty only when non-empty `tags` provide the creative input.
        tags:
          type: string
          description: Comma-separated style tags. May be empty only when non-empty `prompt` is present.
        negative_tags:
          type: string
        title:
          description: Optional title. Non-string JSON values remain accepted for backward compatibility and are converted to strings.
        make_instrumental:
          type: boolean
          default: false
        model_version:
          $ref: '#/components/schemas/SunoModelVersion'

    SunoMusicContinuationInput:
      type: object
      required: [continue_clip_id]
      additionalProperties: true
      properties:
        continue_clip_id:
          type: string
          minLength: 1
          pattern: '\S'
        continue_at:
          type: number
          minimum: 0
          description: Optional timestamp in seconds within the source clip.
        prompt:
          type: string
          description: Optional continuation lyrics.
        title:
          description: Optional title. Non-string JSON values remain accepted for backward compatibility and are converted to strings.
        tags:
          type: string
          description: Accepted for backward compatibility but ignored in continuation mode.
        negative_tags:
          type: string
          description: Accepted for backward compatibility but ignored in continuation mode.
        gpt_description_prompt:
          type: string
          description: Accepted for backward compatibility but ignored in continuation mode.
        make_instrumental:
          type: boolean
          description: Accepted for backward compatibility but ignored in continuation mode.
        model_version:
          $ref: '#/components/schemas/SunoModelVersion'

    SunoModelVersion:
      type: string
      enum: [v6, v5.5, v5]
      default: v6
      description: "Suno model to generate with. `v6` is Suno's current model and the default. Suno retired `v5.5` and `v5` on 2026-09-09; both are still accepted for backward compatibility and generate with V6."

    SunoLyricsInput:
      type: object
      required: [prompt]
      additionalProperties: true
      properties:
        prompt:
          type: string
          minLength: 1
          pattern: '\S'

    SunoUploadInput:
      type: object
      required: [url]
      additionalProperties: true
      properties:
        url:
          type: string
          format: uri
          minLength: 1
          pattern: '^[Hh][Tt][Tt][Pp][Ss]?://'
          description: Publicly accessible HTTP or HTTPS audio URL. Local and private-network addresses are rejected.

    SunoConcatInput:
      type: object
      required: [clip_id]
      additionalProperties: true
      properties:
        clip_id:
          type: string
          minLength: 1
          pattern: '\S'

    UdioMusicInput:
      type: object
      required: [prompt]
      additionalProperties: true
      not:
        anyOf:
          - required: [gpt_description_prompt]
          - required: [make_instrumental]
      properties:
        prompt:
          type: string
          minLength: 1
          pattern: '\S'
        lyrics:
          type: string
        tags:
          type: string
        lyrics_type:
          type: string
          enum: [generate, user, instrumental]
        seed:
          type: integer

    TaskSubmitData:
      type: object
      required: [task_id, type, status, credits_charged, created_at]
      properties:
        task_id:
          type: string
          format: uuid
          description: The task's unique id. Use with `GET /api/v1/task/{taskId}` to poll.
        type:
          type: string
          enum: [music, lyrics, upload, concat]
        status:
          type: string
          enum: [pending]
          description: Initial state is always `pending`.
        credits_charged:
          type: integer
          description: Credits frozen from the balance for this task.
        created_at:
          type: string
          format: date-time
        audio_format:
          type:
            - string
            - "null"
          description: >-
            The delivery format this task was created with — the value of the
            request's `audio_format`, or `null` when it was omitted. `null`
            means `audio_url` points at the provider's own file, whose format
            is not fixed.

    TaskSubmitResponse:
      type: object
      required: [code, data]
      properties:
        code:
          type: integer
          enum: [202]
        data:
          $ref: '#/components/schemas/TaskSubmitData'

    Clip:
      type: object
      description: >-
        One generated clip. The properties below are the contract. Additional
        fields from the upstream model provider are passed through as-is: they
        are not part of the contract, they differ between providers, and they
        may appear or disappear without notice.
      additionalProperties: true
      properties:
        id:
          type: string
          description: Unique clip identifier. Use this for continuation or concat tasks.
        audio_url:
          type: string
          format: uri
          description: >-
            URL to the generated audio. Valid for 7 days after the task
            completes, then 404 permanently. Derive the format from the
            response's Content-Type rather than assuming one.
        image_url:
          type: string
          format: uri
          description: >-
            Cover art. Served by the model provider; not covered by the 7-day
            window.
        title:
          type: string
          description: Generated or provided song title.
        metadata:
          type: object
          additionalProperties: true
          description: >-
            Additional info: duration (seconds), tags, prompt, and other
            provider fields.

    LyricsResult:
      type: object
      additionalProperties: true
      properties:
        title:
          type: string
        text:
          type: string
          description: Generated lyrics with structure tags.

    TaskOutput:
      type: object
      description: >-
        Present on `success`. The wrapper is the same for every task type;
        `result` varies.
      additionalProperties: true
      properties:
        task_type:
          type: string
          enum: [music, lyrics, upload, concat]
        status:
          type: string
        progress:
          type: string
        fail_reason:
          type:
            - string
            - "null"
        result:
          description: >-
            An array of clips for music, upload and concat; an object for
            lyrics. Confirmed against production on 2026-09-05: every upload
            (161 tasks, 2026-04-12 to date) and every concat (3 tasks) stored an
            ARRAY. An earlier revision of this file changed the wording to say
            upload and concat return a single object and added a bare `Clip`
            branch below; both were wrong, and the extra branch made things
            worse — `Clip` and `LyricsResult` are open objects with no
            `required`, so every object matched both and `oneOf` (exactly one)
            rejected lyrics, upload and concat alike. Reverted.
          oneOf:
            - type: array
              items:
                $ref: '#/components/schemas/Clip'
            - $ref: '#/components/schemas/LyricsResult'

    TaskData:
      type: object
      required: [task_id, model, type, status, credits_cost]
      properties:
        task_id:
          type: string
          format: uuid
        model:
          type: string
          enum: [suno, udio]
        type:
          type: string
          enum: [music, lyrics, upload, concat]
        status:
          type: string
          enum: [pending, running, success, failure, timeout]
        credits_cost:
          type: integer
        input:
          type: object
          additionalProperties: true
          description: The `input` payload as originally submitted.
        audio_format:
          type:
            - string
            - "null"
          description: >-
            The delivery format this task was created with — the value of the
            request's `audio_format`, or `null` when it was omitted. `null`
            means `audio_url` points at the provider's own file, whose format
            is not fixed.
        output:
          oneOf:
            - $ref: '#/components/schemas/TaskOutput'
            - type: "null"
          description: Present on `success`.
        error:
          type:
            - string
            - "null"
          description: >-
            Human-readable reason, present on `failure` or `timeout`. Prose:
            branch on `error_code`, not on this string.
        error_code:
          type:
            - string
            - "null"
          enum: [content_moderation, invalid_task_input, rate_limited, upstream_provider_error, null]
          description: >-
            Stable classification of a failed task. Present on `failure` or
            `timeout`, otherwise null.
        retryable:
          type:
            - boolean
            - "null"
          description: >-
            false means resending the same request can never succeed. Present on
            `failure` or `timeout`, otherwise null.
        retry_after_seconds:
          type:
            - integer
            - "null"
          description: >-
            Seconds to wait before retrying, when the provider states a concrete
            wait. Null when it does not — including a spent provider quota,
            whose reset is the provider's own boundary.
        created_at:
          type:
            - string
            - "null"
          format: date-time
        completed_at:
          type:
            - string
            - "null"
          format: date-time

    TaskResponse:
      type: object
      required: [code, data]
      properties:
        code:
          type: integer
          enum: [200]
        data:
          $ref: '#/components/schemas/TaskData'

    BalanceData:
      type: object
      required: [available, frozen, total]
      properties:
        available:
          type: integer
          description: Credits that can be spent on new tasks.
        frozen:
          type: integer
          description: Credits held for in-flight tasks (not yet settled or refunded).
        total:
          type: integer
          description: "available + frozen."

    BalanceResponse:
      type: object
      required: [code, data]
      properties:
        code:
          type: integer
          enum: [200]
        data:
          $ref: '#/components/schemas/BalanceData'

    UsageData:
      type: object
      required: [tasks_created, credits_used, credits_topped_up]
      properties:
        tasks_created:
          type: integer
          description: Lifetime count of tasks created by this account.
        credits_used:
          type: integer
          description: Lifetime sum of `credits_cost` across all tasks (includes failed/refunded).
        credits_topped_up:
          type: integer
          description: Lifetime sum of credit top-ups into this account.

    UsageResponse:
      type: object
      required: [code, data]
      properties:
        code:
          type: integer
          enum: [200]
        data:
          $ref: '#/components/schemas/UsageData'

  responses:
    ValidationError:
      description: Request body missing required fields or contains invalid values.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 400
            error_code: missing_required_fields
            message: "Missing required fields: model, task_type, input"

    Unauthorized:
      description: Missing or invalid API key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 401
            error_code: invalid_api_key
            message: "Invalid API key."

    Forbidden:
      description: The authenticated account is not allowed to create this task.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 403
            error_code: account_suspended
            message: "Account suspended"

    InsufficientCredits:
      description: Balance does not cover this task's credit cost.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 402
            error_code: insufficient_credits
            message: "Insufficient credits."

    NotFound:
      description: Task not found, or the task doesn't belong to the authenticated account.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 404
            error_code: task_not_found
            message: "Task not found"

    RateLimited:
      description: Rate limit exceeded. Default quota is 120 requests per 60 seconds per API key.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 429
            error_code: rate_limited
            message: "Rate limit exceeded."
            retry_after_seconds: 60

    InternalError:
      description: Unexpected server error. Retry only when the operation is known to be safe; task creation may have an ambiguous outcome until idempotency support is available.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 500
            error_code: internal_error
            message: "Internal server error"

    UpstreamError:
      description: Upstream AI provider returned an error. For task creation, do not blindly retry an ambiguous 502 without an idempotency key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestId'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 502
            error_code: upstream_provider_error
            message: "Upstream provider error"
