Push articles via API
This walks through the minimal flow for pushing a Knowledge Base article from outside Atender — useful for doc-as-code pipelines, migrations, or any scripted content sync.
1. Generate an API key
- Go to Settings → API & MCP.
- Click New API key.
- Pick scopes:
knowledge:read— for listing and reading.knowledge:write— for creating, updating, deleting. - Name the key descriptively (“doc-as-code”, “migration”).
- Save and copy the key. It’s shown once.
For testing, use a sa_test_* key. For production, sa_live_*. Keep both out of source control — store them in your CI’s secret store or a local .env ignored by Git.
2. Set the base URL
Use your environment’s domain:
- Production —
https://prod.atender.dev/api/v1/kb - Staging —
https://staging.atender.dev/api/v1/kb - Development —
https://dev.atender.dev/api/v1/kb
Custom domains for the public help center don’t apply to the API — the API stays on Atender’s domain even if the help center is at help.example.com.
3. Make sure the category exists
Articles need a category. Create it first if it doesn’t already exist.
curl -X POST "$BASE_URL/categories" \
-H "Authorization: Bearer $ATENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Billing",
"description": "Invoices, payments, and refunds",
"sortOrder": 1
}'
A successful create returns the category with its server-generated ID. The category slug is made from name. Categories are not upserted: if the slug is already taken in that knowledge base, the call creates a second category with a -1 suffix on the slug. List categories with GET $BASE_URL/categories first and create only the ones that are missing.
Save the id from the response — you’ll need it for the article.
4. Push an article
curl -X POST "$BASE_URL/articles" \
-H "Authorization: Bearer $ATENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "How to update your payment method",
"slug": "update-payment-method",
"summary": "Open Account, go to Billing, click Change.",
"content": "## Update payment method\n\nOpen your account settings...",
"categoryId": "cat_abc123",
"status": "published",
"difficulty": "beginner",
"estimatedMinutes": 2,
"keywords": ["payment", "credit card", "billing"],
"customMetadata": {
"uxPath": "Account → Billing → Payment methods"
}
}'
Notes:
status: "published"publishes immediately. Use"draft"to upload-but-not-show. Use"needs-review"to flag the article in the in-app editor’s review queue.slugis what makes the article uniquely identifiable.POSTalways creates a new article. If a live article already uses the slug you send, the call returns409and writes nothing. If you leaveslugout, it is made from the title, and a taken title slug gets a-2/-3suffix.contentis markdown. The KB renderer converts it server-side. Tables and nested lists render correctly on the public site (with the public renderer’s HTML allow-list).customMetadatais an open jsonb bucket. Use it for downstream consumers — the Agent Stack readsuxPath, you can addvideoUrl,sopChecklist, anything.- Embeddings are refreshed in the background. Creating or updating an article marks it for background embedding, and a background worker indexes it within a few seconds of the call, so search and AI retrieval pick it up almost immediately. The 10-minute embedding scheduler is only a fallback.
5. Update an existing article
To update an article you’ve already pushed, PATCH it by ID:
curl -X PATCH "$BASE_URL/articles/article_xyz" \
-H "Authorization: Bearer $ATENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"summary": "New summary",
"content": "Updated body…"
}'
PATCH is partial — fields you don’t include are unchanged. Editing title, content, summary, or keywords marks the article for background embedding. If you have languages enabled, published changes are picked up by the automatic translation sweep, usually within 15 minutes, for active languages. Drafts are not translated until after publication.
To update by slug, look the article up with GET $BASE_URL/articles/by-slug/{slug} and PATCH it by the returned ID. Re-POSTing an existing slug returns 409; it doesn’t update the article.
6. Mark an article reviewed
Calling mark-reviewed updates lastReviewedAt without round-tripping the article body. Useful for dashboards that surface stale articles and let reviewers approve them in bulk.
curl -X POST "$BASE_URL/articles/article_xyz/mark-reviewed" \
-H "Authorization: Bearer $ATENDER_API_KEY"
The response is the full updated article.
7. Search
To verify retrieval:
curl -X POST "$BASE_URL/search" \
-H "Authorization: Bearer $ATENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "update payment method",
"limit": 5
}'
The response is a list of articles ranked by the same hybrid retrieval pipeline customer-facing search uses.
A minimal Python upsert
import os, requests, frontmatter
BASE = "https://prod.atender.dev/api/v1/kb"
HEADERS = {"Authorization": f"Bearer {os.environ['ATENDER_API_KEY']}"}
def ensure_category(name):
r = requests.get(f"{BASE}/categories", headers=HEADERS)
r.raise_for_status()
for category in r.json()["data"]:
if category["name"] == name:
return category["id"]
r = requests.post(f"{BASE}/categories", headers=HEADERS, json={"name": name})
r.raise_for_status()
return r.json()["data"]["id"]
def upsert_article(path, category_id):
post = frontmatter.load(path)
payload = {
"title": post["title"],
"slug": post["slug"],
"summary": post.get("summary", ""),
"content": post.content,
"categoryId": category_id,
"status": post.get("status", "draft"),
"keywords": post.get("keywords", []),
"customMetadata": {"uxPath": post.get("ux_path")},
}
found = requests.get(f"{BASE}/articles/by-slug/{post['slug']}", headers=HEADERS)
if found.status_code == 404:
r = requests.post(f"{BASE}/articles", headers=HEADERS, json=payload)
else:
found.raise_for_status()
article_id = found.json()["data"]["id"]
r = requests.patch(f"{BASE}/articles/{article_id}", headers=HEADERS, json=payload)
r.raise_for_status()
return r.json()["data"]
That’s the whole shape — read frontmatter + body, ensure the category, look the article up by slug, then PATCH it or POST it. Run it on every commit and your help center stays in sync with your repo.
Common gotchas
- The category must exist before the article. Create categories first; collect IDs; then push articles. The article create call fails with 400 if the category doesn’t exist.
- Slugs are tenant-unique. Two live articles can’t share a slug in your tenant — a
POSTwith a slug that’s already in use returns409and writes nothing. - HTML in content gets sanitized. The public KB renderer enforces an allow-list. Tables and nested lists render correctly today; arbitrary
<script>,<iframe>, and inline event handlers are stripped. - Embeddings take a moment. A freshly pushed article appears in the KB quickly, and a short wait of a few seconds before AI retrieval and search return it is normal. If it takes longer, the 10-minute scheduler run will catch it. If your test query doesn’t return the article right away, wait a few seconds and try again.
- Roles go in
roleIds. It takes role UUIDs or role slugs (for example["admin", "agent"]). Unknown values are dropped without an error.