Wahlu API
Recipes

Create a Schedule Held for Approval

Import media, create and preflight a draft, then create a pending-review Schedule without publishing.

This recipe discovers an accessible brand, current Instagram constraints, and a connected target; imports media; attaches it to a draft; checks scheduling readiness; and creates a Schedule held for review. It carries every returned ID forward and makes one explicit Media read—there is no polling loop.

Set WAHLU_API_KEY, WAHLU_MEDIA_URL to a public image, WAHLU_SCHEDULED_AT to a future ISO 8601 time, and WAHLU_RECIPE_ID to a unique stable value. Keep the same values when retrying so idempotency remains meaningful. The key needs integrations:read, media:write, media:read, posts:write, schedule:write, and schedule:read.

JavaScript (Node.js 18+)
const API_ORIGIN = "https://api.wahlu.com";
const API_KEY = process.env.WAHLU_API_KEY;
const MEDIA_URL = process.env.WAHLU_MEDIA_URL;
const SCHEDULED_AT = process.env.WAHLU_SCHEDULED_AT;
const RECIPE_ID = process.env.WAHLU_RECIPE_ID;

if (!API_KEY) throw new Error("Set WAHLU_API_KEY before running this recipe");
if (!MEDIA_URL) throw new Error("Set WAHLU_MEDIA_URL to a public image URL");
if (!SCHEDULED_AT) throw new Error("Set WAHLU_SCHEDULED_AT to a future ISO 8601 time");
if (!RECIPE_ID) throw new Error("Set WAHLU_RECIPE_ID to a stable unique value");

async function request(path, init = {}) {
  const response = await fetch(new URL(path, API_ORIGIN), {
    ...init,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(init.body ? { "Content-Type": "application/json" } : {}),
      ...init.headers,
    },
  });
  const payload = await response.json();

  if (!response.ok || !payload.success) {
    throw new Error(
      `${payload.error?.code ?? response.status}: ${
        payload.error?.message ?? "Wahlu request failed"
      }`
    );
  }

  return payload.data;
}

// 1. Discover an accessible brand instead of inventing a brand ID.
const context = await request("/v1/context");
const brand = context.brands[0];
if (!brand) throw new Error("This API key cannot access a brand");

// 2. Resolve a currently supported Instagram image post type.
const capabilityResult = await request(context.links.platform_capabilities.href);
const instagramCapability = capabilityResult.platforms.find(
  (platform) => platform.platform === "instagram"
);
const gridPost = instagramCapability?.post_types.find(
  (postType) => postType.id === "GRID_POST" && postType.status === "supported"
);
const gridMediaRule = gridPost
  ? instagramCapability.media_rules[gridPost.id]
  : undefined;
if (!gridPost || !gridMediaRule?.accepted_types.includes("image")) {
  throw new Error("Instagram image grid posts are not currently supported");
}

// 3. Select a real, schedulable Instagram integration.
const targetResult = await request(brand.links.targets.href);
const target = targetResult.targets.find(
  (candidate) =>
    candidate.platform === "instagram" &&
    candidate.schedulable &&
    candidate.integration_id
);
if (!target) throw new Error("Connect a schedulable Instagram target first");
const integrationId = target.integration_id;

// 4. Import a public image. Reusing this key with the same body is safe.
const mediaImport = await request(brand.links.media_imports.href, {
  method: "POST",
  headers: { "Idempotency-Key": `${RECIPE_ID}-media` },
  body: JSON.stringify({
    url: MEDIA_URL,
    filename: "autumn-launch.jpg",
  }),
});

// 5. Read readiness exactly once. If processing is incomplete, surface the
// API's guidance and stop. Re-run later; do not add a polling loop.
const media = await request(mediaImport.links.self.href);
if (!media.readiness.ready_for_content) {
  const guidance = media.readiness.next_actions
    .map((nextAction) => nextAction.guidance)
    .join(" ");
  throw new Error(guidance || "Media is not ready for content yet");
}

// 6. Create a draft with the returned Media and integration IDs attached.
const draftResult = await request(brand.links.content_items.href, {
  method: "POST",
  headers: { "Idempotency-Key": `${RECIPE_ID}-draft` },
  body: JSON.stringify({
    name: "Autumn launch announcement",
    copy_mode: "single",
    single_copy: {
      title: "Autumn launch",
      caption: "Our autumn collection is ready.",
      hashtags: ["autumn", "launch"],
    },
    instagram_settings: {
      post_type: gridPost.id,
      media_ids: [media.id],
      collaborators: [],
      trial_reel: false,
    },
    intended_integration_ids: [integrationId],
  }),
});
const contentItemId = draftResult.content_item.id;

// 7. Preflight is write-free. pending_review keeps the eventual Schedule held.
const preflight = await request(draftResult.links.preflight.href, {
  method: "POST",
  body: JSON.stringify({
    integration_ids: [integrationId],
    scheduled_at: SCHEDULED_AT,
    approval_status: "pending_review",
  }),
});
if (!preflight.can_schedule || !preflight.links.create_schedule) {
  throw new Error(
    preflight.blockers.map((blocker) => blocker.message).join(" ") ||
      "Draft is not ready to schedule"
  );
}

// 8. Build the mutation from the normalised preflight request.
const scheduleResult = await request(preflight.links.create_schedule.href, {
  method: "POST",
  headers: { "Idempotency-Key": `${RECIPE_ID}-schedule` },
  body: JSON.stringify({
    content_item_id: contentItemId,
    integration_ids: preflight.request.integration_ids,
    scheduled_at: preflight.request.scheduled_at,
    approval_status: preflight.request.approval_status,
  }),
});

// 9. Read the returned Schedule once and prove it is held—not published.
const schedule = await request(scheduleResult.schedule.links.self.href);
if (
  schedule.approval_status !== "pending_review" ||
  schedule.status !== "action_required" ||
  schedule.blocking_reason?.code !== "APPROVAL_PENDING" ||
  schedule.latest_execution !== null
) {
  throw new Error("Expected a held Schedule awaiting approval");
}

console.log({
  brand_id: brand.id,
  integration_id: integrationId,
  post_type: gridPost.id,
  media_id: media.id,
  content_item_id: contentItemId,
  schedule_id: schedule.id,
  status: schedule.status,
  blocker: schedule.blocking_reason.code,
  latest_execution: schedule.latest_execution,
});

What this does not do

The result ends with action_required, an APPROVAL_PENDING blocker, and latest_execution: null. It creates no execution or job and makes no publishing-provider request. Creating an explicitly approved Schedule is a separate higher-authority action that also requires the publish:execute scope.