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.
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.