@wahlu/api-client
v0.7.3
Published
Typed, runtime-independent client for the Wahlu public API
Readme
@wahlu/api-client
Typed, standards-based client for the Wahlu public API. It is the shared transport boundary for the Wahlu CLI and MCP server; it contains no database, provider, media-processing, or scheduling logic.
Release status: the canonical 22-operation client is ESM-only and requires Node.js 18+ or an equivalent modern runtime with standards-based
fetch, Web Crypto, andAbortController.
npm install @wahlu/api-clientPass the API key at runtime through createWahluClient({ apiKey }). Never embed a Wahlu API key in a
browser bundle, committed source, URL, command argument, or log. Server and local-agent processes
should load it from their secret store or environment and grant only the scopes and brands the
workflow needs.
The canonical surface contains exactly twenty-two operations:
- Discovery:
getContext(),listTargets(),getTargetDynamicOptions(),refreshTargetDynamicOptions(),getPlatformCapabilities() - Media:
importMediaFromUrl(),createMediaUploadSession(),listMedia(),getMedia(),createMediaRepairDerivative() - Content:
listContentItems(),getContentItem(),createContentDraft(),updateContentDraftTikTokPrivacy(),preflightContentSchedule() - Schedules:
listSchedules(),createSchedule(),getSchedule(),getScheduleReceipt(),cleanupProviderPublications(),rescheduleSchedule(),cancelSchedule()
There is no hidden polling, execution, retry, or generic transport method in this surface. Schedule listing requires an explicit UTC date range of at most 93 days. Rescheduling and cancellation are explicit, idempotent commands with exact Schedule confirmation; cancellation is limited to unsent Schedules with no execution history and retains the audit record.
For TikTok, grant integrations:read and call
getTargetDynamicOptions(brandId, integrationId) for the exact selected target immediately before
choosing privacy. This compatible GET is strictly read-only: it never refreshes credentials,
acquires a provider-effect lease, or changes integration status. If the current credential cannot
safely support the read, it fails explicitly. A caller with integrations:write can then make the
separate medium-risk refreshTargetDynamicOptions(brandId, integrationId) POST; that action may
rotate stored credentials or update integration reauthorisation state, but changes no content,
creates no Schedule, and submits no provider post. It accepts a strict empty body and no idempotency
key. Pass one returned value to
updateContentDraftTikTokPrivacy(brandId, contentItemId, { integration_id, privacy_level }) to
update that same draft. Never guess PUBLIC_TO_EVERYONE: the API re-queries TikTok and rejects a
stale or unsupported value before writing. The update creates no replacement draft or Schedule and
sends no provider post.
import { createWahluClient } from "@wahlu/api-client";
const wahlu = createWahluClient({ apiKey: process.env.WAHLU_API_KEY! });
const { data: context } = await wahlu.getContext();
const brand = context.brands[0];
if (!brand) throw new Error("This API key has no accessible brands.");
const { data: targetDiscovery } = await wahlu.listTargets(brand.id);
const target = targetDiscovery.targets.find(
(candidate) =>
candidate.platform === "instagram" &&
candidate.schedulable &&
candidate.integration_id &&
candidate.capabilities.scheduling &&
candidate.capabilities.media_upload,
);
if (!target?.integration_id) throw new Error("No ready Instagram target is available.");
// The target already embeds its effective capabilities. Call
// getPlatformCapabilities() only when you need the full platform configuration registry.
const { data: imported } = await wahlu.importMediaFromUrl(
brand.id,
{ url: "https://assets.example.com/campaign/hero.jpg" },
{ idempotencyKey: "media-launch-hero-v1" },
);
const { data: media } = await wahlu.getMedia(brand.id, imported.id);
if (media.links.self.href !== imported.links.self.href) {
throw new Error("The media hand-off changed identity.");
}
if (media.status !== "completed" || !media.readiness.ready_for_content) {
const nextAction = media.readiness.next_actions[0];
throw new Error(nextAction?.guidance ?? `Media is not ready: ${media.status}`);
}
const { data: draft } = await wahlu.createContentDraft(
brand.id,
{
name: "Launch announcement",
copy_mode: "single",
single_copy: { caption: "We are live", hashtags: ["launch"] },
instagram_settings: { media_ids: [media.id], post_type: "GRID_POST" },
intended_integration_ids: [target.integration_id],
},
{ idempotencyKey: "draft-launch-announcement-v1" },
);
const scheduledAt = "2026-08-01T10:00:00+10:00";
const { data: preflight } = await wahlu.preflightContentSchedule(
brand.id,
draft.content_item.id,
{
integration_ids: [target.integration_id],
scheduled_at: scheduledAt,
approval_status: "pending_review",
},
);
if (!preflight.can_schedule || !preflight.links.create_schedule) {
throw new Error(preflight.blockers[0]?.message ?? "Not schedulable");
}
const preflightSchedule = preflight.request;
if (!preflightSchedule.scheduled_at || preflightSchedule.approval_status !== "pending_review") {
throw new Error("Preflight did not preserve the held Schedule decision.");
}
const { data: created } = await wahlu.createSchedule(
preflight.content_item.brand_id,
{
content_item_id: preflight.content_item.id,
integration_ids: preflightSchedule.integration_ids,
scheduled_at: preflightSchedule.scheduled_at,
approval_status: preflightSchedule.approval_status,
},
{ idempotencyKey: "schedule-launch-announcement-v1" },
);
const { data: schedule } = await wahlu.getSchedule(
created.schedule.brand_id,
created.schedule.id,
);
console.log(schedule.status, schedule.blocking_reason?.code);pending_review is an explicit safety boundary. It creates a held Schedule with
APPROVAL_PENDING, no execution or job, and no publishing-provider effect. An explicit approved
Schedule additionally requires publish:execute permission and may later publish externally.
Resource-creating mutations require a stable caller-owned idempotency key. Persist and reuse the
same key when retrying the same logical operation; do not generate a new key for each transport
attempt. The precise TikTok privacy PUT is naturally idempotent and does not accept an
Idempotency-Key header. The bounded target dynamic-options refresh is an explicit medium-risk
POST action with a strict empty body; it likewise accepts no idempotency key.
Safety behaviour
- API keys are sent only as bearer tokens and are redacted if a server error echoes them.
- Requests have finite timeouts, support cancellation, and bound response bytes before JSON parsing.
- Redirects stay on the same origin and cannot silently rewrite mutation methods.
- Safe reads retry bounded transport and retryable HTTP failures. Mutations retry only with a caller-supplied idempotency key and a descriptor that supports idempotency.
- Every logical request keeps one request ID across attempts.
- Requests and responses are validated against the canonical operation descriptors, including strict status, envelope, request identity, and replay agreement.
- Response identity and pagination are available only through
result.meta.request_idandresult.meta.pagination; the result object has no duplicate legacy aliases.
Preflight is write-free: it reports readiness and repair actions but creates no Schedule, execution,
job, queue entry, or provider effect. getMedia() and getSchedule() each perform one bounded read;
the SDK does not invent polling behavior.
Production defaults to https://api.wahlu.com and https://media.wahlu.com. Staging or local
environments can set baseUrl and mediaBaseUrl explicitly; plaintext HTTP is accepted only for
loopback development origins.
