# Calendone Content API and MCP

Publish articles written in Calendone on any website: a custom CMS, a static site, or through an AI agent. Use the REST API to pull articles, webhooks to get notified, or MCP for agents. Base URL: `https://www.calendone.io/api/v1`. MCP endpoint: `https://www.calendone.io/api/mcp`. HTML version: https://www.calendone.io/developers/api

## Get started

Add your site, plan your content in Calendone, then automate publishing with the API or MCP.

- Create a free account at https://www.calendone.io/register and add your website.
- Calendone plans topics and writes the articles.
- In the app go to Team → API & MCP and create a token. Choose "Read and mark published" so you can report live URLs. The token is shown once.
- Send it as `Authorization: Bearer YOUR_TOKEN` on every request. One token works for every website in your team.

## Publishing flow

1. `GET /websites` to find your website id.
2. `GET /websites/{website_id}/articles?status=approved` to find articles ready to publish.
3. `GET /websites/{website_id}/articles/{article_id}` for the full article.
4. Publish it on your site, using `slug`, `meta_title`, `meta_description`, `content_html` (or `content_markdown`) and `featured_image`.
5. `PUT /websites/{website_id}/articles/{article_id}/publication` with the live `url`. The article becomes `published`, so it won't show up as approved again.

If an article is edited in Calendone after you published it, `publication.needs_update` becomes `true`. Fetch it again and update your post.

## Article statuses

- `in_review`: Finished, waiting for your approval in Calendone.
- `approved`: Approved and not yet published. This is what you publish.
- `published`: You reported it live with the publication endpoint.
- `rejected`: Rejected in Calendone. Don’t publish it.

## Endpoints

All responses are JSON. Successful responses wrap results in `data`.

### Check your token

`GET https://www.calendone.io/api/v1/me`

Returns the team and token you’re using. Call it first to confirm the token works.

Request:

cURL:

```bash
curl "https://www.calendone.io/api/v1/me" \
  -H "Authorization: Bearer $CALENDONE_TOKEN"
```

Node.js:

```js
const res = await fetch("https://www.calendone.io/api/v1/me", {
  headers: {
    Authorization: `Bearer ${process.env.CALENDONE_TOKEN}`,
  },
});
const data = await res.json();
```

PHP:

```php
$ch = curl_init('https://www.calendone.io/api/v1/me');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('CALENDONE_TOKEN'),
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
```

Python:

```python
import os
import requests

res = requests.get(
    "https://www.calendone.io/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['CALENDONE_TOKEN']}"},
)
data = res.json()
```

Go:

```go
package main

import (
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("GET", "https://www.calendone.io/api/v1/me", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CALENDONE_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	json.NewDecoder(res.Body).Decode(&data)
}
```

Response:

```json
{
    "data": {
        "team": {
            "id": 3,
            "name": "Fitalert"
        },
        "token": {
            "name": "fitalert.com blog",
            "abilities": [
                "read",
                "write"
            ],
            "last_used_at": "2026-11-09T08:00:01+00:00"
        }
    }
}
```

### List websites

`GET https://www.calendone.io/api/v1/websites`

All websites in your team. You need a website id for every article call.

Request:

cURL:

```bash
curl "https://www.calendone.io/api/v1/websites" \
  -H "Authorization: Bearer $CALENDONE_TOKEN"
```

Node.js:

```js
const res = await fetch("https://www.calendone.io/api/v1/websites", {
  headers: {
    Authorization: `Bearer ${process.env.CALENDONE_TOKEN}`,
  },
});
const data = await res.json();
```

PHP:

```php
$ch = curl_init('https://www.calendone.io/api/v1/websites');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('CALENDONE_TOKEN'),
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
```

Python:

```python
import os
import requests

res = requests.get(
    "https://www.calendone.io/api/v1/websites",
    headers={"Authorization": f"Bearer {os.environ['CALENDONE_TOKEN']}"},
)
data = res.json()
```

