Skip to main content
Hayon lets you write once and publish to multiple platforms simultaneously. Posts go through a well-defined lifecycle — from draft to published — with media stored in AWS S3 and delivery handled by a background job queue.

Composing a post

1

Open the post editor

From the dashboard, click New Post to open the post composer. You can also navigate directly to /dashboard and use the compose button in the top navigation.
2

Write your content

Enter your post text in the main content area. The content.text field is the primary copy that goes to all selected platforms. You can use hashtags inline — they are passed as plain text and each platform renders them natively.
3

Upload media (optional)

Click Add Media to attach images or videos. Hayon generates a pre-signed S3 upload URL for each file before it is stored. See Media uploads below for the full flow.
4

Select platforms

Choose one or more platforms to publish to. At least one platform must be selected. The selectedPlatforms field accepts any combination of:bluesky · threads · instagram · facebook · mastodon · tumblr
5

Add platform-specific content (optional)

Expand the Per-platform settings panel to override the default caption or attach different media for a specific platform. These overrides are stored in platformSpecificContent as a keyed object (e.g. platformSpecificContent.instagram.text).
6

Schedule or publish

Choose one of three actions:
  • Publish now — sets status to PENDING and enqueues immediately.
  • Schedule — sets status to SCHEDULED and stores a scheduledAt timestamp.
  • Save as draft — sets status to DRAFT; nothing is enqueued.

Media uploads

Hayon uses a pre-signed URL flow to upload media directly from your browser to AWS S3, keeping large files off the application server.
1

Request an upload URL

Your client calls POST /api/posts/media/upload with the file’s MIME type:
request body
{
  "contentType": "image/jpeg"
}
The server returns a time-limited pre-signed uploadUrl, a public s3Url, and an s3Key.
response
{
  "data": {
    "uploadUrl": "https://s3.amazonaws.com/...?X-Amz-Signature=...",
    "s3Url": "https://cdn.example.com/posts/user123/post-media.jpg",
    "s3Key": "posts/user123/post-media.jpg",
    "contentType": "image/jpeg"
  }
}
2

Upload directly to S3

Perform a PUT request to the uploadUrl with the raw file bytes and a Content-Type header matching the requested MIME type. No backend involvement is needed at this step.
curl -X PUT "<uploadUrl>" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
3

Reference the file in your post

Include the returned s3Url and s3Key in your post’s content.mediaItems array when calling POST /api/posts.
Allowed MIME types: image/png, image/jpeg, image/jpg, image/webp, video/mp4, video/quicktime. Any other type returns a 400 error.

The post data model

Every post is stored in MongoDB with the following structure.
FieldTypeDescription
userIdObjectIdReference to the owning user.
content.textstringMain post body. Required.
content.mediaItemsarrayAttached media. See media item fields below.
selectedPlatformsstring[]Platforms this post targets.
platformSpecificContentobjectPer-platform text/media overrides.
scheduledAtDateFuture publish time. Null for immediate posts.
timezonestringIANA timezone string. Defaults to UTC.
statusstringOverall post status. See status lifecycle.
platformStatusesarrayPer-platform delivery status.
correlationIdstringUnique ID for deduplication (sparse index).
metadata.sourcestringweb or api.
createdAtDateManaged by Mongoose timestamps.
updatedAtDateManaged by Mongoose timestamps.
FieldTypeDescription
s3KeystringS3 object key: posts/user123/image.jpg.
s3UrlstringFull CDN URL to the file.
mimeTypestringFile MIME type.
originalFilenamestringOriginal filename from the upload.
sizeBytesnumberFile size in bytes.
widthnumberImage width in pixels (optional).
heightnumberImage height in pixels (optional).
durationnumberVideo length in seconds (optional).
Each entry in platformStatuses tracks delivery for a single platform.
FieldTypeDescription
platformstringPlatform name (e.g. bluesky).
statusstringpending · processing · completed · failed · deleted
platformPostIdstringID returned by the platform API after publishing.
platformPostUrlstringDirect URL to the published post.
errorstringError message if delivery failed.
attemptCountnumberNumber of delivery attempts.
lastAttemptAtDateTimestamp of the most recent attempt.
completedAtDateTimestamp when the platform accepted the post.
lastAnalyticsFetchDateWhen analytics were last fetched for this platform.

