REST API

Overview

The wisp REST API provides programmatic access to create, update, and delete blog posts on your blog without using the web interface. This is perfect for:

  • Automating content publishing workflows
  • Integrating with external CMS or publishing tools
  • Building custom content management applications
  • Syncing content from other platforms
ℹ️

Paid Feature

REST API access is only available on paid plans. Free plan users will need to upgrade their subscription to use API keys.

Getting Started

1. Create an API Key

To use the REST API, you'll first need to create an API key:

  1. Navigate to Settings → API Keys in your wisp dashboard
  2. Click "New Key" button
  3. Enter a descriptive name (e.g., "Production Key", "CI/CD Pipeline")
  4. Click "Create Key"
  5. Copy your API key - it will be displayed in full and can be copied anytime
⚠️

Security Best Practices

  • • Keep your API keys secure and never commit them to version control
  • • Use environment variables to store API keys in your applications
  • • Create separate keys for different environments (development, staging, production)
  • • Regularly rotate API keys and delete unused keys

2. Authentication

All REST API requests must include your API key in the Authorization header:

Authorization: Bearer wisp_your_api_key_here

Base URL

https://www.wisp.blog/api/rest/v1

Endpoints

Blog Posts

Create a Post

Creates a new blog post.

Endpoint: POST /posts

Request Body:

{
  "title": "My New Blog Post",
  "content": "<p>This is the content of my blog post with HTML formatting.</p>",
  "slug": "my-new-blog-post",
  "description": "A brief description of the post",
  "publishedAt": "2025-12-10T00:00:00Z",
  "image": "https://example.com/image.jpg",
  "metadata": "{\"author\": \"John Doe\"}",
  "tagIds": ["tag-id-1", "tag-id-2"]
}

Parameters:

ParameterTypeRequiredDescription
titlestringYesThe title of the blog post
contentstringYesHTML content of the post
slugstringYesURL-friendly identifier for the post
descriptionstringNoShort description/excerpt
publishedAtstring (ISO 8601)NoPublication date. If null, post is a draft
imagestringNoURL of the featured image
metadatastring (JSON)NoAdditional metadata as JSON string (max 512 chars)
tagIdsarray of stringsNoArray of tag IDs to associate with the post

Example Request:

curl -X POST https://www.wisp.blog/api/rest/v1/posts \
  -H "Authorization: Bearer wisp_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with wisp",
    "content": "<h2>Introduction</h2><p>Welcome to wisp!</p>",
    "slug": "getting-started-with-wisp",
    "description": "Learn the basics of wisp",
    "publishedAt": "2025-12-10T00:00:00Z"
  }'

Response (201 Created):

{
  "id": "clx123456789",
  "title": "Getting Started with wisp",
  "content": "<h2>Introduction</h2><p>Welcome to wisp!</p>",
  "slug": "getting-started-with-wisp",
  "description": "Learn the basics of wisp",
  "image": null,
  "metadata": null,
  "authorId": "usr_123456789",
  "teamId": "team_123456789",
  "publishedAt": "2025-12-10T00:00:00.000Z",
  "createdAt": "2025-12-10T10:30:00.000Z",
  "updatedAt": "2025-12-10T10:30:00.000Z"
}

List Posts

Retrieves a paginated list of blog posts.

Endpoint: GET /posts

Query Parameters:

ParameterTypeDefaultDescription
pagenumber1Page number for pagination
limitnumber10Number of posts per page
tagstring-Filter by tag name (can be specified multiple times)
querystring-Search query for title or content

Example Request:

curl -X GET "https://www.wisp.blog/api/rest/v1/posts?page=1&limit=20" \
  -H "Authorization: Bearer wisp_your_api_key_here"

Response (200 OK):

