> For the complete documentation index, see [llms.txt](https://docs.voicemoat.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.voicemoat.com/assistants/api.md).

# API and webhooks

**In short:** the VoiceMoat REST API lets your own scripts, or any tool that can send an HTTP request (such as the HTTP steps in Make, n8n or Zapier), create drafts, schedule posts, post, and read analytics. Webhooks do the reverse: VoiceMoat tells your tools the moment a post goes out, fails, needs you, or gets a review comment.

Manage both in **Settings → API and webhooks**.

|          | Free | $1 Creator trial | $5 Pro trial | Creator | Pro | Enterprise |
| -------- | ---- | ---------------- | ------------ | ------- | --- | ---------- |
| REST API | no   | no               | yes          | no      | yes | yes        |
| Webhooks | no   | no               | yes          | no      | yes | yes        |

The machine-readable description needs no key and is at:

```
https://app.voicemoat.com/api/v1/openapi.json
```

***

**Keys and scopes**

Create a key in **Settings → API and webhooks → Keys → New key**. Name it after where it will be used, tick what it may do, and copy it. You see it once.

These are the same personal keys assistants use: one list, up to 10 live keys. VoiceMoat stores only a fingerprint of each key.

| Scope      | What the key can do                                          | Ticked on a new key |
| ---------- | ------------------------------------------------------------ | ------------------- |
| `read`     | Your account, profiles, posts, analytics, ideas, voice match | Yes                 |
| `drafts`   | Create drafts, have audenAI draft posts, delete drafts       | Yes                 |
| `schedule` | Schedule drafts and cancel scheduled posts                   | Yes                 |
| `publish`  | Post straight away, in public                                | No                  |

{% hint style="warning" %}
A key with `publish` posts the moment it is called. The API has no preview step. Only tick it for a tool you mean to post on its own.
{% endhint %}

A key can't be changed once it is made. To change what it can do, make a new one and revoke the old one. **Revoke** stops a key at once. **Disconnect** in **Settings → Assistants** stops every key and every assistant.

**Base URL and sending the key**

```
https://app.voicemoat.com/api/v1
```

Send the key as a header with every request:

```
Authorization: Bearer vm_live_your-key
```

`X-API-Key` and `Api-Key` headers work too. A key in a URL does not. The API is for servers: it sends no CORS headers, so a web page can't read its answers. Keep your key on your server.

**A first call**

This creates a LinkedIn post and schedules it for your next open slot.

```bash
curl https://app.voicemoat.com/api/v1/posts \
  -H "Authorization: Bearer $VOICEMOAT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: notion-page-8812" \
  -d '{ "profile": "linkedin", "text": "Three hiring mistakes I made twice", "schedule": "next_best_slot" }'
```

The answer, with status `201`:

```json
{
  "post": {
    "id": "post_s...",
    "status": "scheduled",
    "platform": "linkedin",
    "profile_id": "prof_...",
    "text": "Three hiring mistakes I made twice",
    "thread": null,
    "content_type": "linkedin_post",
    "media": null,
    "scheduled_for": "2026-11-11T03:36:00.000Z",
    "published_at": null,
    "url": null,
    "error": null,
    "made_by": "api · Content script",
    "created_at": "2026-11-09T10:12:00.000Z"
  }
}
```

**Profiles and ids**

Where a call takes `profile`, send a profile id from `GET /profiles` (`prof_...`), or `twitter` or `linkedin`. A platform name works when you have one profile there, or one set as active. With several and none active, the call asks for an id.

A post's id says where it lives: `post_d...` is a draft, and `post_s...` is a scheduled, published, or failed post. Scheduling or publishing a draft gives it a new id, and the answer carries it.

Times are ISO-8601.

| Post `status` | What it means                                                          |
| ------------- | ---------------------------------------------------------------------- |
| `draft`       | Saved, not scheduled                                                   |
| `scheduled`   | In the queue                                                           |
| `publishing`  | Going out now                                                          |
| `published`   | Live                                                                   |
| `failed`      | Didn't go out. `error` says why                                        |
| `needs_you`   | We couldn't confirm it went out, or it missed its slot. See Scheduling |
| `cancelled`   | Cancelled                                                              |

Posts made through the API show a badge on the Calendar with your key's name.

***

**Endpoints**

| Method and path             | Scope                                                 | Credits                 | What it does                                       |
| --------------------------- | ----------------------------------------------------- | ----------------------- | -------------------------------------------------- |
| `GET /me`                   | `read`                                                | None                    | Your account, plan, and this key's name and scopes |
| `GET /profiles`             | `read`                                                | None                    | Your connected profiles                            |
| `GET /posts`                | `read`                                                | None                    | List posts, newest first                           |
| `GET /posts/{id}`           | `read`                                                | None                    | One post                                           |
| `POST /posts`               | `drafts`, plus `schedule` if you send `schedule`      | None                    | Create a draft, and optionally schedule it         |
| `POST /posts/generate`      | `drafts`                                              | Yes, the same as Studio | audenAI drafts a post in your voice                |
| `POST /posts/{id}/schedule` | `schedule`                                            | None                    | Schedule a draft                                   |
| `POST /posts/{id}/publish`  | `publish`                                             | None                    | Post it now                                        |
| `DELETE /posts/{id}`        | `drafts` for a draft, `schedule` for a scheduled post | None                    | Delete a draft or cancel a scheduled post          |
| `GET /analytics`            | `read`                                                | None                    | The numbers Analytics shows                        |
| `GET /ideas`                | `read`                                                | Yes                     | audenAI's post ideas on a topic                    |
| `POST /voice/score`         | `read`                                                | 1 when Stance runs      | How well any text matches your voice               |

**`GET /me`** returns `email`, `plan`, `key` (its `name` and `scopes`), and `default_platform`.

**`GET /profiles`** returns `profiles`. Each has `id`, `platform`, `handle`, `name`, `connected_at`, and `is_default`.

**`GET /posts`**

| Query     | What it does                                                  |
| --------- | ------------------------------------------------------------- |
| `status`  | One of the post statuses above                                |
| `profile` | A profile id or platform                                      |
| `from`    | Scheduled or published at or after this (for drafts: created) |
| `to`      | Before this                                                   |
| `limit`   | 1 to 100. Default 20                                          |
| `cursor`  | `next_cursor` from the previous page                          |

The answer is `posts` and `next_cursor`. **`GET /posts/{id}`** returns `post`.

**`POST /posts`**

| Field            | Required                  | What it is                                                                                                                                                         |
| ---------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `profile`        | Yes                       | A profile id or platform                                                                                                                                           |
| `text`           | One of `text` or `thread` | The post                                                                                                                                                           |
| `thread`         | One of `text` or `thread` | Twitter only. 2 to 25 posts, in order                                                                                                                              |
| `media`          | No                        | Up to 4 images or one PDF, as the storage URLs of files already in your VoiceMoat media library. They go on the first post                                         |
| `carousel_title` | No                        | LinkedIn only, 1 to 100 characters, with one PDF in `media`. It only works together with `schedule`                                                                |
| `schedule`       | No                        | Also schedule it. The same values as `at` below                                                                                                                    |
| `reply_settings` | No                        | Twitter only, and only together with `schedule`: `everyone`, `following`, `mentionedUsers`, `subscribers`, or `verified`. On a thread it applies to the first post |
| `made_with_ai`   | No                        | Twitter only, and only together with `schedule`. Adds Twitter's Made with AI label                                                                                 |
| `format`         | No                        | Twitter only, and only together with `schedule`: `post` or `long_post`. A long post is up to 25,000 characters and needs X Premium                                 |

Length follows the platform: your Twitter account's own limit per post, or 3,000 on LinkedIn. The API can't publish Articles. Unknown fields are refused and nothing is done.

A plain draft keeps its text and media only. Pass the Twitter options to `/schedule` or `/publish` instead.

**`POST /posts/generate`**

| Field          | Required | What it is                                       |
| -------------- | -------- | ------------------------------------------------ |
| `profile`      | Yes      | A profile id or platform                         |
| `topic`        | Yes      | What the post is about. 3 to 2,000 characters    |
| `content_type` | No       | Twitter only: `post` or `thread`. Default `post` |
| `tone`         | No       | Up to 40 characters                              |
| `count`        | No       | How many drafts, 1 to 3. Default 1               |

Each draft is saved. The answer is `posts` and `audenai_note`.

**`POST /posts/{id}/schedule`** works on drafts only.

| Field                                      | Required | What it is                                                                              |
| ------------------------------------------ | -------- | --------------------------------------------------------------------------------------- |
| `at`                                       | Yes      | An ISO-8601 time at least ten minutes away, or `next_best_slot` for your next open slot |
| `reply_settings`, `made_with_ai`, `format` | No       | As above                                                                                |

Your plan's scheduling limits apply. Every queued post is moved by up to seven minutes so posts don't land on round times, so `scheduled_for` is the real time. The answer is `post`, `requested_for`, and `note`. To move a post that is already scheduled, use VoiceMoat, or cancel it and schedule a draft.

**`POST /posts/{id}/publish`** works on a draft or a scheduled post. The body is optional and takes `reply_settings`, `made_with_ai`, and `format`. It goes through the same path as Post now in Studio, with the same checks. A post that is already out, going out, or `needs_you` answers `409 post_is_live`.

| Status | Body                                | What it means                                                         |
| ------ | ----------------------------------- | --------------------------------------------------------------------- |
| `200`  | `post` and `posted_id`              | It is live. `post` can be `null` if the record can't be read back     |
| `202`  | `status: "needs_you"` and `message` | The platform didn't confirm it. **Do not retry.** VoiceMoat checks it |

**`DELETE /posts/{id}`** deletes a draft or cancels a scheduled, failed, or missed post. It never deletes a live post: anything published, going out, or waiting on "did this go out?" answers `409 post_is_live`. The answer is `id`, `deleted`, and `cancelled`.

**`GET /analytics`** takes `profile` and `window` (`today`, `yesterday`, `last_7_days`, or `last_30_days`, default `last_7_days`). It returns impressions, likes, replies, reposts, post count, and the top post.

**`GET /ideas`** takes `topic` (required, up to 240 characters), `profile`, and `count` (1 to 8, default 5).

**`POST /voice/score`** takes `text` (up to 8,000 characters) and `profile`. It returns Style (your measurable habits) and Stance (your claims). They are two separate results.

***

**Idempotency-Key**

Add an `Idempotency-Key` header to any `POST` or `DELETE`. A retried request then can't create or post twice. Use 1 to 255 characters, and a new key for each new request.

| You send the same key                                 | What happens                                                                      |
| ----------------------------------------------------- | --------------------------------------------------------------------------------- |
| With the same method, path, and body, within 24 hours | The stored answer comes back with `Idempotent-Replayed: true`. Nothing runs again |
| With anything different                               | `409 idempotency_conflict`                                                        |
| While the first is still running                      | `409 idempotency_in_progress`                                                     |
| After a `429` or `503`                                | It runs, because nothing ran the first time                                       |

Keys are per personal key. Without the header the request simply runs, though the same text to the same account in the last ten minutes is still refused with `409 duplicate_recent`.

**Rate limits**

| Limit                                              | How much                                                                 |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| Requests                                           | 60 a minute, per key                                                     |
| Scheduling and posting through keys and assistants | 20 an hour, per account, shared with assistants. Drafts are not affected |
| Request body                                       | 1 MB                                                                     |

Your plan's normal post and credit limits apply too. Every answer has `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. Going over 60 a minute also sends `Retry-After`.

**Errors**

Every error has one shape. `code` is stable and `message` is plain English.

```json
{ "error": { "code": "forbidden_scope", "message": "This key does not have the \"publish\" scope, so nothing was done. Make a key that has it in Settings, API and webhooks.", "required_scope": "publish" } }
```

| Status           | Code                                                    | What it means                                                              |
| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- |
| 400              | `invalid_request`, `invalid_json`                       | The request isn't valid. The message names the first problem               |
| 400 or 503       | `publish_failed`                                        | The platform refused it, or it isn't reachable                             |
| 401              | `unauthorized`                                          | No key, or not a live key                                                  |
| 402              | `insufficient_credits`                                  | Out of credits                                                             |
| 403              | `plan_required`                                         | The plan doesn't include the API                                           |
| 403              | `forbidden_scope`                                       | The key lacks the scope                                                    |
| 403              | `account_disconnected`, `account_deleted`, `not_set_up` | Access was turned off, the account was deleted, or no profile is connected |
| 403              | `feature_locked`                                        | The plan doesn't include that feature                                      |
| 403, 409, or 429 | `limit_reached`                                         | A scheduling limit or a daily limit                                        |
| 404              | `not_found`                                             | Not on this account                                                        |
| 409              | `conflict`                                              | The post isn't in a state that allows this                                 |
| 409              | `post_is_live`                                          | It is live, or may be                                                      |
| 409              | `duplicate_recent`                                      | The same text went to the same account in the last ten minutes             |
| 409              | `idempotency_conflict`, `idempotency_in_progress`       | See Idempotency-Key                                                        |
| 409 or 500       | `schedule_failed`                                       | No open slot, or it couldn't be saved                                      |
| 413              | `invalid_request`                                       | The body is over 1 MB                                                      |
| 429              | `rate_limited`                                          | Too many requests. The message says when to try again                      |
| 500 or 502       | `server_error`                                          | Something went wrong on our side. Try again in a minute                    |
| 503              | `unavailable`                                           | Try again in a minute                                                      |

***

**Webhooks**

Add one in **Settings → API and webhooks → Webhooks**. Give the `https` address, pick the events, and pick **All profiles** or one profile. Copy the **Signing secret** when it shows: you see it once. You can have up to 10 webhooks.

**Events**

| Event             | In Settings     | When it is sent                                                                        | Extra fields in `data`                               |
| ----------------- | --------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `post.published`  | Post published  | A post went out                                                                        | `url` is the live post                               |
| `post.failed`     | Post failed     | A post didn't go out                                                                   | `reason`                                             |
| `post.needs_you`  | Post needs you  | We can't confirm it went out, or it missed its slot                                    | `reason`, and `needs_you`: `unconfirmed` or `missed` |
| `post.scheduled`  | Post scheduled  | A post was scheduled from Studio, the Calendar, audenAI chat, an assistant, or the API | None                                                 |
| `review.comment`  | Review comment  | A reviewer commented on a shared post                                                  | `reviewer`, `comment`                                |
| `review.approved` | Review approved | A reviewer approved a shared post                                                      | `reviewer`, `status`                                 |

**Post published**, **Post failed**, and **Post needs you** are ticked by default.

A post that sent `post.needs_you` and is later confirmed live also sends `post.published`. A post someone marks as posted by hand arrives as `post.published` with `marked_by_user`, and its `url` is null. Asking for changes on a review has no event.

**Payload**

Every delivery is a `POST` with this JSON body:

```json
{
  "event": "post.published",
  "id": "evt_...",
  "created": "2026-11-11T03:30:04.000Z",
  "data": {
    "post_id": "post_s...",
    "profile": "linkedin",
    "profile_id": "prof_...",
    "url": "https://www.linkedin.com/feed/update/...",
    "text": "Three hiring mistakes I made twice",
    "scheduled_for": "2026-11-11T03:30:00.000Z"
  }
}
```

`post_id` and `url` can be `null`. A review event has `post_id`, `profile`, `profile_id`, and the fields in the table above, with no post text. Payloads never hold a token, an email address, or an internal id.

Each request has these headers:

| Header                | Value                             |
| --------------------- | --------------------------------- |
| `Content-Type`        | `application/json`                |
| `User-Agent`          | `VoiceMoat-Webhooks/1.0`          |
| `VoiceMoat-Event`     | The event name, or `test`         |
| `VoiceMoat-Signature` | `t=<unix seconds>,v1=<signature>` |

**Checking the signature**

`v1` is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with your webhook's signing secret. Check it against the raw body, before you parse the JSON, and reject a `t` more than five minutes old.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function isFromVoiceMoat(secret, header, rawBody) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const i = part.indexOf("=");
      return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
  const given = Buffer.from(parts.v1, "hex");
  return expected.length === given.length && timingSafeEqual(expected, given);
}
```

**Retries and pausing**

Any `2xx` within 10 seconds counts as delivered. Anything else is a failed try. Redirects aren't followed, so use the final address.

* A failed delivery is tried again after 1, 10, and 50 minutes, four tries in all over about an hour. Then it stops.
* After 20 failed tries in a row, the webhook pauses itself and we email you once. A success resets the count.
* Events that happen while a webhook is paused are not sent later. Press **Resume** to start again, which also clears the count. You can press **Pause** yourself too.

The same event keeps its `id` across retries and **Send again**, so use `id` to ignore repeats.

**Test events and the log**

**Send a test event** sends a signed event straight away, even to a paused webhook. Its `event` is `test`, and it is never retried. Its `data` holds a short `message`.

**Recent deliveries** shows the last 20 for each webhook, with the status and the event. **Send again** re-sends one that failed. Delivery records are kept for 30 days.

**Where a webhook can send**

The address must be `https`, with no user name or password in it, and must point to a public address. Private, local, and cloud metadata addresses are refused when you add the webhook, and checked again on every send. See Security and your data.
