/**
* Postiz Public API Client
*
* Official Postiz Public API Documentation: https://docs.postiz.com/public-api
*
* Base URL: https://api.postiz.com/public/v1
* Authentication: Authorization header with API key (no "Bearer" prefix)
* Rate Limit: 30 requests per hour
*
* Available endpoints:
* - GET /integrations - Get all channels
* - GET /posts - List posts (requires ISO 8601 date format)
* - POST /posts - Create/update posts
* - DELETE /posts/:id - Delete post
* - POST /upload - Upload media file
* - POST /upload-from-url - Upload media from URL
*/
import axios, { AxiosInstance, AxiosError } from 'axios';
import { config } from './config.js';
import {
PostizPost,
PostizMedia,
ListPostsParams,
ListMediaParams,
} from './types.js';
/**
* Postiz Post Response from API
*/
interface PostizPostResponse {
id: string;
date: string;
status: 'draft' | 'scheduled' | 'published' | 'queued';
posts: Array<{
id: string;
content: string;
settings?: {
media?: Array<{
id: string;
url: string;
path?: string;
}>;
};
}>;
[key: string]: any;
}
/**
* Postiz Integration (Channel) Response
*/
interface PostizIntegration {
id: string;
name: string;
identifier: string;
picture: string;
disabled: boolean;
profile: string;
}
/**
* Postiz Public API Client
*/
export class PostizClient {
private client: AxiosInstance;
constructor(baseUrl?: string, apiToken?: string) {
const url = baseUrl || config.postizBaseUrl;
const token = apiToken || config.postizApiToken;
this.client = axios.create({
baseURL: url,
headers: {
// Postiz uses API key directly, no "Bearer" prefix
'Authorization': token,
'Content-Type': 'application/json',
},
timeout: 30000,
});
// Add response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
this.handleError(error);
return Promise.reject(error);
}
);
}
/**
* Handle API errors
*/
private handleError(error: AxiosError): void {
if (error.response) {
const status = error.response.status;
const data = error.response.data as any;
const message = data?.message || data?.error || 'Unknown error';
console.error(`Postiz API Error [${status}]:`, message);
} else if (error.request) {
console.error('Postiz API Error: No response from server');
} else {
console.error('Postiz API Error:', error.message);
}
}
/**
* Get all integrations (channels)
*/
async getIntegrations(): Promise<PostizIntegration[]> {
try {
const response = await this.client.get<PostizIntegration[]>('/integrations');
return response.data;
} catch (error) {
console.error('Failed to get integrations:', error);
throw new Error(`Failed to fetch integrations: ${(error as Error).message}`);
}
}
/**
* List posts from Postiz with date range
*
* @param params - Filter parameters (ISO 8601 date format required)
* @returns Array of posts
*/
async listPosts(params: ListPostsParams = {}): Promise<PostizPost[]> {
try {
// Postiz requires ISO 8601 date format
const queryParams: Record<string, any> = {};
// Set default date range if not provided (last 30 days to today)
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
queryParams.startDate = startDate.toISOString().split('T')[0];
queryParams.endDate = endDate.toISOString().split('T')[0];
const response = await this.client.get<{ posts: PostizPostResponse[] }>('/posts', {
params: queryParams,
});
// Postiz API returns {posts: [...]} not a flat array
const postsArray = response.data.posts || [];
// Convert Postiz post format to our internal format
const posts: PostizPost[] = postsArray.map((post) => ({
...post,
// Extract media IDs from posts
mediaIds: this.extractMediaIdsFromPost(post),
}));
// Filter by statuses if provided
if (params.statuses && params.statuses.length > 0) {
return posts.filter((post) => params.statuses!.includes(post.status));
}
return posts;
} catch (error) {
console.error('Failed to list posts:', error);
throw new Error(`Failed to fetch posts: ${(error as Error).message}`);
}
}
/**
* Extract media IDs from Postiz post response
*/
private extractMediaIdsFromPost(post: PostizPostResponse): string[] {
const mediaIds: string[] = [];
if (post.posts && Array.isArray(post.posts)) {
for (const subPost of post.posts) {
if (subPost.settings?.media && Array.isArray(subPost.settings.media)) {
for (const media of subPost.settings.media) {
if (media.id) {
mediaIds.push(media.id);
}
}
}
}
}
return mediaIds;
}
/**
* List all media from posts
*
* Note: Postiz Public API doesn't have a dedicated /media endpoint
* We extract media information from posts instead
*/
async listMedia(params: ListMediaParams = {}): Promise<PostizMedia[]> {
try {
const posts = await this.listPosts({});
const mediaMap = new Map<string, PostizMedia>();
// Extract unique media from all posts
for (const post of posts) {
if ((post as any).posts && Array.isArray((post as any).posts)) {
for (const subPost of (post as any).posts) {
if (subPost.settings?.media && Array.isArray(subPost.settings.media)) {
for (const media of subPost.settings.media) {
if (media.id && !mediaMap.has(media.id)) {
mediaMap.set(media.id, {
id: media.id,
url: media.url || '',
path: media.path,
deleted: false,
deletedAt: null,
});
}
}
}
}
}
}
return Array.from(mediaMap.values());
} catch (error) {
console.error('Failed to list media:', error);
throw new Error(`Failed to fetch media: ${(error as Error).message}`);
}
}
/**
* Delete a post by ID
*
* Note: Deleting a post will also remove its associated media
*/
async deletePost(id: string): Promise<void> {
try {
await this.client.delete(`/posts/${id}`);
console.log(`Successfully deleted post: ${id}`);
} catch (error) {
console.error(`Failed to delete post ${id}:`, error);
throw new Error(`Failed to delete post ${id}: ${(error as Error).message}`);
}
}
/**
* Delete media by ID
*
* WARNING: Postiz Public API doesn't have /media/:id DELETE endpoint
* This method finds and deletes posts that contain this media
*/
async deleteMedia(mediaId: string): Promise<void> {
try {
// Find posts that contain this media
const allPosts = await this.listPosts({});
const postsWithMedia = allPosts.filter((post) => {
const mediaIds = this.extractMediaIdsFromPost(post as any);
return mediaIds.includes(mediaId);
});
if (postsWithMedia.length === 0) {
console.warn(`No posts found containing media ${mediaId}`);
return;
}
// Delete all posts containing this media
console.log(`Deleting ${postsWithMedia.length} posts containing media ${mediaId}...`);
for (const post of postsWithMedia) {
await this.deletePost(post.id);
}
console.log(`Successfully deleted media ${mediaId} by removing ${postsWithMedia.length} posts`);
} catch (error) {
console.error(`Failed to delete media ${mediaId}:`, error);
throw new Error(`Failed to delete media ${mediaId}: ${(error as Error).message}`);
}
}
/**
* Get a single post by ID
*/
async getPost(id: string): Promise<PostizPost> {
try {
// Postiz Public API doesn't have GET /posts/:id
// We need to fetch all posts and filter
const posts = await this.listPosts({});
const post = posts.find((p) => p.id === id);
if (!post) {
throw new Error(`Post ${id} not found`);
}
return post;
} catch (error) {
console.error(`Failed to get post ${id}:`, error);
throw new Error(`Failed to fetch post ${id}: ${(error as Error).message}`);
}
}
/**
* Upload media file
*
* @param file - File data (FormData)
* @returns Upload response with media URL/ID
*/
async uploadMedia(formData: FormData): Promise<{ id: string; url: string }> {
try {
const response = await this.client.post<{ id: string; url: string }>('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} catch (error) {
console.error('Failed to upload media:', error);
throw new Error(`Failed to upload media: ${(error as Error).message}`);
}
}
/**
* Upload media from URL
*/
async uploadMediaFromUrl(url: string): Promise<{ id: string; url: string }> {
try {
const response = await this.client.post<{ id: string; url: string }>('/upload-from-url', {
url,
});
return response.data;
} catch (error) {
console.error('Failed to upload media from URL:', error);
throw new Error(`Failed to upload media from URL: ${(error as Error).message}`);
}
}
}
/**
* Default singleton instance
*/
export const postizClient = new PostizClient();