{
  "posts": [
    {
      "id": "clx123456789",
      "title": "Getting Started with wisp",
      "slug": "getting-started-with-wisp",
      "description": "Learn the basics of wisp",
      "image": null,
      "publishedAt": "2025-12-10T00:00:00.000Z",
      "createdAt": "2025-12-10T10:30:00.000Z",
      "updatedAt": "2025-12-10T10:30:00.000Z",
      "author": {
        "name": "John Doe",
        "email": "john@example.com"
      },
      "tags": [
        {
          "tag": {
            "id": "tag_123",
            "name": "Tutorial"
          }
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 50,
    "totalPages": 3
  }
}

Get a Post

Retrieves a single blog post by slug.

Endpoint: GET /posts/:slug

Example Request:

curl -X GET "https://www.wisp.blog/api/rest/v1/posts/getting-started-with-wisp" \
  -H "Authorization: Bearer wisp_your_api_key_here"

Response (200 OK):

{
  "id": "clx123456789",
  "title": "Getting Started with wisp",
  "content": "<h2>Introduction</h2><p>Welcome to wisp!</p>",
  "slug": "getting-started-with-wisp",
  "description": "Learn the basics of wisp",
  "image": null,
  "metadata": null,
  "publishedAt": "2025-12-10T00:00:00.000Z",
  "createdAt": "2025-12-10T10:30:00.000Z",
  "updatedAt": "2025-12-10T10:30:00.000Z",
  "author": {
    "name": "John Doe",
    "email": "john@example.com"
  },
  "tags": [
    {
      "tag": {
        "id": "tag_123",
        "name": "Tutorial"
      }
    }
  ]
}

Update a Post

Updates an existing blog post.

Endpoint: PUT /posts/:slug

Request Body: Same as Create Post

Example Request:

curl -X PUT "https://www.wisp.blog/api/rest/v1/posts/getting-started-with-wisp" \
  -H "Authorization: Bearer wisp_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with wisp (Updated)",
    "content": "<h2>Introduction</h2><p>Welcome to wisp! This is updated content.</p>",
    "slug": "getting-started-with-wisp",
    "description": "Updated description",
    "publishedAt": "2025-12-10T00:00:00Z"
  }'

Response (200 OK): Same as Get Post response


Delete a Post

Deletes a blog post by slug.

Endpoint: DELETE /posts/:slug

Example Request:

curl -X DELETE "https://www.wisp.blog/api/rest/v1/posts/getting-started-with-wisp" \
  -H "Authorization: Bearer wisp_your_api_key_here"

Response (200 OK):

{
  "success": true
}

Image Uploads

Get Signed Upload URL

Retrieves a signed URL for uploading images to wisp's storage.

Endpoint: POST /uploads/signed-url

Request Body:

{
  "path": "my-image.jpg"
}

Parameters:

ParameterTypeRequiredDescription
pathstringYesThe filename/path for the image (e.g., "blog-image.jpg")

Example Request:

curl -X POST "https://www.wisp.blog/api/rest/v1/uploads/signed-url" \
  -H "Authorization: Bearer wisp_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"path": "hero-image.jpg"}'

Response (200 OK):

{
  "uploadURL": "https://upload.imagedelivery.net/..."
}

Upload Flow:

  1. Call the signed URL endpoint to get an upload URL
  2. POST your image file to the returned uploadURL
  3. Extract the image URL from the upload response
  4. Use that URL in your blog post's image field

Complete Example:

# Step 1: Get signed upload URL
SIGNED_URL=$(curl -X POST "https://www.wisp.blog/api/rest/v1/uploads/signed-url" \
  -H "Authorization: Bearer wisp_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"path": "hero.jpg"}' | jq -r '.uploadURL')

# Step 2: Upload image to signed URL
IMAGE_RESPONSE=$(curl -X POST "$SIGNED_URL" \
  -F "file=@./hero.jpg" \
  -H "accept: application/json")

# Step 3: Extract image URL
IMAGE_URL=$(echo $IMAGE_RESPONSE | jq -r '.result.variants[0]')

# Step 4: Create post with image
curl -X POST "https://www.wisp.blog/api/rest/v1/posts" \
  -H "Authorization: Bearer wisp_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d "{
    \"title\": \"Post with Image\",
    \"content\": \"<p>Content here</p>\",
    \"slug\": \"post-with-image\",
    \"image\": \"$IMAGE_URL\"
  }"

Error Handling

The API returns standard HTTP status codes:

Status CodeDescription
200Success
201Created successfully
400Bad request - Invalid input
401Unauthorized - Invalid or missing API key
403Forbidden - Free plan or restricted account
404Not found - Resource doesn't exist
409Conflict - Duplicate slug
500Internal server error

Error Response Format:

{
  "error": "Error message describing what went wrong",
  "details": {} // Optional additional error details
}

Common Errors:

  • Invalid API key: Check that your API key is correct and included in the Authorization header
  • Free plan restriction: Upgrade to a paid plan to use the REST API
  • Duplicate slug: The slug already exists for another post in your blog
  • Invalid metadata: Ensure metadata is valid JSON if provided

Rate Limits

API requests are currently limited to:

  • 100 requests per minute per API key

If you exceed the rate limit, you'll receive a 429 Too Many Requests response.


Best Practices

1. Use Descriptive Slugs

Slugs should be URL-friendly and descriptive:

  • getting-started-with-nextjs
  • post-1 or 123abc

2. Handle Errors Gracefully

Always check response status codes and handle errors appropriately:

const response = await fetch("https://www.wisp.blog/api/rest/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(postData),
});

if (!response.ok) {
  const error = await response.json();
  console.error("API Error:", error.error);
  // Handle error appropriately
}

const post = await response.json();

3. Use Environment Variables

Store API keys securely:

// .env
WISP_API_KEY = wisp_your_api_key_here;

// In your code
const apiKey = process.env.WISP_API_KEY;

4. Draft Before Publishing

Create posts as drafts first by omitting publishedAt, then update them to publish:

// Create draft
const draft = await createPost({
  title,
  content,
  slug,
  publishedAt: null,
});

// Publish later
await updatePost(slug, {
  ...draft,
  publishedAt: new Date().toISOString(),
});

SDK Examples

Node.js / TypeScript

import fetch from "node-fetch";

const WISP_API_KEY = process.env.WISP_API_KEY;
const BASE_URL = "https://www.wisp.blog/api/rest/v1";

async function createPost(data: {
  title: string;
  content: string;
  slug: string;
  description?: string;
  publishedAt?: string;
}) {
  const response = await fetch(`${BASE_URL}/posts`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${WISP_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error);
  }

  return await response.json();
}

async function listPosts(page = 1, limit = 10) {
  const response = await fetch(
    `${BASE_URL}/posts?page=${page}&limit=${limit}`,
    {
      headers: {
        Authorization: `Bearer ${WISP_API_KEY}`,
      },
    },
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error);
  }

  return await response.json();
}

// Usage
const newPost = await createPost({
  title: "My New Post",
  content: "<p>Hello World</p>",
  slug: "my-new-post",
  publishedAt: new Date().toISOString(),
});

const posts = await listPosts(1, 20);

Python

import requests
import os
from typing import Optional, Dict, Any

WISP_API_KEY = os.getenv('WISP_API_KEY')
BASE_URL = 'https://www.wisp.blog/api/rest/v1'

def create_post(
    title: str,
    content: str,
    slug: str,
    description: Optional[str] = None,
    published_at: Optional[str] = None
) -> Dict[str, Any]:
    response = requests.post(
        f'{BASE_URL}/posts',
        headers={
            'Authorization': f'Bearer {WISP_API_KEY}',
            'Content-Type': 'application/json',
        },
        json={
            'title': title,
            'content': content,
            'slug': slug,
            'description': description,
            'publishedAt': published_at,
        }
    )

    response.raise_for_status()
    return response.json()

def list_posts(page: int = 1, limit: int = 10) -> Dict[str, Any]:
    response = requests.get(
        f'{BASE_URL}/posts',
        headers={'Authorization': f'Bearer {WISP_API_KEY}'},
        params={'page': page, 'limit': limit}
    )

    response.raise_for_status()
    return response.json()

# Usage
new_post = create_post(
    title='My New Post',
    content='<p>Hello World</p>',
    slug='my-new-post',
    published_at='2025-12-10T00:00:00Z'
)

posts = list_posts(page=1, limit=20)

Use Cases

Automated Publishing

Schedule and publish content automatically:

// Publish scheduled posts
async function publishScheduledPosts() {
  const drafts = await listPosts();
  const now = new Date();

  for (const post of drafts.posts) {
    if (!post.publishedAt && shouldPublish(post)) {
      await updatePost(post.slug, {
        ...post,
        publishedAt: now.toISOString(),
      });
      console.log(`Published: ${post.title}`);
    }
  }
}

Content Migration

Import content from another platform:

// Migrate posts from another CMS
async function migrateFromOtherCMS(posts) {
  for (const oldPost of posts) {
    try {
      await createPost({
        title: oldPost.title,
        content: convertToHTML(oldPost.body),
        slug: sanitizeSlug(oldPost.slug),
        description: oldPost.excerpt,
        publishedAt: oldPost.published_date,
      });
      console.log(`Migrated: ${oldPost.title}`);
    } catch (error) {
      console.error(`Failed to migrate: ${oldPost.title}`, error);
    }
  }
}

CI/CD Integration

Publish documentation from your codebase:

# .github/workflows/publish-docs.yml
name: Publish Documentation

on:
  push:
    branches: [main]
    paths:
      - "docs/**"

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Publish to wisp
        env:
          WISP_API_KEY: ${{ secrets.WISP_API_KEY }}
        run: |
          node scripts/publish-docs.js