Go:

```go
package main

import (
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("GET", "https://www.calendone.io/api/v1/websites", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CALENDONE_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	json.NewDecoder(res.Body).Decode(&data)
}
```

Response:

```json
{
    "data": [
        {
            "id": 7,
            "url": "https://fitalert.com",
            "name": "fitalert.com",
            "created_at": "2026-11-01T10:00:00+00:00"
        }
    ]
}
```

### List articles

`GET https://www.calendone.io/api/v1/websites/{website_id}/articles`

Finished articles for one website, most recently updated first. The list leaves out the article body; fetch one article to get it.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `status` | query | string | all (default), approved, in_review, published or rejected. Use approved to find what to publish next. |
| `updated_since` | query | ISO 8601 | Only articles changed at or after this time. Good for polling. |
| `page` | query | integer | Page number, default 1. |
| `per_page` | query | integer | 1–100, default 20. |

Request:

cURL:

```bash
curl "https://www.calendone.io/api/v1/websites/7/articles?status=approved" \
  -H "Authorization: Bearer $CALENDONE_TOKEN"
```

Node.js:

```js
const res = await fetch("https://www.calendone.io/api/v1/websites/7/articles?status=approved", {
  headers: {
    Authorization: `Bearer ${process.env.CALENDONE_TOKEN}`,
  },
});
const data = await res.json();
```

PHP:

```php
$ch = curl_init('https://www.calendone.io/api/v1/websites/7/articles?status=approved');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('CALENDONE_TOKEN'),
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
```

Python:

```python
import os
import requests

res = requests.get(
    "https://www.calendone.io/api/v1/websites/7/articles?status=approved",
    headers={"Authorization": f"Bearer {os.environ['CALENDONE_TOKEN']}"},
)
data = res.json()
```

Go:

```go
package main

import (
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("GET", "https://www.calendone.io/api/v1/websites/7/articles?status=approved", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CALENDONE_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	json.NewDecoder(res.Body).Decode(&data)
}
```

Response:

```json
{
    "data": [
        {
            "id": 412,
            "website_id": 7,
            "status": "approved",
            "title": "How to choose running shoes for flat feet",
            "slug": "how-to-choose-running-shoes-for-flat-feet",
            "excerpt": "What actually matters when you have flat feet, and how to test a shoe before you buy it.",
            "language": "en",
            "primary_keyword": "running shoes for flat feet",
            "planned_for": "2026-11-12",
            "revision": 3,
            "replaces_article_id": null,
            "approved_at": "2026-11-09T08:12:44+00:00",
            "created_at": "2026-11-08T21:03:10+00:00",
            "updated_at": "2026-11-09T08:12:44+00:00",
            "publication": null
        }
    ],
    "meta": {
        "page": 1,
        "per_page": 20,
        "total": 1,
        "last_page": 1
    }
}
```

### Get an article

`GET https://www.calendone.io/api/v1/websites/{website_id}/articles/{article_id}`

One article with everything you need to publish it: HTML and Markdown body, meta title and description, slug and image URLs. Images are hosted by Calendone; download them if your site serves its own.

Request:

cURL:

```bash
curl "https://www.calendone.io/api/v1/websites/7/articles/412" \
  -H "Authorization: Bearer $CALENDONE_TOKEN"
```

Node.js:

```js
const res = await fetch("https://www.calendone.io/api/v1/websites/7/articles/412", {
  headers: {
    Authorization: `Bearer ${process.env.CALENDONE_TOKEN}`,
  },
});
const data = await res.json();
```

PHP:

```php
$ch = curl_init('https://www.calendone.io/api/v1/websites/7/articles/412');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('CALENDONE_TOKEN'),
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
```

Python:

```python
import os
import requests

res = requests.get(
    "https://www.calendone.io/api/v1/websites/7/articles/412",
    headers={"Authorization": f"Bearer {os.environ['CALENDONE_TOKEN']}"},
)
data = res.json()
```

