# Migrate from Zernio (Late) to the OmniSocials API

> Index of all OmniSocials docs pages: https://docs.omnisocials.com/llms.txt

This guide is for developers who post through the Zernio API (formerly Late, getlate.dev) and want to move to the OmniSocials API. Most of the work is renaming fields. The create-post body is flatter, accounts are referenced by channel ID, scheduled times go in as UTC, and webhook signatures use a different header. The biggest change is that social accounts are connected in the OmniSocials dashboard, not through an API connect flow. Zernio facts on this page come from docs.zernio.com and were checked on October 21, 2026.

If your code still uses the Late names (`LATE_API_KEY`, the `late` Python package), the same steps apply.

## Concepts

| Zernio | OmniSocials | Notes |
|--------|-------------|-------|
| API key (`sk_...`) | API key (`omsk_live_...`) | Both go in `Authorization: Bearer`. An OmniSocials key belongs to one workspace. |
| Restricted key (`zrk_...`) | Key scopes | Pick scopes such as `posts:write` or `media:read` when you create the key. See [Authentication](https://docs.omnisocials.com/authentication.md). |
| Profile | Workspace | A workspace is one set of connected accounts, usually one brand or client. Create workspaces in the dashboard. |
| Account (`_id`) | Account (`id`, for example `368204_instagram`) | You can also pass the bare channel ID, such as `instagram`. A workspace has one account per channel. |
| Connect URL (`GET /v1/connect/{platform}`) | Dashboard connection | Accounts are connected only in the dashboard, never through the API. |
| `platforms[]` on a post | `accounts` on a post | A flat array of account IDs or channel IDs. |
| `customContent` per platform | `content` object keyed by channel | `default` is the fallback text. |
| Queue and slots | None | `GET /analytics/best-times` recommends slots. You pick the time. |
| Post status `publishing` | `posting` | |
| Post status `partial` | `warning` | Published on some channels. `errors` names the rest. |
| Webhook settings | Webhooks | Registered with `POST /webhooks`. |

## Authentication

Both APIs use a Bearer token in the `Authorization` header.

```text
Zernio:      Authorization: Bearer sk_...
OmniSocials: Authorization: Bearer omsk_live_...
```

The difference is in what a key can reach. With Zernio, you pass a `profileId` to pick a profile. An OmniSocials key is bound to one workspace, which you choose when you create the key. If you serve several brands, create one workspace and one key per brand, and store the key next to the brand in your own database. `GET /accounts` returns `workspace_id` and `workspace_name`, so you can check which workspace a key belongs to.

Create keys under **Settings → API** in [app.omnisocials.com](https://app.omnisocials.com). A key's scopes cannot be edited later. If you need more, create a new key.

## Endpoint mapping

All OmniSocials paths are relative to `https://api.omnisocials.com/v1`. Zernio paths are relative to `https://zernio.com/api`.

| Zernio | OmniSocials | Notes |
|--------|-------------|-------|
| `POST /v1/posts` | `POST /posts/create` | `POST /posts` is an alias. `POST /posts/create-and-publish` publishes at once. |
| `GET /v1/posts` | `GET /posts` | Filter with `status`. Page with `limit` (max 100) and `offset`. |
| `GET /v1/posts/{postId}` | `GET /posts/{id}` | |
| `PUT /v1/posts/{postId}` | `PATCH /posts/{id}` | Drafts and scheduled posts only. Send only the fields you change. |
| `DELETE /v1/posts/{postId}` | `DELETE /posts/{id}` | Removes the post from OmniSocials and cancels a scheduled publish. It does not delete a live post on the platform. |
| `POST /v1/posts/{postId}/retry` | `POST /posts/{id}/retry` | Retries only the failed channels, up to 3 times per channel. |
| `POST /v1/posts/{postId}/unpublish` | None | Delete live posts from the dashboard or in the platform's app. |
| `POST /v1/posts/{postId}/edit` | None | Published posts cannot be changed. |
| `POST /v1/posts/bulk-upload` | None | Call `POST /posts/create` once per post. |
| `POST /v1/media/presign` | `POST /media/create-upload-url` | Or skip uploads and pass `media_urls` on the post. See below. |
| `GET /v1/profiles` and profile CRUD | None | Manage workspaces in the dashboard. |
| `GET /v1/accounts` | `GET /accounts` | Returns the key's workspace and its connected accounts. |
| `GET /v1/accounts/health` | `GET /accounts` | Read `status` and `needs_reconnect` on each account. |
| `GET /v1/accounts/follower-stats` | `GET /analytics/accounts` | |
| `GET /v1/connect/{platform}` | None | Connect accounts in the dashboard. |
| `GET /v1/queue/slots`, `GET /v1/queue/next-slot` | `GET /analytics/best-times` | Recommendations from your own history, not a queue. |
| `GET /v1/analytics` | `GET /analytics/overview`, `GET /analytics/posts/{id}`, `GET /analytics/posts?ids=` | Bulk reads take up to 100 post IDs. |
| `POST /v1/webhooks/settings` and CRUD | `POST /webhooks`, `GET`, `PATCH`, `DELETE /webhooks/{id}` | `POST /webhooks/{id}/rotate-secret` rotates the secret. |
| `GET /v1/webhooks/logs` | `GET /webhooks/{id}` | Returns `failure_count` and `last_failure`. There is no full delivery log. |
| `POST /v1/webhooks/test` | None | Subscribe to `post.scheduled` and schedule a post through the API. That sends a real event. |
| `POST /v1/tools/validate/post` | `POST /media/check` | Checks media against your connected channels. There is no full post validation call. |
| `GET /v1/inbox/conversations`, `GET /v1/inbox/comments` | `GET /inbox/conversations` | DMs, comments, and mentions in one list. Filter with `type`. |
| `POST /v1/inbox/conversations/{conversationId}/messages` | `POST /inbox/conversations/{conversationId}/reply` | Needs the opt-in `inbox:write` scope. See [Social inbox](https://docs.omnisocials.com/inbox.md). |

## Create a post

The same post in both APIs: one image to LinkedIn and Instagram, scheduled for 09:00 in Amsterdam on November 2, 2026.

Zernio:

```bash
curl -X POST https://zernio.com/api/v1/posts \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Golden hour at the pier.",
    "mediaItems": [
      { "url": "https://example.com/pier.jpg", "type": "image" }
    ],
    "platforms": [
      { "platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" },
      { "platform": "instagram", "accountId": "66b2e19d8c3f5a7e9d0b1c2e" }
    ],
    "scheduledFor": "2026-11-02T09:00:00",
    "timezone": "Europe/Amsterdam"
  }'
```

OmniSocials:

```bash
curl -X POST https://api.omnisocials.com/v1/posts/create \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Golden hour at the pier.",
    "media_urls": ["https://example.com/pier.jpg"],
    "accounts": ["linkedin", "instagram"],
    "schedule_at": "2026-11-02T08:00:00Z"
  }'
```

OmniSocials has no `timezone` field, so the local time is converted to UTC first (Amsterdam is UTC+1 in November). The response is a `201` with the post under `data` and `"status": "scheduled"`.

Per-platform text works the same way, but it lives in one `content` object instead of on each platform entry:

```json
{
  "content": {
    "default": "Golden hour at the pier.",
    "linkedin": "Golden hour at the pier. Shot on our team walk this week."
  },
  "accounts": ["linkedin", "instagram"],
  "media_urls": ["https://example.com/pier.jpg"],
  "publish_now": true
}
```

## Field mapping

| Zernio field | OmniSocials field | Notes |
|--------------|-------------------|-------|
| `content` | `content` | A string, or an object with `default` and channel keys. |
| `platforms[].platform` and `platforms[].accountId` | `accounts` | Account `id` from `GET /accounts`, or the channel ID. Alias: `channels`. |
| `platforms[].customContent` | `content.linkedin`, `content.x`, and so on | One key per channel ID. |
| `platforms[].customMedia` | `media_urls` or `media_ids` as an object keyed by channel | An empty array opts a channel out of media. |
| `platforms[].platformSpecificData` | A top-level object named after the channel | For example `tiktok.privacy_level`, `pinterest.board_id`, `youtube.privacy_status`. See [Platforms](https://docs.omnisocials.com/platforms.md). |
| `tiktokSettings` | `tiktok` | |
| `facebookSettings` | `facebook` | |
| `title` | `youtube.title`, `tiktok.title`, `pinterest.title` | Titles are set per channel. |
| `mediaItems[].url` | `media_urls` | Up to 10 URLs, 100 MB per file. |
| `mediaItems[].type` | None | The type is read from the file. |
| `scheduledFor` | `schedule_at` | ISO 8601 in UTC, for example `2026-11-02T08:00:00Z`. Alias: `scheduled_at`. |
| `timezone` | None | Convert to UTC before you send. |
| `publishNow` | `publish_now` | |
| `isDraft` | Leave out `schedule_at` and `publish_now` | A post without either is a draft. `draft: true` with `schedule_at` keeps the time on the calendar without publishing. |
| `queuedFromProfile`, `queueId` | None | Choose the time yourself, or use `GET /analytics/best-times`. |
| `recycling` | None | |
| `dryRun` | None | |
| `Idempotency-Key` header | None | Store the returned post `id` and check it before you retry a create. |

## Media and scheduling

**Media.** Zernio uploads take three calls: presign, `PUT` the file, then use the `publicUrl`. OmniSocials offers three shorter routes:

- **`media_urls` on the post.** Pass public URLs and OmniSocials downloads them. Up to 10 files, each up to 100 MB.
- **`POST /media/upload`.** A multipart upload with a `file` field, up to 100 MB. Use the returned `data.id` in `media_ids`.
- **`POST /media/upload-from-url`.** OmniSocials fetches a public URL of up to 1 GB. Files above about 100 MB come back with `status: "processing"`. Poll `GET /media/{id}` until it is `ready`.

For a client that cannot send your API key, `POST /media/create-upload-url` returns a single-use `upload_url` that expires after 10 minutes. POST a multipart `file` to it with no auth header.

The largest file OmniSocials takes is 1 GB, through `upload-from-url`. Zernio's presign limit is 5 GB. A PDF is turned into one image per page. On LinkedIn it publishes as a document. See [Media](https://docs.omnisocials.com/media.md).

**Scheduling.** Send `schedule_at` as an ISO 8601 time in UTC. OmniSocials publishes within one minute of that time. Two behaviors differ from Zernio:

- A time in the past returns `400 validation_error`. Zernio publishes a past `scheduledFor` at once. To publish now, send `publish_now: true`.
- There is no queue. Store your own slots, or read `GET /analytics/best-times?platform=instagram` for recommended times.

## Webhooks

Register a webhook with `POST /webhooks` and a list of events. The response returns a `secret` once. Store it.

```bash
curl -X POST https://api.omnisocials.com/v1/webhooks \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/omnisocials",
    "events": ["post.published", "post.failed"]
  }'
```

| Zernio event | OmniSocials event | Notes |
|--------------|-------------------|-------|
| `post.scheduled` | `post.scheduled` | Sent only for posts scheduled through the API. |
| `post.published` | `post.published` | Fires when at least one channel succeeds. |
| `post.partial` | `post.published` | `data.status` is `warning`. Check each entry in `data.targets`. |
| `post.failed` | `post.failed` | Every channel failed. |
| `post.platform.published`, `post.platform.failed` | None | Read the per-channel results in `data.targets`. |
| `post.cancelled`, `post.recycled`, `post.platform.deleted`, `post.external.*` | None | |
| `account.connected`, `account.disconnected` | None | Poll `GET /accounts` and check `needs_reconnect`. |
| `message.received`, `comment.received` | None | Poll `GET /inbox/conversations` or `GET /inbox/next`. |
| None | `post.approved`, `post.rejected` | Approval workflow outcomes. |

**Signatures.** Zernio sends `X-Zernio-Signature`, an HMAC-SHA256 of the raw body. OmniSocials sends `X-OmniSocials-Signature: t=<timestamp>,v1=<hex>`. The HMAC-SHA256 covers the timestamp, a dot, and the raw body, keyed with your webhook secret. Deduplicate on `X-OmniSocials-Delivery`. See [Webhooks](https://docs.omnisocials.com/webhooks.md#verifying-signatures) for verification code.

**Delivery.** Answer with a `2xx` within 10 seconds. Failed deliveries are retried twice, after 2 seconds and then 5 more. After that the event is dropped. Webhook URLs must use HTTPS.

## Platform coverage

As of October 21, 2026, from docs.zernio.com and the [OmniSocials platforms page](https://docs.omnisocials.com/platforms.md).

| Platform | Zernio | OmniSocials |
|----------|--------|-------------|
| Facebook | Yes (`facebook`) | Yes (`facebook`), Pages |
| Instagram | Yes (`instagram`) | Yes (`instagram`), Business and Creator accounts |
| Threads | Yes (`threads`) | Yes (`threads`) |
| LinkedIn | Yes (`linkedin`) | Yes, profile (`linkedin`) and company page (`linkedin_page`) |
| YouTube | Yes (`youtube`) | Shorts only (`youtube`) |
| TikTok | Yes (`tiktok`) | Yes (`tiktok`) |
| Pinterest | Yes (`pinterest`) | Yes (`pinterest`) |
| Bluesky | Yes (`bluesky`) | Yes (`bluesky`) |
| X | Yes (`twitter`) | Yes (`x`) |
| Google Business Profile | Yes (`googlebusiness`) | Yes (`google_business`) |
| Telegram | Yes, as a messaging channel (`telegram`) | Yes (`telegram`), a channel or group |
| Mastodon | Not listed | Yes (`mastodon`) |
| Reddit | Yes (`reddit`) | Coming soon |
| Snapchat | Closed beta (`snapchat`) | Coming soon |
| WhatsApp, Discord, Slack | Yes, as messaging channels | No |

## What is different

- **Account connection.** OmniSocials has no connect-URL endpoint. Accounts are connected in the dashboard. If your product lets end users connect their own accounts through your app, OmniSocials does not support that today.
- **No profiles API.** Workspaces are created in the dashboard, and each API key reaches one workspace.
- **One account per channel.** A workspace holds one account per channel ID. Two Instagram accounts need two workspaces.
- **Rate limit.** OmniSocials allows 100 requests per minute per key. Zernio's documented limit runs from 60 to 1,200 requests per minute, by the number of connected accounts. Limits are per key on OmniSocials, so you can split heavy work across keys. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md).
- **Fewer post operations.** There is no queue, recycling, bulk upload, idempotency key, dry run, edit of a published post, or unpublish through the API.
- **Times are UTC.** There is no `timezone` field, and past times are refused.
- **X links.** X posts whose text contains a link use prepaid credits. Posts without a link do not. See [X](https://docs.omnisocials.com/platforms/x.md).
- **Fewer webhook events.** There are no account, inbox, or per-platform events, no test endpoint, and no full delivery log.
- **Error shape.** Zernio errors are flat, with `error`, `type`, and `code` at the top level. OmniSocials errors nest under `error`, with `code`, `message`, and optional `details`. Branch on `error.code`.
- **No messaging or ads channels.** WhatsApp, Discord, Slack, SMS, and ads APIs are not part of OmniSocials. YouTube is Shorts only, and Reddit is not live yet.
- **No test mode.** Neither API documents a general sandbox. On OmniSocials, use drafts or a workspace with test accounts.
- **What OmniSocials adds.** Approval workflows with `post.approved` and `post.rejected` events, alt text per image, PDF to carousel and LinkedIn documents, automatic first comments, hashtag sets, Instagram comment-to-DM automations, and Mastodon.

## Migration steps

1. Create an OmniSocials account. Add one workspace for each Zernio profile.
2. Connect each social account in the dashboard, under **Settings → Organisation → Workspaces**.
3. Create one API key per workspace under **Settings → API**. Start with `posts:write`, `posts:read`, `media:write`, `accounts:read`, and `webhooks:manage`.
4. Call `GET /accounts` with each key. Map every Zernio `accountId` to an OmniSocials channel ID or account `id`.
5. Change the base URL and the key. Rename the post fields with the [field mapping](#field-mapping) table. Convert `scheduledFor` and `timezone` to a UTC `schedule_at`.
6. Replace the presign upload with `media_urls`, or with `POST /media/upload` and `media_ids`.
7. Register your webhooks with `POST /webhooks`. Swap the signature check and map the events.
8. Recreate the posts that are still scheduled in Zernio with `schedule_at`. Then delete them in Zernio with `DELETE /v1/posts/{postId}`, so nothing publishes twice.
9. Test with drafts first, since there is no test mode. Then publish one post with `publish_now: true` and check `published_urls` on `GET /posts/{id}`.
10. Handle `429` responses. Wait for the `Retry-After` header, then retry.

:::tip
Posts without `schedule_at` or `publish_now` are drafts and never publish on their own. Use them to check your field mapping against real accounts.
:::
