Skip to content
Wahlu API

Content Items

Create, list and read content drafts, and check their scheduling readiness.

For draft edits, deletion and held-schedule review, see the drafts, History and planning reference.

The released Content API creates an idempotent draft, runs a separate, write-free preflight, and lists or reads saved content. Creating a draft never creates a Schedule or Job, calls a provider, or publishes content.

Captions use copy_mode with single_copy orplatform_copy. Platform settings hold media and publishing options only. Discover current platform rules from the capabilities operation before constructing settings.

X uses x_settings and platform_copy.x. Unlike the other platform fields, responses include them only for a content item that has them, so treat a missing field as null. SDK and contract versions before 0.11.0, CLI before 0.7.0 and the local MCP server before 0.14.0 reject responses that name X.

Bluesky works the same way with bluesky_settings and platform_copy.bluesky. Its settings take media_ids, post_type and alt_text, keyed by a media ID in media_ids. SDK and contract versions before 0.12.0, CLI before 0.8.0 and the local MCP server before 0.15.0 reject responses that name Bluesky.

A thread on X or Bluesky is the main post plus up to 24 more posts, each posted as a reply to the one before. Send the extra posts in the optional thread_parts array on x_settings or bluesky_settings. Each part has text and up to 4 media_ids; on Bluesky it also takes alt_text, keyed by a media ID in that part. Each part follows its network's length and media rules. Responses include thread_parts only for a content item that has a thread, so clients that predate it never see it. It needs SDK and contract 0.13.0, CLI 0.9.0 or the local MCP server 0.16.0.

Telegram, Discord and Tumblr work the same way as X: telegram_settings, discord_settings and tumblr_settings, with their platform_copy entries, are sent only for a content item that has them. Telegram settings take media_ids, post_type, disable_link_preview and silent. Discord settings take media_ids, post_type and publish_to_followers. Tumblr settings take media_ids, post_type, title, link, tags and alt_text, keyed by a media ID in media_ids. SDK and contract versions before 0.13.0, CLI before 0.9.0 and the local MCP server before 0.16.0 reject responses that name Telegram, Discord or Tumblr.

Pinterest works the same way: pinterest_settings and platform_copy.pinterest (the Pin description, up to 800 characters) are sent only for a content item that has them. Pinterest settings take media_ids (1 to 5 images, or one video), post_type (PIN_IMAGE or PIN_VIDEO), board_id, board_name, board_section_id, title (up to 100 characters), link and alt_text (up to 500 characters). Every Pin needs a board: list a Pinterest target's boards with GET /v1/brands/{brand_id}/targets/{integration_id}/dynamic-options, which returns kind pinterest_boards with each board's ID as value. SDK and contract versions before 0.14.0, CLI before 0.10.0 and the local MCP server before 0.17.0 reject responses that name Pinterest.

Meta's Threads app works the same way: threads_settings and platform_copy.threads (up to 500 characters, with each emoji counted as its UTF-8 bytes and at most 5 links) are sent only for a content item that has them. These are separate from X/Bluesky threads in x_settings.thread_parts and bluesky_settings.thread_parts. Threads settings take media_ids (up to 20), post_type (TH_TEXT, TH_IMAGE with one image, TH_VIDEO with one video or TH_CAROUSEL with 2 to 20 images and videos), reply_control (everyone, accounts_you_follow, mentioned_only or followers_only; defaults to everyone), topic_tag (one tag up to 50 characters, without #, full stops or ampersands), thread_parts (up to 9 more text-only posts as { text }, each posted as a reply to the one before) and follow_up_reply (one reply posted once the post is live). Preflight checks the main post, each part and the follow-up reply against the 500 limit. SDK and contract versions before 0.15.0, CLI before 0.11.0 and the local MCP server before 0.18.0 reject responses that name Threads.

Create a content draft

POST/v1/brands/:brand_id/content-items

Requires posts:write. Attach only a Media ID whosereadiness.ready_for_content is true. See the Media reference.

Send either an Idempotency-Key header or anidempotency_key body field. If both are sent, they must match. A new draft returns 201; an exact replay returns the saved draft with 200 and Idempotency-Replayed: true. Reusing a key with different input returns 409.

Request body

