Skip to content
Foliosodev
Menu
On this page

REST API

API examples

Four complete workflows: creating a page from Markdown you've written, editing a page as Markdown and writing it back, uploading an image and using it on a page, and moving, regrouping and tagging a page in one request.

All examples use https://acme.folioso.dev/api/v1; use your organisation's address instead. Each one needs a token with the read and write abilities. See Authentication and limits.

Create a page from Markdown

Write the page as ordinary Markdown:

markdown
Release 4.2 ships on **Thursday**.

## Checklist

- [x] Tag the release
- [ ] Publish the notes

> [!NOTE] Rollback
> Redeploy 4.1 with `./deploy 4.1`.

| Service | Owner |
| --- | --- |
| API | Platform |

Send it as markdown, JSON-encoded like any other string, with the page's title, space, group and tags:

bash
curl https://acme.folioso.dev/api/v1/pages \
  -X POST \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Release 4.2",
    "space": "engineering",
    "group_id": 12,
    "tags": ["release"],
    "markdown": "Release 4.2 ships on **Thursday**.\n\n## Checklist\n\n- [x] Tag the release\n- [ ] Publish the notes\n\n> [!NOTE] Rollback\n> Redeploy 4.1 with `./deploy 4.1`.\n\n| Service | Owner |\n| --- | --- |\n| API | Platform |"
  }'

The response is 201 Created with the new page. Its content is the Markdown converted to the editor's format: a paragraph, a heading, a task list, an info alert box titled "Rollback" and a table. The page looks exactly as if it had been typed in the editor:

json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Release 4.2 ships on " },
        { "type": "text", "text": "Thursday", "marks": [{ "type": "bold" }] },
        { "type": "text", "text": "." }
      ]
    },
    { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Checklist" }] },
    {
      "type": "taskList",
      "content": [
        { "type": "taskItem", "attrs": { "checked": true }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Tag the release" }] }] },
        { "type": "taskItem", "attrs": { "checked": false }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Publish the notes" }] }] }
      ]
    },
    {
      "type": "alertBox",
      "attrs": { "type": "info" },
      "content": [
        { "type": "alertTitle", "content": [{ "type": "text", "text": "Rollback" }] },
        {
          "type": "alertBody",
          "content": [
            {
              "type": "paragraph",
              "content": [
                { "type": "text", "text": "Redeploy 4.1 with " },
                { "type": "text", "text": "./deploy 4.1", "marks": [{ "type": "code" }] },
                { "type": "text", "text": "." }
              ]
            }
          ]
        }
      ]
    },
    {
      "type": "table",
      "content": [
        {
          "type": "tableRow",
          "content": [
            { "type": "tableHeader", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Service" }] }] },
            { "type": "tableHeader", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner" }] }] }
          ]
        },
        {
          "type": "tableRow",
          "content": [
            { "type": "tableCell", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "API" }] }] },
            { "type": "tableCell", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Platform" }] }] }
          ]
        }
      ]
    }
  ]
}

If release isn't a tag in the Engineering space yet, it's created. Add ?format=markdown to the address to get the body back as Markdown instead.

Edit a page as Markdown

Say a page's stored content is this:

json
{
  "type": "doc",
  "content": [
    { "type": "heading", "attrs": { "level": 2, "textAlign": null }, "content": [{ "type": "text", "text": "Deploy steps" }] },
    {
      "type": "paragraph",
      "attrs": { "textAlign": null },
      "content": [
        { "type": "text", "text": "Ask " },
        { "type": "mention", "attrs": { "id": 5, "label": "Ada Lovelace", "mentionSuggestionChar": "@", "taggedBy": 7 } },
        { "type": "text", "text": " before you start, and follow " },
        { "type": "pageLink", "attrs": { "id": 42, "slug": "9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b" } },
        { "type": "text", "text": "." }
      ]
    },
    {
      "type": "orderedList",
      "attrs": { "start": 1, "type": null },
      "content": [
        { "type": "listItem", "content": [{ "type": "paragraph", "attrs": { "textAlign": null }, "content": [{ "type": "text", "text": "Run the migrations" }] }] },
        { "type": "listItem", "content": [{ "type": "paragraph", "attrs": { "textAlign": null }, "content": [{ "type": "text", "text": "Restart the workers" }] }] }
      ]
    },
    {
      "type": "alertBox",
      "attrs": { "type": "warning" },
      "content": [
        { "type": "alertTitle", "content": [{ "type": "text", "text": "Never on a Friday" }] },
        {
          "type": "alertBody",
          "content": [
            {
              "type": "paragraph",
              "attrs": { "textAlign": null },
              "content": [
                { "type": "text", "text": "Deploys " },
                { "type": "text", "text": "freeze", "marks": [{ "type": "underline" }] },
                { "type": "text", "text": " at 15:00." }
              ]
            }
          ]
        }
      ]
    },
    { "type": "codeBlock", "attrs": { "language": "bash" }, "content": [{ "type": "text", "text": "php artisan migrate --force" }] }
  ]
}

