Guides
Publish posts
Upload media, create a post for one or more accounts, schedule it, and follow each target to its permalink.
The sequence
- Add media if the post has any: register it, upload the bytes, then complete it.
- Create the post with the accounts it goes to, the text and the media.
- Follow the result. Each account is a target with its own status and permalink.
Check each account's capabilities.publish first. Instagram posts need media.
1. Add media
Register an image, video or PDF with POST /v1/media. There are two ways, and you send exactly one:
source_url: a publichttp(s)URL that we download right away. The media isREADYin the response.upload: truewithmime_typeandsize_bytes: you upload the bytes yourself.
# 1. register
curl -X POST https://connect.social.wasparks.com/v1/media \
-H "Authorization: Bearer $WASPARKS_KEY" -H "X-Tenant-Id: acme-001" \
-H "Content-Type: application/json" \
-d '{"upload": true, "mime_type": "image/jpeg", "size_bytes": 482113}'
# 2. PUT the bytes to upload_url, with the same Content-Type
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @latte.jpg
# 3. complete
curl -X POST https://connect.social.wasparks.com/v1/media/$MEDIA_ID/complete \
-H "Authorization: Bearer $WASPARKS_KEY" -H "X-Tenant-Id: acme-001"
import fs from "node:fs";
const base = "https://connect.social.wasparks.com";
const headers = { Authorization: `Bearer ${process.env.WASPARKS_KEY}`, "X-Tenant-Id": "acme-001" };
const bytes = await fs.promises.readFile("latte.jpg");
// 1. register
const created = await fetch(`${base}/v1/media`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ upload: true, mime_type: "image/jpeg", size_bytes: bytes.length }),
}).then((r) => r.json());
// 2. PUT the bytes to upload_url, with the same Content-Type
await fetch(created.upload_url, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: bytes });
// 3. complete
const media = await fetch(`${base}/v1/media/${created.media.id}/complete`, { method: "POST", headers }).then((r) => r.json());
import os, requests
base = "https://connect.social.wasparks.com"
headers = {"Authorization": f"Bearer {os.environ['WASPARKS_KEY']}", "X-Tenant-Id": "acme-001"}
data = open("latte.jpg", "rb").read()
# 1. register
created = requests.post(f"{base}/v1/media", headers=headers,
json={"upload": True, "mime_type": "image/jpeg", "size_bytes": len(data)}).json()
# 2. PUT the bytes to upload_url, with the same Content-Type
requests.put(created["upload_url"], headers={"Content-Type": "image/jpeg"}, data=data)
# 3. complete
media = requests.post(f"{base}/v1/media/{created['media']['id']}/complete", headers=headers).json()The response to POST /v1/media is always { media, upload_url, upload_expires_at }. With source_url the two upload fields are null.
A completed media item has a status (UPLOADING, READY or FAILED) and platform_checks, which tells you per platform whether the file will pass and why not:
"platform_checks": {
"facebook_page": { "ok": true, "problems": [] },
"instagram": { "ok": false, "problems": ["aspect_ratio"] }
}Using media that is not READY in a post gives 400 media_not_ready.
2. Create the post
curl -X POST https://connect.social.wasparks.com/v1/posts \
-H "Authorization: Bearer $WASPARKS_KEY" -H "X-Tenant-Id: acme-001" \
-H "Idempotency-Key: 6d3f1c0e-1b8a-4f0a-9a56-2f0c1d7a9e11" \
-H "Content-Type: application/json" \
-d '{
"text": "Pumpkin spice season is back. Come say hi!",
"media_ids": ["0192a7c2-0e00-7d44-8b55-6c7d8e9fa001"],
"account_ids": ["0192a7c1-6a10-7b22-8e31-4c5d6e7f8a01"]
}'
const res = await fetch("https://connect.social.wasparks.com/v1/posts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WASPARKS_KEY}`,
"X-Tenant-Id": "acme-001",
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "Pumpkin spice season is back. Come say hi!",
media_ids: ["0192a7c2-0e00-7d44-8b55-6c7d8e9fa001"],
account_ids: ["0192a7c1-6a10-7b22-8e31-4c5d6e7f8a01"],
}),
});
const post = await res.json();
import os, uuid, requests
r = requests.post(
"https://connect.social.wasparks.com/v1/posts",
headers={
"Authorization": f"Bearer {os.environ['WASPARKS_KEY']}",
"X-Tenant-Id": "acme-001",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"text": "Pumpkin spice season is back. Come say hi!",
"media_ids": ["0192a7c2-0e00-7d44-8b55-6c7d8e9fa001"],
"account_ids": ["0192a7c1-6a10-7b22-8e31-4c5d6e7f8a01"],
},
)
post = r.json()The fields:
| Field | Meaning |
|---|---|
account_ids |
The accounts to publish to. One target is created per account. |
text |
The post text. Required on every platform except Instagram media posts. Length limits are per platform. |
media_ids |
READY media to attach. |
link_url, link_preview |
A link to share; see below. |
scheduled_at |
Publish at this time (UTC). Leave it out to publish now. |
draft |
true saves the post without publishing it. |
overrides |
Per-account changes, keyed by account id: { "<account_id>": { "text": "…" } }. |
options |
Platform options: instagram.type (FEED, REEL, STORY, CAROUSEL), facebook.type (FEED, REEL), linkedin.visibility (PUBLIC, CONNECTIONS) and linkedin.reshare_of (a post URN to reshare). |
workspace_id |
Defaults to the org's default workspace. |
Every platform's rules are checked before anything is queued. A problem comes back as 400 validation_failed, with one entry in details.fields for each account that has one:
{
"error": {
"code": "validation_failed",
"message": "One or more fields are not valid.",
"request_id": "6b1f0c2e-3d4a-4b5c-8d9e-0f1a2b3c4d5e",
"details": { "fields": [{ "field": "account_ids[0]", "code": "media_required", "message": "Instagram posts need at least one image or video.", "account_id": "0192a7c1-6a10-7b22-8e31-4c5d6e7f8a01" }] }
}
}Things that are allowed but worth a look come back on the post as warnings, with the same shape.
Links
- Facebook Pages:
link_urlis sent as the post's link and Facebook builds the preview.link_previewis ignored. - LinkedIn: LinkedIn does not read the page behind a link. With
link_preview.title(and optionallydescriptionandthumbnail_media_id) you get an article post. Without it the URL is added to the text as plain text. - Instagram: captions cannot hold clickable links. The URL is added to the caption as plain text, with a warning.
- With
link_urlandmedia_idstogether, the media wins and the URL is added to the text.
Scheduling and drafts
scheduled_atmakes the postSCHEDULED. Change it withPATCH /v1/posts/{postId}while the post isDRAFTorSCHEDULED."draft": truesaves aDRAFT. Publish it later withPOST /v1/posts/{postId}/publish.- Cancel a
DRAFTorSCHEDULEDpost withPOST /v1/posts/{postId}/cancel; it becomesCANCELLED. - Editing or cancelling a post that is already publishing or published returns
409 invalid_state.
3. Follow the result
Publishing is asynchronous. The create call returns the post with its targets; read it again, or listen for events, to see the outcome.
GET /v1/posts/{postId}{
"id": "0192a7c2-1f00-7c33-9a44-5b6c7d8e9f01",
"status": "PUBLISHED",
"targets": [
{
"id": "0192a7c2-1f00-7c33-9a44-5b6c7d8e9f11",
"account_id": "0192a7c1-6a10-7b22-8e31-4c5d6e7f8a01",
"platform": "FACEBOOK_PAGE",
"status": "PUBLISHED",
"permalink": "https://www.facebook.com/104857600123456/posts/998877665544332",
"published_at": "2026-11-02T09:00:04Z",
"error": null
}
]
}A target's status is PENDING, PUBLISHING, PUBLISHED or FAILED. A FAILED target has an error with a code and a message. The post's status follows its targets: PUBLISHED when all are published, PARTIAL when some are, FAILED when none are.
For events instead of polling, subscribe to post.published, post.partially_published, post.failed, post.target.published and post.target.failed. See Webhooks.
To try a failed target again, call POST /v1/posts/{postId}/targets/{targetId}/retry. If it failed with publish_outcome_unknown, it may in fact have been published, and a retry can publish it twice. Check the account on the platform first.
Idempotency
Send an Idempotency-Key header on POST /v1/posts. If your request times out and you repeat it with the same key, you get the original response back instead of a second post. Keys are scoped to the org and remembered for 24 hours. Reusing a key with a different request body gives 409 idempotency_conflict. More in Errors, limits and idempotency.
Delete a post
DELETE /v1/posts/{postId}?remote=trueThe post is always removed on our side. With remote=true we also try to delete each published target on its platform, and report each result:
{
"id": "0192a7c2-1f00-7c33-9a44-5b6c7d8e9f01",
"status": "PUBLISHED",
"remote_results": [
{ "target_id": "0192a7c2-1f00-7c33-9a44-5b6c7d8e9f11", "account_id": "0192a7c1-6a10-7b22-8e31-4c5d6e7f8a01", "platform": "FACEBOOK_PAGE", "status": "DELETED", "code": null, "message": null }
]
}statusis the post's status before it was deleted.- Each result is
DELETED,SKIPPEDorFAILED, with the reason incode.SKIPPEDcodes arenot_published,already_deletedandunsupported_for_platform. - A platform refusing never fails the request.
- Instagram has no delete API, so Instagram targets are always
SKIPPEDwithunsupported_for_platform. Facebook only lets us delete posts that were created through the API; for any other post the result isFAILEDwithpost_not_owned_by_app. - With
remote=false(the default),remote_resultsis[].
Next: Comments and inbox