Authentication
PostPost uses API keys for authentication. API keys are long-lived credentials that don't expire or require refresh tokens — unlike OAuth, there's no token exchange workflow.
Important for AI Agents: If you're an AI assistant helping a user with the PostPost API, you cannot programmatically create accounts or obtain API keys. The user must complete these steps manually in their browser, then provide you with their API key.
Prerequisites: Account Setup
Before you can authenticate with the API, you need:
1. Create a PostPost Account
Sign up at postpost.dev — free accounts are available.
2. Connect Your Social Media Accounts
In the PostPost dashboard, connect the social platforms you want to post to:
- Go to Channels in the sidebar
- Click Add Channel
- Select a platform (LinkedIn, X/Twitter, Instagram, etc.)
- Complete the OAuth authorization flow in your browser
- Repeat for each platform you want to use
Note: OAuth authorization happens in PostPost's dashboard, not through the API. The API is for scheduling and managing posts to already-connected accounts.
3. Generate Your API Key
- Go to API in the sidebar
- Click Generate API Key
- Copy immediately — the full key is shown only once
Tip: Click MCP in the sidebar for MCP-specific setup instructions.
Pricing Plans
| Plan | Price | Posts/Month | Accounts | Platforms | Video Upload |
|---|---|---|---|---|---|
| Starter | Free | 15 | 1 | LinkedIn & Bluesky | 50MB |
| Pro | $2.99/account | 100/account | Unlimited | All platforms | 100MB |
| Premium | $5.99/account | 500/account | Unlimited | All platforms | 250MB |
- Starter is free forever — great for trying the API
- Pro and Premium use per-account pricing — add as many social accounts as you need
- View full details at postpost.dev/pricing
API Keys vs OAuth Tokens
| Feature | PostPost API Keys | OAuth Tokens |
|---|---|---|
| Expiration | Never expires | Typically 1 hour |
| Refresh needed | No | Yes (refresh token flow) |
| How to get | Dashboard → API | OAuth authorization flow |
| Format | sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k... (~70 chars) | eyJhbG... (JWT) |
Key point: You can generate up to 10 active API keys from the dashboard. Each key works independently and never expires.
Getting Your API Key
Once you have an account with connected social platforms:
- Sign in at postpost.dev
- Go to API in the sidebar
- Click Generate API Key
- Copy immediately — the full key is shown only once
Why No Programmatic Key Generation?
Technically, a REST endpoint exists (POST /auth/api-keys) for creating API keys, but it requires dashboard session authentication — not API key auth. This means you cannot use an existing API key to create new API keys; you must be logged in through the browser. The endpoint powers the dashboard's "Generate API Key" button.
This is intentional for security reasons:
| Concern | Why Session-Auth-Only |
|---|---|
| Key theft prevention | API key auth cannot generate new keys — compromised code can't escalate access |
| Human verification | Dashboard login ensures a human authorized the key |
| Audit trail | All key generation is logged with user/IP information |
| Accidental exposure | Prevents automated systems from creating excess keys |
For automation and CI/CD: Store your API key in environment variables or secrets managers (AWS Secrets Manager, HashiCorp Vault, GitHub Secrets). The key never expires, so you only need to set it up once.
# GitHub Actions secret
gh secret set PUBLORA_API_KEY
# AWS Secrets Manager
aws secretsmanager create-secret --name postpost-api-key --secret-string "sk_..."
# Kubernetes secret
kubectl create secret generic postpost --from-literal=api-key="sk_..."API Key Management Endpoints
These endpoints manage API keys and require dashboard session authentication (not API key auth). They power the dashboard UI and cannot be called with an API key.
| Method | Endpoint | Description |
|---|---|---|
| GET | /auth/api-keys | List all API keys for the authenticated user |
| POST | /auth/api-keys | Create a new API key |
| PATCH | /auth/api-keys/:keyId | Update an API key (e.g., rename) |
| DELETE | /auth/api-keys/:keyId | Revoke (soft-delete) an API key |
Response formats:
-
POST
/auth/api-keys(Create):{ "message": "API key created successfully", "apiKey": { "_id": "65f8a1b2c3d4e5f6a7b8c9d0", "name": "My Key", "keyPrefix": "sk_kzq5mjw_a1b2c3d4", "createdAt": "2026-02-22T10:00:00.000Z", "lastUsedAt": null, "rawKey": "sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k..." } }Important:
rawKeyis only returned once at creation time. Store it immediately. -
GET
/auth/api-keys(List):{ "apiKeys": [ { "_id": "65f8a1b2c3d4e5f6a7b8c9d0", "name": "My Key", "keyPrefix": "sk_kzq5mjw_a1b2c3d4", "createdAt": "2026-02-22T10:00:00.000Z", "lastUsedAt": "2026-02-23T14:30:00.000Z" } ] } -
DELETE
/auth/api-keys/:keyId(Revoke):{ "message": "API key revoked successfully" } -
PATCH
/auth/api-keys/:keyId(Update):{ "message": "API key updated successfully", "apiKey": { "_id": "65f8a1b2c3d4e5f6a7b8c9d0", "name": "Renamed Key", "keyPrefix": "sk_kzq5mjw_a1b2c3d4", "createdAt": "2026-02-22T10:00:00.000Z", "lastUsedAt": "2026-02-23T14:30:00.000Z" } }
Key name handling:
- Names are HTML-sanitized to prevent XSS
- If no name is provided (or the name is empty), the key defaults to
"Default" - Maximum name length: 100 characters
Key Format
sk_kzq5mjw_a1b2c3d4e5f6g7h8i9j0.k1l2m3n4o5p6...- Starts with
sk_prefix - Contains a base36-encoded timestamp segment
- Followed by an underscore and a random hex string
- Then a dot separator and another random hex string
- Format:
sk_<timestamp_base36>_<random_hex>.<random_hex> - Total length: ~70 characters
- Maximum of 10 active API keys per user
- Name length: Maximum 100 characters (enforced by the API)
Note: The authentication middleware does not enforce the
sk_prefix — it performs a two-step verification: first a prefix lookup against stored key prefixes, then a bcrypt comparison of the full key. Legacy keys created before thesk_format was introduced will still work. Client-side validation of thesk_prefix (shown below) is a best practice but not strictly required.
Key Validation (Before Making Requests)
Validate your key format before making API calls:
function isValidPostPostKey(key) {
if (!key || typeof key !== 'string') return false;
if (!key.startsWith('sk_')) return false;
if (key.length < 20) return false;
return true;
}
// Usage
const apiKey = process.env.PUBLORA_API_KEY;
if (!isValidPostPostKey(apiKey)) {
throw new Error('Invalid PUBLORA_API_KEY format. Key must start with sk_');
}def is_valid_postpost_key(key):
"""Validate PostPost API key format."""
if not key or not isinstance(key, str):
return False
if not key.startswith('sk_'):
return False
if len(key) < 20:
return False
return True
# Usage
api_key = os.environ.get('PUBLORA_API_KEY')
if not is_valid_postpost_key(api_key):
raise ValueError('Invalid PUBLORA_API_KEY format. Key must start with sk_')Two Ways to Authenticate
PostPost provides two interfaces that use the same API key with different header formats:
| Interface | Header Format | Use Case |
|---|---|---|
| REST API | x-api-key: sk_... | Direct HTTP requests |
| MCP Server | Authorization: Bearer sk_... | AI assistants (Claude, Cursor) |
REST API Authentication
For direct HTTP calls to api.postpost.dev:
curl https://api.postpost.dev/api/v1/platform-connections \
-H "x-api-key: sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k..."const response = await fetch('https://api.postpost.dev/api/v1/platform-connections', {
headers: {
'x-api-key': process.env.PUBLORA_API_KEY
}
});response = requests.get(
'https://api.postpost.dev/api/v1/platform-connections',
headers={'x-api-key': os.environ['PUBLORA_API_KEY']}
)MCP Authentication
For MCP clients connecting to mcp.postpost.dev:
{
"mcpServers": {
"postpost": {
"type": "http",
"url": "https://mcp.postpost.dev",
"headers": {
"Authorization": "Bearer sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k..."
}
}
}
}Why different headers? The REST API uses a custom header (x-api-key) for simplicity. The MCP server uses the standard Authorization: Bearer header because MCP clients expect OAuth-style headers.
MCP Client Identification
MCP clients send an additional header x-postpost-client: mcp to identify themselves. This triggers an mcpAccess entitlement check on the server side. If the account does not have MCP access enabled, the server responds with 403 "MCP access is not enabled for this account".
You do not need to set this header manually — MCP-compatible clients (Claude Desktop, Cursor, etc.) send it automatically.
The x-postpost-client header value is stored as req.apiUser.client in the request context (defaults to "api" when not set). This value is available to all downstream route handlers for client-specific logic or logging.
Internal Request Context (req.apiUser)
After successful authentication, the middleware attaches a req.apiUser object with the following fields:
| Field | Type | Description |
|---|---|---|
userId | ObjectId | The effective user ID (target user if workspace, otherwise key owner) |
ownerId | ObjectId | The billing owner's user ID (may differ from the direct key owner for managed workspace users, resolved via resolveWorkspaceEntitlementsByActor) |
ownerUser | Object | Subset of the actorUser document (the user whose API key was used, resolved via workspace entitlements) containing { _id, permissions, isAdmin } |
billingOwnerUser | Object | The user responsible for billing. Contains { _id, permissions, isAdmin, entitlements }. May differ from ownerUser in workspace setups |
keyPrefix | string | The prefix portion of the API key used for lookup (falls back to "legacy" for keys without a dot separator) |
isWorkspace | boolean | true if acting on behalf of a managed user via x-postpost-user-id |
client | string | Client identifier — "mcp" or "api" (default) |
entitlements | Object | Feature flags and plan capabilities for the billing owner |
Note: These fields are internal to the server — they are not returned in API responses. They are documented here for contributors and advanced integrators who may encounter them in error messages or logs.
Complete Authentication Workflow
Step 1: Store Your Key Securely
# Add to ~/.bashrc or ~/.zshrc
export PUBLORA_API_KEY="sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k..."Or use a .env file (add to .gitignore):
# .env
PUBLORA_API_KEY=sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k...Step 2: Verify Your Key Works
Test authentication by fetching your connected platforms:
async function verifyAuthentication() {
const apiKey = process.env.PUBLORA_API_KEY;
// Validate format first
if (!apiKey?.startsWith('sk_')) {
console.error('Error: API key must start with sk_');
console.error('Get your key at: https://postpost.dev → API');
return false;
}
try {
const response = await fetch('https://api.postpost.dev/api/v1/platform-connections', {
headers: { 'x-api-key': apiKey }
});
if (response.status === 401) {
console.error('Error: Invalid API key');
console.error('Check your key or generate a new one at postpost.dev');
return false;
}
if (!response.ok) {
console.error(`Error: HTTP ${response.status}`);
return false;
}
const data = await response.json();
console.log(`✓ Authenticated successfully`);
console.log(`✓ ${data.connections.length} connected platform(s)`);
return true;
} catch (error) {
console.error('Error: Connection failed -', error.message);
return false;
}
}import os
import requests
def verify_authentication():
"""Verify API key is valid and working."""
api_key = os.environ.get('PUBLORA_API_KEY')
# Validate format first
if not api_key or not api_key.startswith('sk_'):
print('Error: API key must start with sk_')
print('Get your key at: https://postpost.dev → API')
return False
try:
response = requests.get(
'https://api.postpost.dev/api/v1/platform-connections',
headers={'x-api-key': api_key}
)
if response.status_code == 401:
print('Error: Invalid API key')
print('Check your key or generate a new one at postpost.dev')
return False
response.raise_for_status()
data = response.json()
print(f'✓ Authenticated successfully')
print(f"✓ {len(data['connections'])} connected platform(s)")
return True
except requests.RequestException as e:
print(f'Error: Connection failed - {e}')
return False
# Run verification
verify_authentication()Step 3: Make Your First API Call
// After verification succeeds, make API calls
const response = await fetch('https://api.postpost.dev/api/v1/list-posts', {
headers: { 'x-api-key': process.env.PUBLORA_API_KEY }
});
const { posts } = await response.json();
console.log(`Found ${posts.length} posts`);Workspace Authentication
For B2B workspaces managing multiple users, add the user ID header. There is no separate "workspace API key" — you use the same API key. The middleware checks actorUser (resolved via workspace entitlements), which may differ from the direct key owner for managed users.
| Header | Value | Purpose |
|---|---|---|
x-api-key | Your API key | Authenticates your account |
x-postpost-user-id | Managed user's ObjectId | Specifies which user to act as |
The target user must have their parentUser field set to the API key owner. This parentUser relationship is what authorizes the key owner to act on behalf of that user. Without it, the request will be rejected with a 403 error.
Note: Passing your own user ID as
x-postpost-user-idis a no-op — the middleware detects the self-reference and skips the workspace/parentUser check entirely. The request proceeds as if the header were not set.
const response = await fetch('https://api.postpost.dev/api/v1/create-post', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.PUBLORA_API_KEY,
'x-postpost-user-id': '507f1f77bcf86cd799439011' // Managed user (must have parentUser set to key owner)
},
body: JSON.stringify({
content: 'Posted on behalf of managed user',
platforms: ['twitter-123456']
})
});Error Handling
Authentication Errors
| Status | Error | Cause | Solution |
|---|---|---|---|
| 400 | "Invalid x-postpost-user-id" | x-postpost-user-id header is not a valid ObjectId | Provide a valid 24-character hex ObjectId |
| 401 | "API key is required" | Missing x-api-key header | Include the x-api-key header with your API key |
| 401 | "Invalid API key" | Key is wrong or has been revoked | Generate a new key at API in sidebar |
| 401 | "Invalid API key owner" | User associated with the key could not be found | Contact support; the key owner account may be deleted |
| 403 | "API access is not enabled for this account" | Account lacks the API access entitlement | Upgrade your plan at postpost.dev/pricing |
| 403 | "Your current plan does not include API access" | Plan does not include API access (returned by key management endpoints) | Upgrade your plan at postpost.dev/pricing |
| 403 | "MCP access is not enabled for this account" | Account lacks MCP access entitlement (sent via MCP client) | Upgrade your plan to include MCP access |
| 403 | "Workspace access is not enabled for this key" | Used x-postpost-user-id but key owner does not have workspacesEnabled | Enable workspace access on your account or remove the header. Note: Admin users (isAdmin: true) automatically bypass this check and have workspace access regardless of the workspacesEnabled permission |
| 403 | "User is not managed by key" | Target user's parentUser is not set to the key owner | Ensure the managed user has parentUser set to your user ID |
| 400 | "Maximum of 10 active API keys allowed" | Attempted to create an API key when 10 active keys already exist | Delete an existing key before creating a new one |
| 400 | "Name must be a string with maximum 100 characters" | POST /auth/api-keys — name is not a string or exceeds 100 characters | Provide a valid name under 100 characters |
| 404 | "User not found" | POST /auth/api-keys — authenticated user could not be found in the database | Contact support; the account may be deleted |
| 400 | "Name is required" | PATCH /auth/api-keys/:keyId — name field is missing or empty | Provide a non-empty name |
| 400 | "Name must be maximum 100 characters" | PATCH /auth/api-keys/:keyId — name exceeds 100 characters | Shorten the name to 100 characters or fewer |
| 400 | "Invalid key ID format" | PATCH or DELETE /auth/api-keys/:keyId — keyId is not a valid ObjectId | Provide a valid 24-character hex ObjectId |
| 404 | "API key not found" | DELETE or PATCH /auth/api-keys/:keyId — the specified key doesn't exist or has been revoked | Verify the key ID is correct and the key has not already been deleted |
| 500 | "Internal server error" | Unexpected error during authentication middleware | Retry the request; if persistent, contact support |
Handling Auth Errors in Code
async function postpostRequest(endpoint, options = {}) {
const apiKey = process.env.PUBLORA_API_KEY;
if (!apiKey?.startsWith('sk_')) {
throw new Error('PUBLORA_API_KEY not set or invalid format');
}
const response = await fetch(`https://api.postpost.dev/api/v1${endpoint}`, {
...options,
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
...options.headers
}
});
if (response.status === 401) {
throw new Error('Invalid API key. Generate a new one at postpost.dev → API');
}
if (response.status === 403) {
const data = await response.json();
if (data.error?.includes('API access is not enabled')) {
throw new Error('API access not enabled. Upgrade at postpost.dev/pricing');
}
throw new Error(data.error || 'Access denied');
}
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
return response.json();
}class PostPostAuthError(Exception):
"""Raised when authentication fails."""
pass
def postpost_request(endpoint, method='GET', **kwargs):
"""Make authenticated request with proper error handling."""
api_key = os.environ.get('PUBLORA_API_KEY')
if not api_key or not api_key.startswith('sk_'):
raise PostPostAuthError('PUBLORA_API_KEY not set or invalid format')
response = requests.request(
method,
f'https://api.postpost.dev/api/v1{endpoint}',
headers={
'x-api-key': api_key,
'Content-Type': 'application/json'
},
**kwargs
)
if response.status_code == 401:
raise PostPostAuthError('Invalid API key. Generate a new one at postpost.dev → API')
if response.status_code == 403:
data = response.json()
if 'API access is not enabled' in data.get('error', ''):
raise PostPostAuthError('API access not enabled. Upgrade at postpost.dev/pricing')
raise PostPostAuthError(data.get('error', 'Access denied'))
response.raise_for_status()
return response.json()Retry Logic with Exponential Backoff
For production applications, implement retry logic:
async function postpostRequestWithRetry(endpoint, options = {}, maxRetries = 3) {
const apiKey = process.env.PUBLORA_API_KEY;
if (!apiKey?.startsWith('sk_')) {
throw new Error('PUBLORA_API_KEY not set or invalid format');
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(`https://api.postpost.dev/api/v1${endpoint}`, {
...options,
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
...options.headers
}
});
// Don't retry auth errors - they won't succeed
if (response.status === 401 || response.status === 403) {
const data = await response.json();
throw new Error(data.error || `Auth error: ${response.status}`);
}
// Retry on rate limit
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
console.log(`Rate limited. Waiting ${retryAfter}s...`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
// Retry on server errors
if (response.status >= 500 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.log(`Server error. Retry ${attempt}/${maxRetries} in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
continue;
}
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
return response.json();
} catch (error) {
if (attempt === maxRetries) throw error;
if (error.message.includes('Auth error')) throw error; // Don't retry auth errors
}
}
}Security Best Practices
Do
- Store keys in environment variables
- Use
.envfiles (added to.gitignore) - Rotate keys periodically
- Use separate keys for development and production
- Validate key format before making requests
Don't
- Hardcode keys in source code
- Commit keys to version control
- Share keys in chat, email, or public forums
- Use keys in client-side JavaScript (browsers)
- Log full API keys (mask them:
sk_kzq5mjw_a1b2...****)
Key Storage
// Good: Environment variable
const apiKey = process.env.PUBLORA_API_KEY;
// Bad: Hardcoded
const apiKey = 'sk_kzq5mjw_a1b2c3d4e5f6.7h8i9j0k...'; // Never do this!Managing API Keys
You can generate multiple API keys — useful for different environments or applications.
If your key is compromised:
- Go to postpost.dev → API in the sidebar
- Delete the compromised key
- Click Generate API Key to create a new one
- Update your environment variables with the new key
Note: Key deletion is a soft-delete — it sets a
revokedAttimestamp rather than removing the key from the database. Revoked keys immediately stop working but remain in the audit trail.
Quick Reference
| What | Value |
|---|---|
| REST API Base URL | https://api.postpost.dev/api/v1 |
| MCP Server URL | https://mcp.postpost.dev |
| REST API Header | x-api-key: sk_... |
| MCP Header | Authorization: Bearer sk_... |
| Key Format | sk_<timestamp_base36>_<random_hex>.<random_hex> (~70 chars) |
| Key Expiration | Never (until revoked) |
| Max Keys | 10 active keys per user |
| Get Key | postpost.dev → API |