Go:

```go
package main

import (
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("GET", "https://www.calendone.io/api/v1/websites/7/articles/412", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CALENDONE_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	json.NewDecoder(res.Body).Decode(&data)
}
```

Response:

```json
{
    "data": {
        "id": 412,
        "website_id": 7,
        "status": "approved",
        "title": "How to choose running shoes for flat feet",
        "slug": "how-to-choose-running-shoes-for-flat-feet",
        "excerpt": "What actually matters when you have flat feet, and how to test a shoe before you buy it.",
        "language": "en",
        "primary_keyword": "running shoes for flat feet",
        "planned_for": "2026-11-12",
        "revision": 3,
        "replaces_article_id": null,
        "approved_at": "2026-11-09T08:12:44+00:00",
        "created_at": "2026-11-08T21:03:10+00:00",
        "updated_at": "2026-11-09T08:12:44+00:00",
        "publication": null,
        "meta_title": "How to choose running shoes for flat feet",
        "meta_description": "What actually matters when you have flat feet, and how to test a shoe before you buy it.",
        "featured_image": {
            "url": "https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp",
            "alt": "Runner tying a stability shoe",
            "width": 1536,
            "height": 1024
        },
        "images": [
            {
                "url": "https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp",
                "alt": "Runner tying a stability shoe",
                "width": 1536,
                "height": 1024
            }
        ],
        "content_markdown": "![Runner tying a stability shoe](https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp)\n\nFlat feet don’t automatically need…\n\n## What support actually means\n…",
        "content_html": "<p><img src=\"https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp\" alt=\"Runner tying a stability shoe\" /></p>\n<p>Flat feet don’t automatically need…</p>\n<h2>What support actually means</h2>\n<p>…</p>"
    }
}
```

### Mark as published

`PUT https://www.calendone.io/api/v1/websites/{website_id}/articles/{article_id}/publication`

Tell Calendone an approved article is live and where. Calendone then tracks it and moves it to published. Calling it again updates the record, so retries are safe. Needs a token with write access.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `url` | body | string, required | Full live URL of the article. |
| `remote_id` | body | string | Your own id for the post, up to 191 characters. |
| `published_at` | body | ISO 8601 | When it went live. Default: now. |

Request:

cURL:

```bash
curl -X PUT "https://www.calendone.io/api/v1/websites/7/articles/412/publication" \
  -H "Authorization: Bearer $CALENDONE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://fitalert.com/blog/running-shoes-flat-feet","remote_id":"post_8812"}'
```

Node.js:

```js
const res = await fetch("https://www.calendone.io/api/v1/websites/7/articles/412/publication", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.CALENDONE_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "url": "https://fitalert.com/blog/running-shoes-flat-feet",
    "remote_id": "post_8812"
  }),
});
const data = await res.json();
```

PHP:

```php
$ch = curl_init('https://www.calendone.io/api/v1/websites/7/articles/412/publication');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('CALENDONE_TOKEN'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://fitalert.com/blog/running-shoes-flat-feet',
        'remote_id' => 'post_8812',
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
```

Python:

```python
import os
import requests

res = requests.put(
    "https://www.calendone.io/api/v1/websites/7/articles/412/publication",
    headers={"Authorization": f"Bearer {os.environ['CALENDONE_TOKEN']}"},
    json={
        "url": "https://fitalert.com/blog/running-shoes-flat-feet",
        "remote_id": "post_8812"
    },
)
data = res.json()
```

Go:

```go
package main

import (
	"encoding/json"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := strings.NewReader(`{"url":"https://fitalert.com/blog/running-shoes-flat-feet","remote_id":"post_8812"}`)
	req, _ := http.NewRequest("PUT", "https://www.calendone.io/api/v1/websites/7/articles/412/publication", body)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CALENDONE_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	json.NewDecoder(res.Body).Decode(&data)
}
```

Response:

```json
{
    "data": {
        "id": 412,
        "status": "published",
        "…": "…",
        "publication": {
            "url": "https://fitalert.com/blog/running-shoes-flat-feet",
            "remote_id": "post_8812",
            "published_at": "2026-11-12T07:00:00+00:00",
            "needs_update": false
        }
    }
}
```

### Remove publication

`DELETE https://www.calendone.io/api/v1/websites/{website_id}/articles/{article_id}/publication`

Use this when you took the article down. It goes back to approved. Needs write access.

Request:

cURL:

```bash
curl -X DELETE "https://www.calendone.io/api/v1/websites/7/articles/412/publication" \
  -H "Authorization: Bearer $CALENDONE_TOKEN"
```

Node.js:

```js
const res = await fetch("https://www.calendone.io/api/v1/websites/7/articles/412/publication", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.CALENDONE_TOKEN}`,
  },
});
const data = await res.json();
```

PHP:

```php
$ch = curl_init('https://www.calendone.io/api/v1/websites/7/articles/412/publication');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('CALENDONE_TOKEN'),
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
```

Python:

```python
import os
import requests

res = requests.delete(
    "https://www.calendone.io/api/v1/websites/7/articles/412/publication",
    headers={"Authorization": f"Bearer {os.environ['CALENDONE_TOKEN']}"},
)
data = res.json()
```

Go:

```go
package main

