Wahlu API

Schedules

Create an explicitly approved or held Schedule and read its current safe status.

A Schedule is a durable publishing instruction, not an execution attempt. The released surface creates one Schedule and reads that same resource. Run the write-free preflight first, then follow its resolved links.create_schedule only whencan_schedule is true.

Create a Schedule

POST/v1/brands/:brand_id/schedules

Requires schedule:write and a caller-owned idempotency key. The approval decision is always explicit. Apending_review Schedule is safely held; anapproved request additionally requirespublish:execute because it authorises external publishing.

Request body

ParameterTypeDescription
content_item_id*stringThe draft ID returned by content creation.
scheduled_at*stringISO 8601 date and time with an explicit timezone offset or Z suffix.
integration_ids*string[]One to 20 unique target integration IDs.
approval_status*stringapproved, pending_review, or rejected. Never inferred.
idempotency_keystringBody alternative to Idempotency-Key. If both are sent, they must match.
Create a held Schedule
curl -X POST https://api.wahlu.com/v1/brands/brand_01k0acme/schedules \
  -H "Authorization: Bearer wahlu_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: schedule-autumn-launch-v1" \
  -d '{
    "content_item_id": "content_01k0launch",
    "scheduled_at": "2026-07-20T12:00:00+10:00",
    "integration_ids": ["integration_01k0instagram"],
    "approval_status": "pending_review"
  }'
201 held response
{
  "success": true,
  "data": {
    "schedule": {
      "id": "schedule_01k0launch",
      "brand_id": "brand_01k0acme",
      "content_item_id": "content_01k0launch",
      "integration_ids": ["integration_01k0instagram"],
      "scheduled_at": "2026-07-20T02:00:00.000Z",
      "approval_status": "pending_review",
      "status": "action_required",
      "blocking_reason": {
        "code": "APPROVAL_PENDING",
        "message": "This schedule is waiting for approval.",
        "repair_guidance": "Approve the content before its scheduled time."
      },
      "latest_execution": null,
      "next_actions": [
        {
          "action": "review_approval",
          "guidance": "Approve the content before its scheduled time."
        }
      ],
      "created_at": "2026-07-19T02:00:00.000Z",
      "updated_at": "2026-07-19T02:00:00.000Z",
      "links": {
        "self": {
          "href": "/v1/brands/brand_01k0acme/schedules/schedule_01k0launch",
          "required_scopes": ["schedule:read"]
        },
        "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"]
        }
      }
    },
    "outcome": "created"
  },
  "meta": { "request_id": "req_schedule_created_01k0" }
}

A new Schedule returns 201 withoutcome: "created". An exact idempotent replay returns the same Schedule with 200,outcome: "replayed", andIdempotency-Replayed: true. Reusing the key with different input returns 409.

The held result is explicit:status: "action_required", blockerAPPROVAL_PENDING, and latest_execution: null. It creates no execution or Job, sends no provider request, and publishes nothing.

Get a Schedule

GET/v1/brands/:brand_id/schedules/:schedule_id

Requires schedule:read. Follow the created Schedule'slinks.self.href for one bounded read of its approval, status, blocker, latest execution summary (including the opaque latest_execution.id when a run exists), and next action. The run ID is only a Wahlu correlation handle; it is not a provider ID. The read does not modify the Schedule, create an execution, or start polling.

One explicit read
curl https://api.wahlu.com/v1/brands/brand_01k0acme/schedules/schedule_01k0launch \
  -H "Authorization: Bearer wahlu_live_your_api_key_here"
200 held response
{
  "success": true,
  "data": {
    "id": "schedule_01k0launch",
    "brand_id": "brand_01k0acme",
    "content_item_id": "content_01k0launch",
    "integration_ids": ["integration_01k0instagram"],
    "scheduled_at": "2026-07-20T02:00:00.000Z",
    "approval_status": "pending_review",
    "status": "action_required",
    "blocking_reason": {
      "code": "APPROVAL_PENDING",
      "message": "This schedule is waiting for approval.",
      "repair_guidance": "Approve the content before its scheduled time."
    },
    "latest_execution": null,
    "next_actions": [
      {
        "action": "review_approval",
        "guidance": "Approve the content before its scheduled time."
      }
    ],
    "created_at": "2026-07-19T02:00:00.000Z",
    "updated_at": "2026-07-19T02:00:00.000Z",
    "links": {
      "self": {
        "href": "/v1/brands/brand_01k0acme/schedules/schedule_01k0launch",
        "required_scopes": ["schedule:read"]
      },
      "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_schedule_read_01k0" }
}
Check the held state
const schedule = payload.data;
if (
  schedule.approval_status !== "pending_review" ||
  schedule.status !== "action_required" ||
  schedule.blocking_reason?.code !== "APPROVAL_PENDING" ||
  schedule.latest_execution !== null
) {
  throw new Error("Schedule is not in the expected held state");
}

Get a publish receipt

GET/v1/brands/:brand_id/schedules/:schedule_id/receipt

Requires schedule:read. This bounded read returns the exact latest publish run with redacted per-platform outcomes. It never returns provider IDs, provider errors, tokens, payloads, or arbitrary prior runs. A cleanup authority and link appear only when that exact run is safely bounded.

Queue exact provider cleanup

POST/v1/brands/:brand_id/schedules/:schedule_id/provider-cleanup

Requires publish:execute, a caller-owned idempotency key, and thecleanup_authority returned by the matching publish receipt. The request accepts no provider IDs, cannot search or replay arbitrary history, and queues cleanup only for eligible published records covered by that exact authority. No provider is called by this request; failed and action-required targets are skipped.