ParameterTypeDescription
name (required)stringTrimmed draft name, up to 500 characters.
idempotency_keystringBody alternative to Idempotency-Key. If both are sent, they must match.
copy_modestringsingle or per_platform.
single_copyobject | nullShared caption, hashtags, and optional title when copy_mode is single.
platform_copyobject | nullPer-platform caption objects when copy_mode is per_platform.
*_settingsobject | nullPlatform media and publishing options. Supported keys are returned by platform capabilities.
intended_integration_idsstring[]Up to 20 unique, canonically sorted target IDs for the next preflight.
label_idsstring[]Optional unique label IDs in canonical ascending order.
thumbnail_media_idstringOptional Media ID for the draft thumbnail.
thumbnail_timestampnumberOptional non-negative video thumbnail time in seconds.

The body is strict snake_case. Unknown keys, camelCase aliases, padded identifiers, duplicate IDs, and non-canonical sorted arrays are rejected instead of silently changed.

Create an attached Instagram draft
curl -X POST https://api.wahlu.com/v1/brands/brand_01k0acme/content-items \
  -H "Authorization: Bearer wahlu_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: draft-autumn-launch-v1" \
  -d '{
    "name": "Autumn launch announcement",
    "copy_mode": "single",
    "single_copy": {
      "caption": "Our autumn collection is ready.",
      "hashtags": ["autumn", "launch"],
      "title": "Autumn launch"
    },
    "instagram_settings": {
      "media_ids": ["media_01k0hero"],
      "post_type": "REEL",
      "trial_reel": false,
      "collaborators": []
    },
    "intended_integration_ids": ["integration_01k0instagram"]
  }'
201 response
{
  "success": true,
  "data": {
    "content_item": {
      "id": "content_01k0launch",
      "brand_id": "brand_01k0acme",
      "name": "Autumn launch announcement",
      "label_ids": [],
      "thumbnail_media_id": null,
      "thumbnail_timestamp": 0,
      "intended_integration_ids": ["integration_01k0instagram"],
      "copy_mode": "single",
      "single_copy": {
        "caption": "Our autumn collection is ready.",
        "hashtags": ["autumn", "launch"],
        "title": "Autumn launch"
      },
      "platform_copy": null,
      "instagram_settings": {
        "media_ids": ["media_01k0hero"],
        "post_type": "REEL",
        "trial_reel": false,
        "graduation_strategy": null,
        "collaborators": []
      },
      "tiktok_settings": null,
      "facebook_settings": null,
      "youtube_settings": null,
      "linkedin_settings": null,
      "created_at": "2026-07-18T02:00:00.000Z",
      "updated_at": "2026-07-18T02:00:00.000Z"
    },
    "outcome": "created",
    "intended_integration_ids": ["integration_01k0instagram"],
    "next_actions": [
      {
        "action": "preflight_content",
        "integration_ids": ["integration_01k0instagram"]
      }
    ],
    "links": {
      "targets": {
        "href": "/v1/brands/brand_01k0acme/targets",
        "required_scopes": ["integrations:read"]
      },
      "preflight": {
        "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch/preflight",
        "method": "POST",
        "required_scopes": ["schedule:write"]
      }
    }
  },
  "meta": { "request_id": "req_draft_created_01k0" }
}

Update TikTok privacy on the existing draft

PUT/v1/brands/:brand_id/content-items/:content_item_id/tiktok-privacy

Requires posts:write. Send the exact TikTok integration ID selected for this draft and one privacy value returned by its live dynamic-options operation. Wahlu performs a fresh target-specific check immediately before the write, so a stale or unsupported value fails clearly and the draft stays unchanged. Wahlu never guesses PUBLIC_TO_EVERYONE.

This operation changes only tiktok_settings.privacy_level on the same content item. It creates no replacement draft, Schedule, Job, or provider post. Repeating the same supported value returns an unchanged outcome. It is naturally idempotent and does not accept an Idempotency-Key header. Drafts already controlled by active or approved publication intent are rejected.

Set a validated TikTok privacy choice
curl -X PUT https://api.wahlu.com/v1/brands/brand_01k0acme/content-items/content_01k0launch/tiktok-privacy \
  -H "Authorization: Bearer $WAHLU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "integration_id": "integration_01k0tiktok",
    "privacy_level": "SELF_ONLY"
  }'
