---
title: "Create assets"
method: POST
path: "/v2/assets/"
tags: ["Assets"]
---

# Create assets

`POST /v2/assets/`

Create up to ``BATCH_MAX_ITEMS`` assets in a single request.

Each item supplies a ``content_url`` that the server fetches and re-hosts
**asynchronously**. The response therefore describes rows in state
``PROCESSING`` — poll ``GET /v2/assets/{id}`` until ``state`` becomes ``READY``
or ``ERROR``. A fetch failure surfaces as ``state=ERROR`` with a reason in
``status_msg``, not as an error on this response.

The ``type`` supplied per item seeds the created row, but is **provisional**: the
server re-derives it from the fetched content during ingest and that value wins.

Returns 200 if all items succeed, 207 if any fail, 400 if the envelope is empty
or over the cap.

Rate limited more tightly than a plain row-writing batch endpoint: every accepted
item converts one request into real egress, and video or audio items also start a
transcription job.

**Required OAuth scope:** `assets:write`

## Request body

- AssetCreateRequestEnvelope — JSON:API envelope for POST /v2/assets/. The ``data`` field is always an array — single-create is a list of one. Cap enforcement lives in the endpoint, NOT here — that gives us HTTP 400 via ``ValidationError`` instead of Pydantic's 422, per the v2 batch-response convention.
  - `data` AssetCreateData[], required — List of asset items to create.
    - `type` 'assets', required — Must be "assets".
    - `attributes` AssetCreateRequest, required — Request attributes for POST /v2/assets/. Accepts exactly what AC1 specifies: a ``content_url`` and a ``type`` from the uploadable set. There is deliberately no ``title`` here — ``display_name`` is derived from the ingested filename, and a caller who wants a specific label sets it with a follow-up PATCH. Adding an optional field later is backwards compatible; removing one is not. ``content_url`` uses the shared ``ContentUrl`` type from GE-253536 rather than redeclaring the https/well-formed rule. That check is syntactic only and runs at schema-parse time (422); the authoritative network safety runs at fetch time in the worker.
      - `content_url` string, required — https:// URL of the asset file. The server fetches and re-hosts it asynchronously; the created resource starts in state=PROCESSING.
      - `type` 'PDF' | 'FILE' | 'HTML' | 'VIDEO_BOTR' | 'AUDIO_BOTR', required — Asset kind to create: PDF, FILE, HTML, VIDEO_BOTR or AUDIO_BOTR. TEMPLATE is not uploadable and is rejected with 422. This value seeds the row so the create response is not misleading, but the server re-derives the type from the fetched content and that derived value wins.

## Response `200`

OK

- BatchResultEnvelopeAssetResource
  - `data` union[], required — Per-item results in request order.
    - union
      - BatchSucceededItemAssetResource
        - `status` 'succeeded'
        - `id` string, nullable — Resource ID of the created or updated resource.
        - `result` AssetResource, required — JSON:API resource object for an asset, as returned on list.
          - `type` 'assets' — Always "assets".
          - `id` string, required — Opaque asset ID. Use in URL paths.
          - `attributes` AssetAttributes, required — Attributes of an asset resource object, as returned on list. ``type`` and ``state`` are deliberately typed as loose ``str`` rather than ``Literal``, matching ``WebPackageAttributes`` and ``LessonAttributes.type``: a new choice added to ``Asset.ASSET_TYPE_CHOICES`` or ``ASSET_STATE_CHOICES`` must never break serialization of existing rows. The valid values are documented in the field descriptions. Note ``type`` here is the **asset kind**, not the JSON:API resource type. Both are spelled ``type`` — the resource-level one is always ``"assets"``, while this one distinguishes a PDF from a video. Living inside ``attributes`` is what keeps them apart on the wire, and follows the ``LessonAttributes.type`` precedent. ``download_url`` is **not** here: signing a URL costs a round trip per row, so it is exposed only on the single-resource response (see ``AssetDetailAttributes``).
            - `type` string, required — Asset kind: PDF, FILE, HTML, VIDEO_BOTR, AUDIO_BOTR, or TEMPLATE. Server-determined from the fetched content — a value supplied on create seeds the row but is provisional and may be corrected during ingest.
            - `state` string, required — Ingestion state: PROCESSING while the asset is being fetched, stored and (for video/audio) handed to JW Platform; READY once that handoff has been accepted; ERROR if it could not be completed. Poll this field after create. READY means accepted for processing, not that transcoding has finished.
            - `title` string, required — Human-readable label. Server-derived from the uploaded filename when not set explicitly.
            - `status_msg` string, required — Reason the asset reached ERROR, or empty when there is none. Generic by design — it never names the host or address a fetch was attempted against.
            - `created_at` string, date-time, required — Timestamp when the asset was created.
            - `modified_at` string, date-time, required — Timestamp when the asset was last modified.
          - `links` object, nullable — Self and related links.
      - BatchFailedItem — Wrapper for a failed item in a 207 batch response.
        - `status` 'failed'
        - `id` string, nullable — Echoed resource ID if the input identified a row.
        - `error` BatchItemError, required — Per-item error inside a 207 batch response. Aligned with JSON:API ``ErrorObject`` field naming (``code`` rather than ``reason``) so consumers can reuse error-handling logic across document-level errors (``ErrorObject`` in ``ErrorEnvelope``) and per-item errors (here). Differences from ``ErrorObject``: ``status``/``title`` are omitted because they're redundant for the 207-batch context (HTTP status is on the envelope, and the title is derivable from ``code``).
          - `code` 'duplicate_email' | 'duplicate_in_batch' | 'duplicate_name' | 'validation_error' | 'not_found' | 'internal_error' | 'not_in_domain' | 'already_enrolled' | 'already_published', required — Machine-readable error code.
          - `source` object, nullable — Pointer to the offending input slot, e.g. {"pointer": "/data/0/attributes/email"}.
          - `detail` string, nullable — Human-readable explanation of the error.
  - `summary` BatchSummary, required — Aggregate counts for a 207 batch response. Invariant: ``succeeded + failed == total``. Enforced by ``@model_validator``.
    - `total` integer, required — Total number of items submitted.
    - `succeeded` integer, required — Number of items that succeeded.
    - `failed` integer, required — Number of items that failed.

## Other responses

- `207` — Multi-Status
- `400` — Bad Request
- `401` — Unauthorized
- `403` — Forbidden
- `404` — Not Found
- `422` — Unprocessable Entity
- `429` — Too Many Requests

## Changes

- **2026-09-01** `a3c13d9cb9b7` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/skilljar/apis/skilljar-api-v2/changes/v2/assets/post.md)

---

[API](https://skmtc.dev/skilljar/apis/skilljar-api-v2.md) · [All operations](https://skmtc.dev/skilljar/apis/skilljar-api-v2/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/skilljar/skilljar-api-v2/revisions/91fb0195e524/schema)
