Push articles via API

Generate an API key with knowledge:write, ensure the category exists with POST /api/v1/kb/categories, then POST or PATCH the article with /api/v1/kb/articles. Embeddings rebuild on the server.

6 min read

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

  1. Go to Settings → API & MCP.
  2. Click New API key.
  3. Pick scopes:
    knowledge:read — for listing and reading.
    knowledge:write — for creating, updating, deleting.
  4. Name the key descriptively (“doc-as-code”, “migration”).
  5. 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 '{
    "title": "Billing",
    "slug": "billing",
    "description": "Invoices, payments, and refunds",
    "sortOrder": 1
  }'

A successful create returns the category with its server-generated ID. If the slug already exists for your tenant, the call returns the existing category — categories are upserted by slug.

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.
  • slug is what makes the article uniquely identifiable. Re-POSTing the same slug updates the existing article — the API treats this as upsert.
  • content is 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).
  • customMetadata is an open jsonb bucket. Use it for downstream consumers — the Agent Stack reads uxPath, you can add videoUrl, sopChecklist, anything.
  • Embeddings are refreshed in the background. Creating or upserting an article marks it for background embedding; search and AI retrieval pick it up after the embedding scheduler processes it.

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.

If your pipeline upserts by slug, you don’t need PATCH at all — re-POST the article and the slug match handles it.

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.

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(title, slug):
    r = requests.post(f"{BASE}/categories", headers=HEADERS,
                      json={"title": title, "slug": slug})
    r.raise_for_status()
    return r.json()["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")},
    }
    r = requests.post(f"{BASE}/articles", headers=HEADERS, json=payload)
    r.raise_for_status()
    return r.json()

That’s the whole shape — read frontmatter + body, ensure the category, upsert the article. 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 articles with the same slug in your tenant collide — the second updates the first.
  • 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, but AI retrieval and search may wait until the embedding scheduler drains pending articles — up to about 10 minutes. If your test query doesn’t return it immediately, wait and try again.
  • Roles aren’t yet on v1. If your articles need role tagging, apply them in the in-app editor after the push, or use the internal /api/kb/* surface (Supabase auth required) for that step.

Tags

How ToAdvanced