200 response
{
  "success": true,
  "data": {
    "content_item_id": "content_01k0launch",
    "brand_id": "brand_01k0acme",
    "integration_id": "integration_01k0tiktok",
    "privacy_level": "SELF_ONLY",
    "outcome": "updated",
    "next_action": { "action": "preflight_content" },
    "links": {
      "self": {
        "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch/tiktok-privacy",
        "method": "PUT",
        "required_scopes": ["posts:write"]
      },
      "preflight": {
        "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch/preflight",
        "method": "POST",
        "required_scopes": ["schedule:write"]
      },
      "dynamic_options": {
        "href": "/v1/brands/brand_01k0acme/targets/integration_01k0tiktok/dynamic-options",
        "required_scopes": ["integrations:read"]
      }
    }
  },
  "meta": { "request_id": "req_tiktok_privacy_01k0" }
}

Preflight a content draft

POST/v1/brands/:brand_id/content-items/:content_item_id/preflight

Requires schedule:write. This is a write-free readiness check for the returned draft ID and selected integration IDs. It creates no Schedule or Job, enqueues nothing, calls no provider, and publishes nothing.

Request body

ParameterTypeDescription
integration_ids (required)string[]One to 20 unique integration IDs in canonical order.
scheduled_atstringISO 8601 date and time with an explicit timezone offset or Z suffix.
approval_statusstringapproved, pending_review, or rejected. Send pending_review for a held workflow.
Preflight for a held Schedule
curl -X POST https://api.wahlu.com/v1/brands/brand_01k0acme/content-items/content_01k0launch/preflight \
  -H "Authorization: Bearer wahlu_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "integration_ids": ["integration_01k0instagram"],
    "scheduled_at": "2026-07-20T12:00:00+10:00",
    "approval_status": "pending_review"
  }'
200 ready response
{
  "success": true,
  "data": {
					"validation_version": "2026-08-05.1",
    "fingerprint": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "content_item": {
      "id": "content_01k0launch",
      "brand_id": "brand_01k0acme",
      "updated_at": "2026-07-18T02:00:00.000Z"
    },
    "request": {
      "integration_ids": ["integration_01k0instagram"],
      "scheduled_at": "2026-07-20T02:00:00.000Z",
      "approval_status": "pending_review"
    },
    "policy": { "scheduling_allowed": true },
    "can_schedule": true,
    "target_count": 1,
    "targets": [
      {
        "integration_id": "integration_01k0instagram",
        "platform": "instagram",
        "display_name": "Acme Instagram",
        "connection_status": "connected",
        "can_schedule": true,
        "issues": [],
        "blockers": [],
        "warnings": []
      }
    ],
    "issues": [
      {
        "severity": "warning",
        "code": "APPROVAL_PENDING",
        "scope": "approval",
        "field_path": "$.approval_status",
        "message": "The schedule can be created, but publishing remains gated until approval.",
        "integration_id": null,
        "platform": null,
        "repair_action": {
          "id": "review_content",
          "action": "change_approval",
          "label": "Review the content",
          "guidance": "Approve the content before its execution time."
        }
      }
    ],
    "blockers": [],
    "warnings": [
      {
        "severity": "warning",
        "code": "APPROVAL_PENDING",
        "scope": "approval",
        "field_path": "$.approval_status",
        "message": "The schedule can be created, but publishing remains gated until approval.",
        "integration_id": null,
        "platform": null,
        "repair_action": {
          "id": "review_content",
          "action": "change_approval",
          "label": "Review the content",
          "guidance": "Approve the content before its execution time."
        }
      }
    ],
    "next_actions": [
      "Create the schedule; the mutation will re-run this preflight before writing."
    ],
    "links": {
      "self": {
        "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch/preflight",
        "method": "POST"
      },
      "targets": {
        "href": "/v1/brands/brand_01k0acme/targets",
        "required_scopes": ["integrations:read"]
      },
      "create_schedule": {
        "href": "/v1/brands/brand_01k0acme/schedules",
        "method": "POST",
        "required_scopes": ["schedule:write"]
      }
    }
  },
  "meta": { "request_id": "req_preflight_ready_01k0" }
}

Decide from the response

A successful HTTP response can still have can_schedule: false. When blocked, apply the returned repair guidance and explicitly run preflight again. links.create_schedule appears only whencan_schedule is true.

