Create an SOP
Reference for POST /sops, which turns a title plus Markdown or plain text into a new SOP with one card per heading.
On this page
POST https://app.sopcompass.com/api/v1/sopsScope: read and write. Content-Type: application/json.
Creates an SOP in one of the workspace’s own libraries from a title plus either markdown or text. Returns 201 Created with the new SOP in full, exactly as Get an SOP returns it.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
library_id |
UUID | Yes | A library of this workspace, from GET /libraries where shared is false. |
title |
string | Yes | 1 to 200 characters after trimming spaces. |
markdown |
string | One of markdown or text |
Markdown. Each # or ## heading starts a new card. See Markdown support. |
text |
string | One of markdown or text |
Plain text, as a single card called “Overview”. Blank lines separate paragraphs; single line breaks are kept. |
description |
string or null | No | The SOP’s short description, up to 2,000 characters. |
Send exactly one of markdown and text, as a non-empty string. Unknown fields are ignored; tags, owner, departments and review date can’t be set through the API in v1.
Limits
markdownortext: at most 500,000 characters.- At most 50 cards (sections).
- At most about 1 MB of card content once converted to HTML.
Request
curl https://app.sopcompass.com/api/v1/sops \
-H "Authorization: Bearer $SOPCOMPASS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"library_id": "4a6c8e0b-2d4f-4a1c-8e3a-5c7e9b1d3f07",
"title": "Close a client site for the night",
"description": "Evening close for Acme Cleaning Co. (example) crews.",
"markdown": "Do this after the last clean of the day.\n\n## Equipment\n\n1. Empty and rinse the mop buckets\n2. Plug in the floor scrubber\n\n## Lock up\n\n- Check every window\n- Set the alarm, then lock the **front door** last\n- Log the time in [the site sheet](https://example.com/site-sheet)"
}'const res = await fetch("https://app.sopcompass.com/api/v1/sops", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SOPCOMPASS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
library_id: "4a6c8e0b-2d4f-4a1c-8e3a-5c7e9b1d3f07",
title: "Close a client site for the night",
description: "Evening close for Acme Cleaning Co. (example) crews.",
markdown: [
"Do this after the last clean of the day.",
"",
"## Equipment",
"",
"1. Empty and rinse the mop buckets",
"2. Plug in the floor scrubber",
"",
"## Lock up",
"",
"- Check every window",
"- Set the alarm, then lock the **front door** last",
"- Log the time in [the site sheet](https://example.com/site-sheet)",
].join("\n"),
}),
});
const { data: sop } = await res.json();
console.log(res.status, sop.app_url);import os
import requests
markdown = """Do this after the last clean of the day.
## Equipment
1. Empty and rinse the mop buckets
2. Plug in the floor scrubber
## Lock up
- Check every window
- Set the alarm, then lock the **front door** last
- Log the time in [the site sheet](https://example.com/site-sheet)
"""
res = requests.post(
"https://app.sopcompass.com/api/v1/sops",
headers={"Authorization": f"Bearer {os.environ['SOPCOMPASS_API_KEY']}"},
json={
"library_id": "4a6c8e0b-2d4f-4a1c-8e3a-5c7e9b1d3f07",
"title": "Close a client site for the night",
"description": "Evening close for Acme Cleaning Co. (example) crews.",
"markdown": markdown,
},
timeout=30,
)
sop = res.json()["data"]requests sets Content-Type: application/json for you when you pass json=.
Response
201 Created
{
"data": {
"id": "f8c0e2a4-6b8d-4c0e-8a2f-6b8d0f2c4e13",
"title": "Close a client site for the night",
"description": "Evening close for Acme Cleaning Co. (example) crews.",
"library_id": "4a6c8e0b-2d4f-4a1c-8e3a-5c7e9b1d3f07",
"library_title": "Client sites",
"board_id": "1b3d5f7a-9c2e-4a6b-8d0f-2a4c6e8b0d14",
"board_name": "Fulfillment and delivery",
"tags": [],
"departments": ["operations"],
"owner_email": null,
"published": false,
"shared": false,
"next_review_on": null,
"archived_at": null,
"created_at": "2026-09-19T16:18:30.277161Z",
"updated_at": "2026-09-19T16:18:30.277161Z",
"cards": [
{
"id": "a1c3e5f7-2b4d-4f6a-8c1e-3b5d7f9a1c17",
"title": "Overview",
"position": 0,
"body_html": "<p>Do this after the last clean of the day.</p>",
"steps": [],
"image_urls": []
},
{
"id": "b2d4f6a8-3c5e-4a7b-9d2f-4c6e8a0b2d18",
"title": "Equipment",
"position": 1,
"body_html": "<ol><li><p>Empty and rinse the mop buckets</p></li><li><p>Plug in the floor scrubber</p></li></ol>",
"steps": [],
"image_urls": []
},
{
"id": "c3e5a7b9-4d6f-4b8c-8e3a-5d7f9b1c3e19",
"title": "Lock up",
"position": 2,
"body_html": "<ul><li><p>Check every window</p></li><li><p>Set the alarm, then lock the <strong>front door</strong> last</p></li><li><p>Log the time in <a href=\"https://example.com/site-sheet\" target=\"_blank\" rel=\"noopener noreferrer\">the site sheet</a></p></li></ul>",
"steps": [],
"image_urls": []
}
],
"app_url": "https://app.sopcompass.com/sops/f8c0e2a4-6b8d-4c0e-8a2f-6b8d0f2c4e13?t=7c2e4a6b-8d1f-4a3c-9e5b-0d2f4a6c8e04"
}
}Content is created as cards with HTML bodies; the API doesn’t create steps, so steps is always [] on a new SOP. departments shows the library’s default departments.
How the new SOP appears in the app
- It is added at the end of the chosen library and is visible right away to everyone in the workspace who can see that library, like any other SOP. It also appears in search in the app and in
GET /search. - It is returned with
"published": false. That flag marks where the SOP came from; the app doesn’t currently show a draft badge or hide unpublished SOPs, so don’t rely on it to keep content private. If the content needs review before your team sees it, create it in a library only reviewers can access, or review it before you send it. - Its Version history starts with a first version marked “created through the API”.
- It has no owner, tags or review date, and isn’t assigned to anyone. Set those in the app, or assign it with
POST /assignments. - Nothing is emailed to anyone.
Markdown support
How sections become cards
- A line starting with
#or##followed by a space starts a new card, and its text becomes the card’s title (trimmed, up to 200 characters). Closing#characters are removed. - Text before the first such heading becomes a card titled “Overview”. If there is none, there is no Overview card.
- A heading with nothing under it before the next heading is dropped. Put content under every heading.
- Headings inside fenced code blocks are left alone.
Supported syntax
| Markdown | Becomes |
|---|---|
### Heading |
<h3>. ####, ##### and ###### all become <h4>. |
| Lines of text | A paragraph. Consecutive lines are joined with a space; a blank line starts a new paragraph. |
- item, * item, + item |
A bulleted list. |
1. item or 1) item |
A numbered list. Numbering always starts at 1. |
- [ ] item, - [x] item |
A list item starting with ☐ or ☑. |
| A line indented 2 or more spaces under a list item | Continues that item’s text. |
> quote |
A block quote. It can contain paragraphs, lists and the other blocks. |
``` or ~~~ fences |
A code block. The language tag is ignored. |
---, *** or ___ on its own line, after a blank line |
A horizontal rule. |
**bold** or __bold__ |
Bold. |
*italic* or _italic_ |
Italic. |
`code` |
Inline code. |
[label](https://...) |
A link that opens in a new tab. http, https and mailto URLs only. |
 |
An image. http and https URLs only. The image stays at that URL; it isn’t copied into SOPCompass. |
Not supported, and shown as plain text: tables, nested lists (indented items become items of the same list), underlined (=== or ---) headings, reference-style links, bare URLs as links, strikethrough and footnotes.
What is sanitized
- All HTML in your input is escaped, so
<script>,<iframe>,<b>and every other tag appear as literal text on the card, never as markup. The only HTML in the result is what the converter itself writes for the syntax above. - A link or image whose URL isn’t
httporhttps(ormailtofor links), such asjavascript:ordata:, is left as the original Markdown text. - The app sanitizes card HTML again when it displays it, so this is the first of two layers.
Plain text
With text, the whole input becomes one card titled “Overview”. Paragraphs are split on blank lines, single line breaks become <br>, and everything is escaped: Markdown syntax is shown literally.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_json |
The body isn’t a JSON object. |
| 403 | read_only_key |
The key is read only. |
| 403 | workspace_read_only |
The workspace’s subscription has ended. |
| 403 | library_read_only |
The library is shared into this workspace from another one. Create the SOP in the workspace that owns it. |
| 404 | not_found |
No library with that id in this workspace. |
| 415 | unsupported_media_type |
The request has no Content-Type: application/json header. |
| 422 | invalid_parameter |
library_id is missing or not a UUID, or description isn’t a string. |
| 422 | invalid_title |
title is missing, blank or longer than 200 characters. |
| 422 | invalid_description |
description is longer than 2,000 characters. |
| 422 | invalid_body |
Both or neither of markdown and text were sent, or the one sent isn’t a non-empty string. |
| 422 | too_large |
Over 500,000 characters, over 50 cards, or over about 1 MB of converted content. |
Important:
POST /sopsisn’t idempotent: sending the same request twice creates two SOPs. If a request times out with no response, checkGET /sops?library_id=...&updated_since=...for it before retrying.