Post status lifecycle

The status field on a post reflects its overall delivery state across all selected platforms.
DRAFT ──────────────────────────────────────────────────────────────────┐
  │ (publish / schedule)                                                  │
  ▼                                                                       │
PENDING / SCHEDULED → PROCESSING → COMPLETED                             │
                                  → PARTIAL_SUCCESS  (some platforms ok)  │
                                  → FAILED           (all platforms failed)│
                    → CANCELLED ◄──────────────────────────────────────────┘

DRAFT

Saved but not submitted to the queue. The post can be edited freely and published later.

PENDING

Queued for immediate delivery. The worker picks it up as soon as a consumer is available.

SCHEDULED

Has a future scheduledAt time. The scheduler enqueues it at the right moment.

PROCESSING

At least one platform worker is actively publishing.

COMPLETED

All platforms accepted the post successfully.

PARTIAL_SUCCESS

Some platforms succeeded and at least one failed.

FAILED

All platforms failed. Use retry to re-enqueue.

CANCELLED

Manually cancelled before the worker consumed it.

Platform-specific content

You can supply different text or media for individual platforms using platformSpecificContent. The worker merges this with the base content at publish time: platform-specific text overrides content.text, and platform-specific mediaItems override content.mediaItems.
Example: different captions per platform
{
  "content": {
    "text": "Default caption for all platforms.",
    "mediaItems": []
  },
  "selectedPlatforms": ["instagram", "bluesky"],
  "platformSpecificContent": {
    "instagram": {
      "text": "Instagram-specific caption with more hashtags. #photography #travel"
    },
    "bluesky": {
      "text": "Short Bluesky post, under 300 chars."
    }
  },
  "status": "PENDING"
}

Creating a post via API

POST /api/posts
curl -X POST https://api.yourhayon.com/api/posts \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "content": {
      "text": "Hello from Hayon!",
      "mediaItems": [
        {
          "s3Url": "https://cdn.example.com/posts/user123/photo.jpg",
          "s3Key": "posts/user123/photo.jpg",
          "mimeType": "image/jpeg"
        }
      ]
    },
    "selectedPlatforms": ["bluesky", "mastodon"],
    "scheduledAt": "2026-04-01T10:00:00.000Z",
    "timezone": "America/New_York",
    "status": "SCHEDULED"
  }'
A successful response returns the new postId, the resolved status, and the scheduledAt value:
201 response
{
  "data": {
    "postId": "664f1a2b3c4d5e6f7a8b9c0d",
    "status": "SCHEDULED",
    "scheduledAt": "2026-04-01T14:00:00.000Z"
  }
}
Users are subject to a plan-based post creation limit tracked in user.usage.postsCreated vs user.limits.maxPosts. Exceeding the limit returns a 429 response.

Saving as draft

Pass "status": "DRAFT" in the request body to save without publishing. Draft posts are stored in MongoDB but never enqueued. You can edit them later and change the status to PENDING or SCHEDULED to trigger delivery.
Draft request body
{
  "content": { "text": "Work in progress..." },
  "selectedPlatforms": ["threads"],
  "status": "DRAFT"
}
When a draft is later published (status changes from DRAFT to PENDING or SCHEDULED), Hayon resets createdAt to the current time so it appears at the top of the post history.

Viewing post history

The History page at /history lists all your posts with their current status. You can filter by status and sort by creation date. Use GET /api/posts with optional query parameters:
ParameterDefaultDescription
page1Page number.
limit20Results per page.
statusFilter by post status.
sortBycreatedAtField to sort by.
sortOrderdescasc or desc.

Build docs developers (and LLMs) love