> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ocoya.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom (webhook)

> Send scheduled posts to your own endpoint as JSON, and the exact payload it receives.

The **Custom** channel doesn't publish to a network. It sends each scheduled post to a URL you own, as JSON, and whatever happens next is up to your code.

Use it for a CMS Ocoya doesn't connect to, a static site generator, an internal review queue, or anything you'd rather build than wait for.

## Connect an endpoint

<Steps>
  <Step title="Open Channels">
    Select **Channels** in the left sidebar, then **Connect channel**.
  </Step>

  <Step title="Choose Custom">
    Pick **Custom** from the **Add a connection** panel.
  </Step>

  <Step title="Name it">
    Give it a name you'll recognise in the channel list, like *My blog*. This is only a label.
  </Step>

  <Step title="Enter your webhook URL">
    The full URL, including the scheme — `https://example.com/hooks/content`. It has to be reachable from Ocoya's servers, so an address on your own machine or inside a private network won't work.
  </Step>

  <Step title="Optionally, add headers">
    Under **Advanced options**. Most endpoints want to know the request is yours before they accept it — add `Authorization` with a value of `Bearer your-token`, or whatever header your receiver checks. Each one is sent with every request.
  </Step>
</Steps>

<Warning>
  Without an authorising header, **the URL is the only credential** — anyone who learns it can post to your endpoint. Add one, or at minimum use a long, unguessable path.
</Warning>

<Note>
  Header values are stored the way credentials are and never sent back to the browser, so the connect form shows an empty Headers section even for a destination that has them. Reconnecting the same URL replaces the whole set — re-enter every header you want to keep.
</Note>

## What your endpoint receives

A `POST` with `Content-Type: application/json`:

```json theme={null}
{
  "event": "post.published",
  "publishedAt": "2026-09-17T09:00:00.000Z",
  "profile": {
    "id": "clx…",
    "name": "My blog"
  },
  "post": {
    "id": "clx…",
    "postGroupId": "clx…",
    "title": "Three labels, one warmer palette",
    "caption": "Testing a softer set of creams…",
    "media": [
      {
        "url": "https://…/image.jpg",
        "type": "image",
        "mimetype": "image/jpeg",
        "thumbnail": null,
        "width": 1600,
        "height": 1200,
        "alt": "Three coffee labels side by side"
      }
    ],
    "options": {}
  }
}
```

The fields worth knowing:

| Field               | What it is                                                                                                      |
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
| `post.caption`      | The body, exactly as authored. Markdown if you wrote Markdown                                                   |
| `post.title`        | The title, or `null` when none was set                                                                          |
| `post.media[].type` | `image`, `video`, `gif`, or `file` for anything else                                                            |
| `post.media[].url`  | A URL to fetch. Download it — don't hotlink it                                                                  |
| `post.postGroupId`  | Shared by every channel in the same scheduled post, so you can tell that this went out alongside others         |
| `post.options`      | Everything else the composer stored — excerpt, tags, and whatever a future field adds. Passed through untouched |

The shape is deliberately generic: the same whether the content is a social caption or a 1,200-word article. Mapping it onto your CMS is your endpoint's job.

## What Ocoya expects back

Answer with any **2xx** status. Anything else is recorded as a failed publish, and the status and response body are shown on the post so you can see what your endpoint said.

Your endpoint has **30 seconds** to respond. Do the slow work after answering rather than before — acknowledge the request, then process it.

## Limits

|         |                                      |
| ------- | ------------------------------------ |
| Title   | 300 characters                       |
| Body    | 200,000 characters                   |
| Media   | 20 items                             |
| Timeout | 30 seconds                           |
| Headers | Up to 10, values of 2,000 characters |

No vendor sits behind a webhook, so there's nothing real to validate against — you own the receiving end and decide what it accepts. These are guard rails rather than limits: wide enough for a long article with its media, narrow enough that a runaway generation is caught in the composer rather than at your endpoint.

## Common problems

**"A destination URL must start with http\:// or https\://."** The URL has no scheme. Include it.

**""Content-Type" is set by Ocoya and cannot be overridden."** A few headers describe the request itself rather than authenticate it — the content headers, `Host`, `Connection`, `Transfer-Encoding`. Ocoya sets those, so your own headers can't replace them.

**""…" is not a valid header name."** A header name is letters, digits and dashes. A space or a colon in the name field usually means the whole `Name: value` line was pasted into it — the name goes in the left field, the value in the right.

**"That is not a valid URL."** The address couldn't be parsed. Check for typos and stray spaces.

**"Your endpoint could not be reached. Check the URL is correct and publicly accessible."** DNS didn't resolve or the connection was refused. An endpoint on localhost or a private network isn't reachable from Ocoya.

**"Your endpoint did not respond in time."** It took longer than 30 seconds. Acknowledge first, process afterwards.

**"Your endpoint returned 500: …"** Your code raised an error. The detail after the status is what your endpoint sent back, truncated.

## Related

<Columns cols={2}>
  <Card title="Character and media limits" icon="ruler" href="/help/channel-limits">
    Every network's limits side by side.
  </Card>

  <Card title="The post editor" icon="square-pen" href="/help/post-editor">
    Writing a different version per channel.
  </Card>
</Columns>