import (
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("DELETE", "https://www.calendone.io/api/v1/websites/7/articles/412/publication", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CALENDONE_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	json.NewDecoder(res.Body).Decode(&data)
}
```

Response:

```json
{
    "data": {
        "id": 412,
        "status": "approved",
        "…": "…",
        "publication": null
    }
}
```

## MCP server

Endpoint: `https://www.calendone.io/api/mcp` (Streamable HTTP, JSON responses). Use the same bearer token.

| Tool | Arguments | What it does |
| --- | --- | --- |
| `list_websites` | — | Websites in your team. |
| `list_articles` | website_id, status?, updated_since?, page?, per_page? | Same as List articles. |
| `get_article` | website_id, article_id | Same as Get an article. |
| `mark_published` | website_id, article_id, url, remote_id?, published_at? | Same as Mark as published. Needs write access. |

Claude Code:

```
claude mcp add --transport http calendone https://www.calendone.io/api/mcp \
  --header "Authorization: Bearer $CALENDONE_TOKEN"
```

Cursor, Windsurf and other JSON configs:

```
{
    "mcpServers": {
        "calendone": {
            "url": "https://www.calendone.io/api/mcp",
            "headers": {
                "Authorization": "Bearer YOUR_TOKEN"
            }
        }
    }
}
```

## Webhooks

Add a URL under Team → API & MCP → Webhooks and choose events. Calendone sends a signed `POST` with the full article.

Events:

- `article.ready_to_publish`: An approved article reached its planned publish date and isn’t published yet. Publish now moves its date to today and sends this event immediately.
- `article.approved`: An article was approved in Calendone.
- `article.ready_for_review`: Calendone finished writing an article and it needs approval.
- `article.updated`: A published article was edited in Calendone and your copy is out of date.

`article.ready_to_publish` is checked every minute. It fires once per article on its planned date (UTC), or right away if an approved article's date has already passed. Every event fires once per article. The app's "Send test" button sends a `ping` event.

Headers:

- `Calendone-Event`: Event name, e.g. article.ready_to_publish.
- `Calendone-Delivery`: Unique delivery id. Retries reuse it, so you can ignore repeats.
- `Calendone-Timestamp`: Unix time when the request was signed.
- `Calendone-Signature`: v1= followed by HMAC-SHA256 of "{timestamp}.{raw body}" using your signing secret, hex encoded.

Body:

```json
{
    "id": "6f1c2a8e-4b1d-4d1e-9a51-0c7f3b2e9d10",
    "event": "article.ready_to_publish",
    "created_at": "2026-11-12T00:01:04+00:00",
    "team_id": 3,
    "website": {
        "id": 7,
        "url": "https://fitalert.com"
    },
    "data": {
        "article": {
            "id": 412,
            "website_id": 7,
            "status": "approved",
            "title": "How to choose running shoes for flat feet",
            "slug": "how-to-choose-running-shoes-for-flat-feet",
            "excerpt": "What actually matters when you have flat feet, and how to test a shoe before you buy it.",
            "language": "en",
            "primary_keyword": "running shoes for flat feet",
            "planned_for": "2026-11-12",
            "revision": 3,
            "replaces_article_id": null,
            "approved_at": "2026-11-09T08:12:44+00:00",
            "created_at": "2026-11-08T21:03:10+00:00",
            "updated_at": "2026-11-09T08:12:44+00:00",
            "publication": null,
            "meta_title": "How to choose running shoes for flat feet",
            "meta_description": "What actually matters when you have flat feet, and how to test a shoe before you buy it.",
            "featured_image": {
                "url": "https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp",
                "alt": "Runner tying a stability shoe",
                "width": 1536,
                "height": 1024
            },
            "images": [
                {
                    "url": "https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp",
                    "alt": "Runner tying a stability shoe",
                    "width": 1536,
                    "height": 1024
                }
            ],
            "content_markdown": "![Runner tying a stability shoe](https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp)\n\nFlat feet don’t automatically need…\n\n## What support actually means\n…",
            "content_html": "<p><img src=\"https://calendone-io.s3.eu-north-1.amazonaws.com/website-content/7/412/cover.webp\" alt=\"Runner tying a stability shoe\" /></p>\n<p>Flat feet don’t automatically need…</p>\n<h2>What support actually means</h2>\n<p>…</p>"
        }
    }
}
```

Verify the signature before trusting the request. Answer with any 2xx status. If you publish the article right away, reply with `{"url": "...", "remote_id": "..."}` and Calendone marks it published.

PHP (Laravel):

```
$body = $request->getContent();
$timestamp = $request->header('Calendone-Timestamp');
$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$body, env('CALENDONE_WEBHOOK_SECRET'));

abort_unless(hash_equals($expected, (string) $request->header('Calendone-Signature')), 401);
abort_if(abs(time() - (int) $timestamp) > 300, 401); // reject old requests

$article = json_decode($body, true)['data']['article'];
// ...save the article, then optionally reply with where it went live:
return response()->json(['url' => "https://fitalert.com/blog/{$article['slug']}"]);
```

Node.js (Express):

```
import crypto from 'node:crypto';

app.post('/calendone/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('Calendone-Timestamp');
  const expected = 'v1=' + crypto.createHmac('sha256', process.env.CALENDONE_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`).digest('hex');
  const given = req.get('Calendone-Signature') || '';
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const { article } = JSON.parse(req.body).data;
  // ...save the article
  res.json({ url: `https://fitalert.com/blog/${article.slug}` });
});
```

Only HTTPS URLs on the standard port are allowed. Requests time out after 10 seconds and redirects aren't followed. Calendone retries non-2xx answers after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, and pauses the webhook after 20 failures in a row.

## Errors

Errors return `{"error": {"code": "...", "message": "..."}}` with a matching HTTP status.

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | — | Missing, wrong or revoked token. Body: {"message": "Unauthenticated."} |
| 403 | forbidden | The token is read-only and the endpoint needs write access. |
| 404 | website_not_found / article_not_found | The id doesn’t exist or belongs to another team. |
| 409 | not_approved | Only approved articles can be marked published. |
| 422 | invalid_status / invalid_url / invalid_date | A parameter has the wrong format. The message says which. |
| 429 | — | More than 60 requests a minute. Wait for the Retry-After header and try again. |

## Limits and versioning

- 60 requests per minute.
- `v1` only gets additive changes: new fields or endpoints. Ignore fields you don't know.