1. Read it as Markdown

bash
curl "https://acme.folioso.dev/api/v1/pages/1f0e2d3c-5b6a-4978-8a9b-0c1d2e3f4a5b?format=markdown" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"

The response's markdown holds the page, and contentEpoch its current epoch, say 3. The mention, the page link and the underline are written with Folioso extensions, and the page link's label is the target's title as you see it:

markdown
## Deploy steps

Ask [@Ada Lovelace](folioso:mention?id=5&taggedBy=7) before you start, and follow [Deploy runbook](folioso:page?id=42&slug=9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b).

1. Run the migrations
2. Restart the workers

> [!WARNING] Never on a Friday
>
> Deploys [freeze](folioso:span?marks=underline) at 15:00.

```bash
php artisan migrate --force
```

2. Change it

Edit the Markdown as text. Here the second step is reworded and a third added:

markdown
## Deploy steps

Ask [@Ada Lovelace](folioso:mention?id=5&taggedBy=7) before you start, and follow [Deploy runbook](folioso:page?id=42&slug=9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b).

1. Run the migrations
2. Restart the queue workers
3. Check Horizon

> [!WARNING] Never on a Friday
>
> Deploys [freeze](folioso:span?marks=underline) at 15:00.

```bash
php artisan migrate --force
```

3. Write it back

Send the whole Markdown as markdown, with the epoch you read:

bash
curl https://acme.folioso.dev/api/v1/pages/1f0e2d3c-5b6a-4978-8a9b-0c1d2e3f4a5b \
  -X PATCH \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  --data-binary @body.json

where body.json is:

json
{
  "markdown": "## Deploy steps\n\nAsk [@Ada Lovelace](folioso:mention?id=5&taggedBy=7) before you start, and follow [Deploy runbook](folioso:page?id=42&slug=9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b).\n\n1. Run the migrations\n2. Restart the queue workers\n3. Check Horizon\n\n> [!WARNING] Never on a Friday\n>\n> Deploys [freeze](folioso:span?marks=underline) at 15:00.\n\n```bash\nphp artisan migrate --force\n```",
  "epoch": 3
}

The page now has the three-step list:

json
{
  "type": "orderedList",
  "attrs": { "start": 1 },
  "content": [
    { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Run the migrations" }] }] },
    { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Restart the queue workers" }] }] },
    { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Check Horizon" }] }] }
  ]
}

Everything else is the same document as before: Ada is still mentioned, the page link still points at page 42, "freeze" is still underlined and the code block is still Bash. The only differences in the stored JSON are attributes at their defaults, such as "textAlign": null, which may be left out. See What "the same document" means.

The response has the new contentEpoch. Anyone with the page open is reloaded with your version. If the page was restored from a snapshot or replaced since you read it, you get 409 Conflict instead and nothing changes: read it again and reapply your edit.

Upload an image and use it

1. Upload the image

bash
curl https://acme.folioso.dev/api/v1/images \
  -X POST \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -F "image=@request-flow.png"
json
{
  "data": {
    "id": 318,
    "url": "https://folioso.dev/storage/images/0b9f4c5e-8d7a-4f1e-9c2b-3a4d5e6f7a8b.webp",
    "width": 1280,
    "height": 720,
    "alt": null,
    "expiresAt": "2026-10-20T11:15:02+00:00"
  }
}

2. Use it in Markdown

Use the url as the image's address, before expiresAt. A block attributes carrier sets its width and centres it:

markdown
How a request reaches the API:

[](folioso:attrs?width=640&textAlign=center)![Request flow](https://folioso.dev/storage/images/0b9f4c5e-8d7a-4f1e-9c2b-3a4d5e6f7a8b.webp)
json
{
  "type": "doc",
  "content": [
    { "type": "paragraph", "content": [{ "type": "text", "text": "How a request reaches the API:" }] },
    {
      "type": "image",
      "attrs": {
        "src": "https://folioso.dev/storage/images/0b9f4c5e-8d7a-4f1e-9c2b-3a4d5e6f7a8b.webp",
        "alt": "Request flow",
        "title": null,
        "width": 640,
        "textAlign": "center"
      }
    }
  ]
}

Send it as markdown on POST /pages or PATCH /pages/{slug}:

bash
curl https://acme.folioso.dev/api/v1/pages \
  -X POST \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Request flow",
    "space": "engineering",
    "markdown": "How a request reaches the API:\n\n[](folioso:attrs?width=640&textAlign=center)![Request flow](https://folioso.dev/storage/images/0b9f4c5e-8d7a-4f1e-9c2b-3a4d5e6f7a8b.webp)"
  }'

3. Or use it in TipTap JSON

The same image as content puts the url in src:

bash
curl https://acme.folioso.dev/api/v1/pages \
  -X POST \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Request flow",
    "space": "engineering",
    "content": {
      "type": "doc",
      "content": [
        { "type": "paragraph", "content": [{ "type": "text", "text": "How a request reaches the API:" }] },
        {
          "type": "image",
          "attrs": {
            "src": "https://folioso.dev/storage/images/0b9f4c5e-8d7a-4f1e-9c2b-3a4d5e6f7a8b.webp",
            "alt": "Request flow",
            "width": 640,
            "textAlign": "center"
          }
        }
      ]
    }
  }'

Either way, the write keeps the upload as one of the page's images. An upload no page uses by expiresAt is deleted. See Images.

Move, regroup and tag a page

One PATCH can move a page to another space, put it in a group there, and set its tags:

bash
curl https://acme.folioso.dev/api/v1/pages/9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b \
  -X PATCH \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "space": "operations", "group_id": 27, "tags": ["runbook", "deploy"] }'
  1. Look up the target space's slug with GET /spaces, and the group's id with GET /groups?space=operations. See Spaces, groups and tags.
  2. You need the editor or admin role in the page's current space, or to be its author, and the editor or admin role in the target space.
  3. group_id must be a group in the target space. Leave it out to put the page outside any group there.
  4. tags is the page's complete set of tags in the target space. runbook and deploy are matched to the Operations space's tags by exact name, and any that don't exist there are created.

The response is the updated page. Its content and contentEpoch are left out here:

json
{
  "data": {
    "id": 42,
    "slug": "9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b",
    "title": "Deploying the API",
    "excerpt": null,
    "visibility": "space",
    "space": { "slug": "operations", "name": "Operations" },
    "group": { "id": 27, "name": "Runbooks" },
    "tags": [{ "id": 85, "name": "runbook" }, { "id": 88, "name": "deploy" }],
    "url": "https://acme.folioso.dev/operations/9b2f6c1e-4d1a-4b8e-9f0a-2c3d4e5f6a7b",
    "createdAt": "2026-10-01T14:03:11+00:00",
    "updatedAt": "2026-10-20T10:02:57+00:00"
  }
}

The page's slug doesn't change, so links to it keep working, and the pages nested under it move too. excerpt is null until the moved page is next indexed for search, as after any change. Snapshots from before the move are hidden, because the page has a new audience. Body changes can go in the same request: the title and body are written first, then the page is moved, then tagged. See Move, regroup and tag a page.

To regroup a page within its own space, send only group_id, or "group_id": null to take it out of its group. To change only its tags, send only tags.