Supported dimension or aspect-ratio blockers include deterministic repair_optionsfor the exact integration, platform, post type, source hash, policy, safe area, and transform geometry. Choose centre_crop or pad explicitly and pass that option to the Media repair-derivative endpoint. If no safe transform is available, preflight returns a machine-readable unavailable code and explanation instead of repairing implicitly.

The link's authority matches the requested approval: a pending_review Schedule requires onlyschedule:write, while approved additionally requires publish:execute. Follow that link into the Schedule reference.

List content items

GET/v1/brands/:brand_id/content-items

Requires posts:read. Returns one bounded page of the brand's saved content, ordered by update time. Each item is a short summary with a caption preview, the platforms and Media IDs it uses, and a resolved links.self for the full read. The list does not infer Schedule or publication state.

When has_more is true, next_cursor is the ID of the last returned item. Send it as cursor to read the next page. An unknown or inaccessible cursor returns 400 with INVALID_CURSOR.

Query parameters

ParameterTypeDescription
limitintegerItems to return, from 1 to 50. Defaults to 20.
cursorstringThe next_cursor from the previous page.
order_directionstringdesc (most recently updated first, the default) or asc.
curl
curl "https://api.wahlu.com/v1/brands/brand_01k0acme/content-items?limit=20" \
  -H "Authorization: Bearer $WAHLU_API_KEY"
200 response
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "content_01k0launch",
        "brand_id": "brand_01k0acme",
        "name": "Autumn launch",
        "caption_preview": "A closer look at the autumn collection.",
        "copy_mode": "single",
        "platforms": ["instagram"],
        "media_ids": ["media_01k0hero"],
        "intended_integration_ids": ["integration_01k0instagram"],
        "label_ids": ["label_campaign"],
        "created_at": "2026-08-12T00:00:00.000Z",
        "updated_at": "2026-08-12T01:00:00.000Z",
        "links": {
          "self": {
            "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch",
            "method": "GET",
            "required_scopes": ["posts:read"]
          }
        }
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "meta": { "request_id": "req_content_list_01" }
}

Get a content item

GET/v1/brands/:brand_id/content-items/:content_item_id

Requires posts:read. Follow an item's links.self.href to read the full saved draft: the same content_item shape that draft creation returns. The read never repairs or changes the record, and it takes no query parameters. A missing or inaccessible item returns 404 withCONTENT_ITEM_NOT_FOUND.

links.preflight appears only when the item has intended integration IDs, so you can move straight to a readiness check.

curl
curl https://api.wahlu.com/v1/brands/brand_01k0acme/content-items/content_01k0launch \
  -H "Authorization: Bearer $WAHLU_API_KEY"
200 response
{
  "success": true,
  "data": {
    "content_item": {
      "id": "content_01k0launch",
      "brand_id": "brand_01k0acme",
      "name": "Autumn launch",
      "label_ids": ["label_campaign"],
      "thumbnail_media_id": "media_01k0hero",
      "thumbnail_timestamp": 0,
      "intended_integration_ids": ["integration_01k0instagram"],
      "copy_mode": "single",
      "single_copy": {
        "caption": "A closer look at the autumn collection.",
        "hashtags": ["autumncollection"],
        "title": null
      },
      "platform_copy": null,
      "instagram_settings": {
        "media_ids": ["media_01k0hero"],
        "post_type": "GRID_POST",
        "trial_reel": null,
        "graduation_strategy": null,
        "collaborators": []
      },
      "tiktok_settings": null,
      "facebook_settings": null,
      "youtube_settings": null,
      "linkedin_settings": null,
      "created_at": "2026-08-12T00:00:00.000Z",
      "updated_at": "2026-08-12T01:00:00.000Z"
    },
    "links": {
      "self": {
        "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch",
        "method": "GET",
        "required_scopes": ["posts:read"]
      },
      "targets": {
        "href": "/v1/brands/brand_01k0acme/targets",
        "method": "GET",
        "required_scopes": ["integrations:read"]
      },
      "preflight": {
        "href": "/v1/brands/brand_01k0acme/content-items/content_01k0launch/preflight",
        "method": "POST",
        "required_scopes": ["schedule:write"]
      }
    }
  },
  "meta": { "request_id": "req_content_get_01" }
}