Install
openclaw skills install publoraPublora API — schedule and publish social media posts across 10 platforms (X/Twitter, LinkedIn, Instagram, Threads, TikTok, YouTube, Facebook, Bluesky, Mastodon, Telegram). Use this skill when the user wants to post, schedule, draft, bulk-schedule, manage workspace users, configure webhooks, or retrieve LinkedIn analytics via Publora.
openclaw skills install publoraPublora is an affordable REST API for scheduling and publishing social media posts
across 10 platforms (Pinterest is listed internally but not yet supported). Base URL: https://api.publora.com/api/v1
| Plan | Price | Posts/Month | Platforms |
|---|---|---|---|
| Starter | Free | 15 | LinkedIn & Bluesky |
| Pro | $2.99/account | 100/account | All |
| Premium | $5.99/account | 500/account | All |
ℹ️ Starter gives API access for LinkedIn and Bluesky. Twitter/X requires Pro or Premium (explicitly excluded from Starter). See publora.com/pricing.
All requests require the x-publora-key header. Keys start with sk_ (format: sk_xxxxxxx.xxxxxx...).
curl https://api.publora.com/api/v1/platform-connections \
-H "x-publora-key: sk_YOUR_KEY"
Get your key: publora.com → Settings → API Keys → Generate API Key. ⚠️ Copy immediately — shown only once.
Always call this first to get valid platform IDs before posting.
const res = await fetch('https://api.publora.com/api/v1/platform-connections', {
headers: { 'x-publora-key': 'sk_YOUR_KEY' }
});
const { connections } = await res.json();
// connections[i].platformId → e.g. "linkedin-ABC123", "twitter-456"
// Also returns: tokenStatus, tokenExpiresIn, lastSuccessfulPost, lastError
Platform IDs look like: twitter-123, linkedin-ABC, instagram-456, threads-789, etc.
Omit scheduledTime to publish right away:
await fetch('https://api.publora.com/api/v1/create-post', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' },
body: JSON.stringify({
content: 'Your post content here',
platforms: ['twitter-123', 'linkedin-ABC']
})
});
Include scheduledTime in ISO 8601 UTC — must be in the future:
await fetch('https://api.publora.com/api/v1/create-post', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' },
body: JSON.stringify({
content: 'Scheduled post content',
platforms: ['twitter-123', 'linkedin-ABC'],
scheduledTime: '2026-03-16T10:00:00.000Z'
})
});
// Response: { postGroupId: "pg_abc123", scheduledTime: "..." }
Omit scheduledTime — post is created as draft. Schedule it later:
// Create draft
const { postGroupId } = await createPost({ content, platforms });
// Schedule later
await fetch(`https://api.publora.com/api/v1/update-post/${postGroupId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' },
body: JSON.stringify({ status: 'scheduled', scheduledTime: '2026-03-16T10:00:00.000Z' })
});
Filter, paginate and sort your scheduled/published posts:
// GET /api/v1/list-posts
// Query params: status, platform, fromDate, toDate, page, limit, sortBy, sortOrder
const res = await fetch(
'https://api.publora.com/api/v1/list-posts?status=scheduled&platform=twitter&page=1&limit=20',
{ headers: { 'x-publora-key': 'sk_YOUR_KEY' } }
);
const { posts, pagination } = await res.json();
// pagination: { page, limit, totalItems, totalPages, hasNextPage, hasPrevPage }
Valid statuses: draft, scheduled, published, failed, partially_published
# Get post details
GET /api/v1/get-post/:postGroupId
# Delete post (also removes media from storage)
DELETE /api/v1/delete-post/:postGroupId
Debug failed or partially published posts:
const res = await fetch(
`https://api.publora.com/api/v1/post-logs/${postGroupId}`,
{ headers: { 'x-publora-key': 'sk_YOUR_KEY' } }
);
const { logs } = await res.json();
Verify a platform connection is healthy before posting:
const res = await fetch(
'https://api.publora.com/api/v1/test-connection/linkedin-ABC123',
{ method: 'POST', headers: { 'x-publora-key': 'sk_YOUR_KEY' } }
);
// Returns: { status: "ok"|"error", message, permissions, tokenExpiresIn }
from datetime import datetime, timedelta, timezone
import requests
HEADERS = { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' }
base_date = datetime(2026, 3, 16, 10, 0, 0, tzinfo=timezone.utc)
posts = ['Monday post', 'Tuesday post', 'Wednesday post', 'Thursday post', 'Friday post']
for i, content in enumerate(posts):
scheduled_time = base_date + timedelta(days=i)
requests.post('https://api.publora.com/api/v1/create-post', headers=HEADERS, json={
'content': content,
'platforms': ['twitter-123', 'linkedin-ABC'],
'scheduledTime': scheduled_time.isoformat()
})
All media (images and videos) use a 3-step pre-signed upload workflow:
Step 1: POST /api/v1/create-post → get postGroupId
Step 2: POST /api/v1/get-upload-url → get uploadUrl
Step 3: PUT {uploadUrl} with file bytes (no auth needed for S3)
import requests
HEADERS = { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' }
# Step 1: Create post
post = requests.post('https://api.publora.com/api/v1/create-post', headers=HEADERS, json={
'content': 'Check this out!',
'platforms': ['instagram-456'],
'scheduledTime': '2026-03-15T14:30:00.000Z'
}).json()
post_group_id = post['postGroupId']
# Step 2: Get pre-signed upload URL
upload = requests.post('https://api.publora.com/api/v1/get-upload-url', headers=HEADERS, json={
'fileName': 'photo.jpg',
'contentType': 'image/jpeg',
'type': 'image', # or 'video'
'postGroupId': post_group_id
}).json()
# Step 3: Upload directly to S3 (no auth header needed)
with open('./photo.jpg', 'rb') as f:
requests.put(upload['uploadUrl'], headers={'Content-Type': 'image/jpeg'}, data=f)
For carousels: call get-upload-url N times with the same postGroupId.
X/Twitter and Threads support threading. Three methods:
(1/N) markers (e.g. (1/3)).---: Use --- on its own line to define exact split points.[n/m]: Use [1/3], [2/3] markers — Publora preserves them as-is.// Manual split example
body: JSON.stringify({
content: 'First tweet.\n\n---\n\nSecond tweet.\n\n---\n\nThird tweet.',
platforms: ['twitter-123']
})
⚠️ Threads Restriction: Multi-threaded nested posts are temporarily unavailable on Threads (connected replies). Single posts, images, and carousels work normally. Contact support@publora.com for updates.
// Post statistics — queryTypes is an ARRAY (not a string; 'ALL' is invalid here)
// Use queryType (singular string) for one metric, queryTypes (array) for multiple
await fetch('https://api.publora.com/api/v1/linkedin-post-statistics', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' },
body: JSON.stringify({
postedId: 'urn:li:share:7123456789',
platformId: 'linkedin-ABC123',
queryTypes: ['IMPRESSION', 'MEMBERS_REACHED', 'RESHARE', 'REACTION', 'COMMENT']
// OR: queryType: 'IMPRESSION' ← singular, returns { count: 123 }
// Multi-metric response: { metrics: { IMPRESSION: 4521, MEMBERS_REACHED: 3200, ... } }
})
});
// Profile summary (followers + aggregated stats)
await fetch('https://api.publora.com/api/v1/linkedin-profile-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' },
body: JSON.stringify({ platformId: 'linkedin-ABC123' })
});
Available analytics endpoints:
| Endpoint | Description |
|---|---|
POST /linkedin-post-statistics | Impressions, reactions, reshares for a post |
POST /linkedin-account-statistics | Aggregated account metrics |
POST /linkedin-followers | Follower count and growth |
POST /linkedin-profile-summary | Combined profile overview |
POST /linkedin-create-reaction | React to a post |
DELETE /linkedin-delete-reaction | Remove a reaction |
Get real-time notifications when posts are published, fail, or tokens are expiring.
// Create a webhook
await fetch('https://api.publora.com/api/v1/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_YOUR_KEY' },
body: JSON.stringify({
name: 'My webhook',
url: 'https://myapp.com/webhooks/publora',
events: ['post.published', 'post.failed', 'token.expiring']
})
});
// Returns: { webhook: { _id, name, url, events, secret, isActive } }
// Save the `secret` — it's only shown once. Use it to verify webhook signatures.
Valid events: post.scheduled, post.published, post.failed, token.expiring
| Endpoint | Method | Description |
|---|---|---|
/webhooks | GET | List all webhooks |
/webhooks | POST | Create webhook |
/webhooks/:id | PATCH | Update webhook |
/webhooks/:id | DELETE | Delete webhook |
/webhooks/:id/regenerate-secret | POST | Rotate webhook secret |
Max 10 webhooks per account.
Manage multiple users under your workspace account. Contact serge@publora.com to enable Workspace API access.
// Create a managed user (returns HTTP 201)
const { user } = await fetch('https://api.publora.com/api/v1/workspace/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-publora-key': 'sk_CORP_KEY' },
body: JSON.stringify({ username: 'client@example.com', displayName: 'Acme Corp' })
}).then(r => r.json());
// user._id is the MongoDB ObjectId (24-char hex), e.g. "6626a1f5e4b0c91a2d3f4567"
// Generate connection URL for user to connect their social accounts
const { connectionUrl } = await fetch(
`https://api.publora.com/api/v1/workspace/users/${user._id}/connection-url`,
{ method: 'POST', headers: { 'x-publora-key': 'sk_CORP_KEY' } }
).then(r => r.json());
// Post on behalf of managed user
await fetch('https://api.publora.com/api/v1/create-post', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-publora-key': 'sk_CORP_KEY',
'x-publora-user-id': user._id // ← key header for acting on behalf of a user
},
body: JSON.stringify({ content: 'Post for Acme Corp!', platforms: ['linkedin-XYZ'] })
});
Workspace endpoints:
| Endpoint | Method | Description |
|---|---|---|
/workspace/users | GET | List managed users |
/workspace/users | POST | Create managed user |
/workspace/users/:userId | DELETE | Detach managed user (preserves user record, removes workspace association) |
/workspace/users/:userId/api-key | POST | Generate per-user API key |
/workspace/users/:userId/connection-url | POST | Generate OAuth connection link |
Each managed user has a dailyPostsLeft field (default: 100) — note this is informational only and not enforced as an actual posting limit. Real limits are workspace-level: monthlyPosts, scheduledPosts, scheduleHorizonDays — enforced at scheduling time. Never expose your workspace key client-side — use per-user API keys for client-facing scenarios.
⚠️ API limits are often stricter than native app limits. Always design against these.
| Platform | Char Limit | Max Images | Video Max | Text Only? |
|---|---|---|---|---|
| Twitter/X | 280 (25K Premium) | 4 × 5MB | 2 min / 512MB | ✅ |
| 3,000 | 10 × 5MB | 30 min / 500MB | ✅ | |
| 2,200 | 10 × 8MB (JPEG only) | 3 min (180s) Reels / 60s Stories / 300MB | ❌ | |
| Threads | 500 | 20 × 8MB | 5 min / 500MB | ✅ |
| TikTok | 2,200 | Video only | 10 min / 4GB | ❌ |
| YouTube | 5,000 desc | Video only | 12h / 256GB | ❌ |
| 63,206 | 10 × 10MB | 45 min / 2GB | ✅ | |
| Bluesky | 300 | 4 × 1MB | 3 min / 100MB | ✅ |
| Mastodon | 500 | 4 × 16MB | ~99MB | ✅ |
| Telegram | 4,096 (1,024 captions) | 10 × 10MB | 50MB (Bot API) | ✅ |
For full limits detail, see the docs/guides/platform-limits.md in the Publora API Docs.
For platform-specific settings, limits, and examples:
publora-linkedin — LinkedIn posts + analytics + reactionspublora-twitter — X/Twitter posts & threadspublora-instagram — Instagram images/reels/carouselspublora-threads — Threads postspublora-tiktok — TikTok videospublora-youtube — YouTube videospublora-facebook — Facebook page postspublora-bluesky — Bluesky postspublora-mastodon — Mastodon postspublora-telegram — Telegram channelsdraft — Not scheduled yetscheduled — Waiting to publishpublished — Successfully postedfailed — Publishing failed (check /post-logs)partially_published — Some platforms failed| Code | Meaning |
|---|---|
| 400 | Invalid request (check scheduledTime format, required fields) |
| 401 | Invalid or missing API key |
| 403 | Plan limit reached or Workspace API not enabled |
| 404 | Post/resource not found |
| 429 | Platform rate limit exceeded |