Developer walkthrough · REST and MCP
Sync your Knowledgebase
Keep writing in your existing system and send changes to Sonny. This walkthrough creates a Billing category, syncs a draft article, then publishes it for a verified customer audience.
1. Choose a channel and create a key
- Open Channels → your channel. Copy the ID after /app/sources/ in its dashboard URL. The API calls this channel ID sourceId; it is different from the widget’s siteId and the help center’s public slug.
- In Settings → Developer → API keys → Create key, grant kb:read and kb:write. Limit Channel access to the channel you intend to sync. Save the one-time key in your server’s secret manager.
- Set the variables below in your backend environment. These examples use curl; replace uppercase IDs with the values returned by Sonny. Run them against a test channel before syncing a live help center.
# Load SONNY_API_KEY from your server's secret manager first.
# SOURCE_ID is the ID in the channel dashboard URL: /app/sources/SOURCE_ID
export SOURCE_ID="YOUR_SOURCE_ID"
export BASE="https://www.usesonny.com/api/v1/sources/$SOURCE_ID"Optional discovery: GET /api/v1/sources and the MCP tool list_sources return accessible channels and require conversations:read. You do not need that scope if you already have the channel ID. Domain connection remains a dashboard task.
Full API key setup2. Create or update the category
Use a stable ID from your source system in the URL. Sending the same external ID again updates the existing category instead of creating a duplicate. The first create initializes this channel’s help center automatically.
curl --fail-with-body -X PUT "$BASE/categories/by-external-id/billing" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Billing","position":0}'Expect 201 on create or 200 on update, with the category under data. Save data.id when you need Sonny’s internal category ID. URL-encode external IDs; keep them stable even when titles change.
3. Sync an article as a draft
curl --fail-with-body -X PUT "$BASE/articles/by-external-id/billing-guide" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Manage billing","body":"## Find your invoices\n\nOpen **Settings → Billing** in your account.","bodyFormat":"markdown","categoryExternalId":"billing","status":"draft"}'The response contains data.id and sanitized HTML in data.body. Open the article in Channels → your channel → Help Center to check its formatting. Markdown is converted to HTML; raw HTML is also accepted using bodyFormat: "html" (the default). The article title is separate from body headings.
Use categoryExternalId to refer to the category from step 2, or categoryId for its Sonny ID. Supply only one. Set either to null to uncategorize an article. Every upsert needs a title and body; PATCH is available for partial edits. New articles default to drafts when status is omitted.
4. Set access, then publish
For this private-center example, first connect verified customer sign-in. Create the audience and save its returned data.id:
curl --fail-with-body -X POST "$BASE/audiences" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Pro customers","rules":{"match":"all","conditions":[{"trait":"plan","op":"in","value":["pro","business"]}]}}'Replace AUDIENCE_ID, ARTICLE_ID, and the login URL below. The first request makes the help center Live and restricts it to Pro customers. The second publishes the article. Your app’s login flow must exchange a signed customer JWT with the help center; setting signInUrl alone does not sign anyone in.
curl --fail-with-body -X PATCH "$BASE/help-center" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled":true,"title":"Acme Help Center","audience":"verified","audienceId":"AUDIENCE_ID","signInUrl":"https://app.example.com/login"}'
# Replace ARTICLE_ID and AUDIENCE_ID with the returned data.id values.
curl --fail-with-body -X PATCH "$BASE/articles/ARTICLE_ID" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"audienceId":"AUDIENCE_ID","status":"published"}'Use the URL returned by GET /help-center to test the signed-in reader view and a signed-out browser. API keys and MCP act as staff and can read content within their scopes; their successful response does not prove that a customer can see the article.
For a public center, use audience: "everyone" and audienceId: null on the center and article, and check the category’s access too. To remove only a named group, send audienceId: null; verified-only access remains unless you explicitly change audience. An omitted audienceId retains the previous assignment. All help-center, category, and article restrictions must match.
Named rules use match: "all" or "any", up to 10 conditions, and strictly typed values. Operators: eq, neq, in, not_in, exists, not_exists, gt, gte, lt, lte. Numeric comparisons take numbers; in/not_in take arrays of 1–100 scalar values; exists/not_exists omit value. Missing traits fail comparisons. See rule behavior, inheritance, and access troubleshooting.
5. Keep the sync current
Repeat writes without duplicating content
Repeat PUT with the same external ID whenever your source changes. External IDs are unique within a help center. Omitted update fields keep their current values. Omit status on later upserts to preserve publication; explicitly sending draft withdraws a live article. publishedAt is set on first publication and stays unchanged. It is read-only, so historical publication dates cannot be imported. Unchanged content and metadata-only edits do not trigger unnecessary re-embedding.
Read changes and analytics
curl --fail-with-body --get "$BASE/articles" \
-H "Authorization: Bearer $SONNY_API_KEY" \
--data-urlencode "page=1" --data-urlencode "limit=100" \
--data-urlencode "status=published" \
--data-urlencode "updatedSince=2026-09-01T00:00:00Z"
# Get the full sanitized HTML and read-only analytics for one article.
curl --fail-with-body "$BASE/articles/ARTICLE_ID" \
-H "Authorization: Bearer $SONNY_API_KEY"Article and category lists return data plus pagination: page, limit, total. Pages start at 1; limit defaults to 50 and accepts up to 100. Keep the same filters and request pages until page × limit reaches total. Both lists accept externalId and updatedSince; articles also accept categoryId and status (draft or published). Use an ISO timestamp with a timezone for updatedSince; it includes records updated at that time.
Read each article detail to obtain its full body, viewCount, helpfulYes, and helpfulNo. These analytics are read-only. updatedSince lists current records; it does not report deletions. Track removals in your source system and explicitly delete the corresponding Sonny record when intended.
Upload an image
curl --fail-with-body -X POST "$BASE/media" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-F "file=@billing.png;type=image/png"Use returned data.url in Markdown image syntax or an HTML img element, then sync the article body. Uploads accept JPEG, PNG, GIF, and WebP up to 25 MB and require an initialized help center. Uploaded URLs are public; an article’s audience restriction does not protect the image URL. MCP uses upload_kb_media with sourceId, fileName, contentType, and the file’s dataBase64 instead of multipart form data.
Arrange articles and categories
curl --fail-with-body -X PUT "$BASE/articles/reorder" \
-H "Authorization: Bearer $SONNY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ids":["ARTICLE_ID_1","ARTICLE_ID_2"]}'First fetch every page without status, category, or date filters, including drafts. Replace the sample IDs with every current article ID exactly once, in the desired order. Use /categories/reorder for the complete category list. A successful reorder returns 204 with no body. If the list has changed or includes foreign, duplicate, or missing IDs, refresh it before retrying. You can also set a non-negative position in individual writes.
Use the same workflow through MCP
- Connect your MCP client and choose the correct workspace. Confirm it has kb:read and kb:write, with access to your channel.
- Use the dashboard’s channel ID, or call list_sources if your credential also has conversations:read. Never substitute the widget’s siteId.
- Run the first five examples below in order. Replace SOURCE_ID with your channel ID, and ARTICLE_ID/AUDIENCE_ID with each earlier result’s data.id. Replace the login URL and connect sign-in before publishing.
- Use list_kb_articles to review changes. Only run a reorder after collecting the complete unfiltered list, including drafts. Refresh your lists after successful writes.
Each example shows the tool name and its arguments. Upsert fields go directly in arguments; update tools put changed fields inside updates. These are tool-call inputs, not an HTTP request to the MCP endpoint.
1. upsert_kb_category
{
"name": "upsert_kb_category",
"arguments": {
"sourceId": "SOURCE_ID",
"externalId": "billing",
"name": "Billing",
"position": 0
}
}2. upsert_kb_article
{
"name": "upsert_kb_article",
"arguments": {
"sourceId": "SOURCE_ID",
"externalId": "billing-guide",
"title": "Manage billing",
"body": "## Find your invoices\n\nOpen **Settings → Billing** in your account.",
"bodyFormat": "markdown",
"categoryExternalId": "billing",
"status": "draft"
}
}3. create_kb_audience
{
"name": "create_kb_audience",
"arguments": {
"sourceId": "SOURCE_ID",
"name": "Pro customers",
"rules": {
"match": "all",
"conditions": [
{
"trait": "plan",
"op": "in",
"value": [
"pro",
"business"
]
}
]
}
}
}4. update_help_center
{
"name": "update_help_center",
"arguments": {
"sourceId": "SOURCE_ID",
"updates": {
"enabled": true,
"title": "Acme Help Center",
"audience": "verified",
"audienceId": "AUDIENCE_ID",
"signInUrl": "https://app.example.com/login"
}
}
}5. update_kb_article
{
"name": "update_kb_article",
"arguments": {
"sourceId": "SOURCE_ID",
"articleId": "ARTICLE_ID",
"updates": {
"audience": "verified",
"audienceId": "AUDIENCE_ID",
"status": "published"
}
}
}6. list_kb_articles
{
"name": "list_kb_articles",
"arguments": {
"sourceId": "SOURCE_ID",
"page": 1,
"limit": 100,
"updatedSince": "2026-09-01T00:00:00Z"
}
}7. reorder_kb_articles
{
"name": "reorder_kb_articles",
"arguments": {
"sourceId": "SOURCE_ID",
"ids": [
"ARTICLE_ID_1",
"ARTICLE_ID_2"
]
}
}8. update_kb_audience
{
"name": "update_kb_audience",
"arguments": {
"sourceId": "SOURCE_ID",
"audienceId": "AUDIENCE_ID",
"updates": {
"name": "Paid customers"
}
}
}Related operations
REST paths below are relative to BASE. Read the API reference for every field and response schema.
| Task | REST | MCP |
|---|---|---|
| List / get articles | GET /articles · GET /articles/{articleId} | list_kb_articles · get_kb_article |
| List / get categories | GET /categories · GET /categories/{categoryId} | list_kb_categories · get_kb_category |
| Create without an external ID | POST /articles · POST /categories | create_kb_article · create_kb_category |
| Edit existing content | PATCH /articles/{articleId} · PATCH /categories/{categoryId} | update_kb_article · update_kb_category |
| Order categories | PUT /categories/reorder | reorder_kb_categories |
| Read / edit help-center settings | GET /help-center · PATCH /help-center | get_help_center · update_help_center |
| List / edit audiences | GET /audiences · PATCH /audiences/{audienceId} | list_kb_audiences · update_kb_audience |
| Delete | DELETE /articles/{articleId} · /categories/{categoryId} · /audiences/{audienceId} | delete_kb_article · delete_kb_category · delete_kb_audience |
Deleting an article is permanent. Deleting a category uncategorizes its articles and removes that inherited category restriction. Deleting an audience removes its trait rules while leaving linked content verified-only. Review the affected access before deleting; use drafts to withdraw articles when you want to keep them.
Troubleshoot a sync
- 400 · Invalid request
- Check required title/body or name, exact field names, valid rule types, and complete reorder IDs. Send categoryId or categoryExternalId, not both.
- 401 · Unauthorized
- Supply a valid Bearer key. Check whether it expired or was revoked.
- 403 · Forbidden
- Check the key’s scopes, the key owner’s permissions, and workspace billing. Reconnect an MCP client with the required access if its tools are read-only.
- 404 · Not found
- Confirm the source belongs to the selected workspace and is allowed by the credential. Initialize a new help center by creating content first; reads do not create it.
- 409 · Conflict
- Re-fetch the current content and reconcile the conflicting ID or stale order before retrying.
- 413 · Too large
- Reduce the image below the 25 MB limit.
- 429 · Rate limited
- Wait for Retry-After before retrying. Use RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset to pace requests.
Related docs
- Help Center
Publish answers, organize categories, choose reader access, and keep your help center current.
- Help-center audiences
Create customer groups, make your help center private, and test who can read each answer.
- Public APIBeta
Create scoped API keys and integrate contacts, conversations, notes, and webhooks.
- Sonny MCPBeta
Connect Claude and other MCP clients to your workspace with OAuth or scoped API keys.