PostPost

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:

  1. Go to Channels in the sidebar
  2. Click Add Channel
  3. Select a platform (LinkedIn, X/Twitter, Instagram, etc.)
  4. Complete the OAuth authorization flow in your browser
  5. 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

  1. Go to API in the sidebar
  2. Click Generate API Key
  3. Copy immediately — the full key is shown only once

Tip: Click MCP in the sidebar for MCP-specific setup instructions.

Pricing Plans

PlanPricePosts/MonthAccountsPlatformsVideo Upload
StarterFree151LinkedIn & Bluesky50MB
Pro$2.99/account100/accountUnlimitedAll platforms100MB
Premium$5.99/account500/accountUnlimitedAll platforms250MB
  • 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

FeaturePostPost API KeysOAuth Tokens
ExpirationNever expiresTypically 1 hour
Refresh neededNoYes (refresh token flow)
How to getDashboard → APIOAuth authorization flow
Formatsk_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:

  1. Sign in at postpost.dev
  2. Go to API in the sidebar
  3. Click Generate API Key
  4. 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:

ConcernWhy Session-Auth-Only
Key theft preventionAPI key auth cannot generate new keys — compromised code can't escalate access
Human verificationDashboard login ensures a human authorized the key
Audit trailAll key generation is logged with user/IP information
Accidental exposurePrevents 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.

MethodEndpointDescription
GET/auth/api-keysList all API keys for the authenticated user
POST/auth/api-keysCreate a new API key
PATCH/auth/api-keys/:keyIdUpdate an API key (e.g., rename)
DELETE/auth/api-keys/:keyIdRevoke (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: rawKey is 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 the sk_ format was introduced will still work. Client-side validation of the sk_ 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:

InterfaceHeader FormatUse Case
REST APIx-api-key: sk_...Direct HTTP requests
MCP ServerAuthorization: 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:

FieldTypeDescription
userIdObjectIdThe effective user ID (target user if workspace, otherwise key owner)
ownerIdObjectIdThe billing owner's user ID (may differ from the direct key owner for managed workspace users, resolved via resolveWorkspaceEntitlementsByActor)
ownerUserObjectSubset of the actorUser document (the user whose API key was used, resolved via workspace entitlements) containing { _id, permissions, isAdmin }
billingOwnerUserObjectThe user responsible for billing. Contains { _id, permissions, isAdmin, entitlements }. May differ from ownerUser in workspace setups
keyPrefixstringThe prefix portion of the API key used for lookup (falls back to "legacy" for keys without a dot separator)
isWorkspacebooleantrue if acting on behalf of a managed user via x-postpost-user-id
clientstringClient identifier — "mcp" or "api" (default)
entitlementsObjectFeature 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.

HeaderValuePurpose
x-api-keyYour API keyAuthenticates your account
x-postpost-user-idManaged user's ObjectIdSpecifies 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-id is 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

StatusErrorCauseSolution
400"Invalid x-postpost-user-id"x-postpost-user-id header is not a valid ObjectIdProvide a valid 24-character hex ObjectId
401"API key is required"Missing x-api-key headerInclude the x-api-key header with your API key
401"Invalid API key"Key is wrong or has been revokedGenerate a new key at API in sidebar
401"Invalid API key owner"User associated with the key could not be foundContact support; the key owner account may be deleted
403"API access is not enabled for this account"Account lacks the API access entitlementUpgrade 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 workspacesEnabledEnable 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 ownerEnsure 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 existDelete 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 charactersProvide a valid name under 100 characters
404"User not found"POST /auth/api-keys — authenticated user could not be found in the databaseContact support; the account may be deleted
400"Name is required"PATCH /auth/api-keys/:keyId — name field is missing or emptyProvide a non-empty name
400"Name must be maximum 100 characters"PATCH /auth/api-keys/:keyId — name exceeds 100 charactersShorten the name to 100 characters or fewer
400"Invalid key ID format"PATCH or DELETE /auth/api-keys/:keyId — keyId is not a valid ObjectIdProvide 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 revokedVerify the key ID is correct and the key has not already been deleted
500"Internal server error"Unexpected error during authentication middlewareRetry 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 .env files (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:

  1. Go to postpost.dev → API in the sidebar
  2. Delete the compromised key
  3. Click Generate API Key to create a new one
  4. Update your environment variables with the new key

Note: Key deletion is a soft-delete — it sets a revokedAt timestamp rather than removing the key from the database. Revoked keys immediately stop working but remain in the audit trail.

Quick Reference

WhatValue
REST API Base URLhttps://api.postpost.dev/api/v1
MCP Server URLhttps://mcp.postpost.dev
REST API Headerx-api-key: sk_...
MCP HeaderAuthorization: Bearer sk_...
Key Formatsk_<timestamp_base36>_<random_hex>.<random_hex> (~70 chars)
Key ExpirationNever (until revoked)
Max Keys10 active keys per user
Get Keypostpost.dev → API

On this page