---
title: "REST API v1"
source: https://sumgenius.ai/docs/rest-api
generated: 2026-09-13
---

# REST API v1

Read and manage your ChatGenius data: contacts, conversations, messages, tags, appointments, and your team. One base URL, one API key, one envelope.

Webhook & API Add-on · $29/month · Requires Creator+ plan

Account

[*GET*/account](#get-account)

Webhooks & keys

[*GET*/webhooks/endpoints](#webhook-endpoints)

[*POST*/webhooks/endpoints](#webhook-endpoints)

[*GET*/webhooks/endpoints/{id}](#webhook-endpoints)

[*PATCH*/webhooks/endpoints/{id}](#webhook-endpoints)

[*DEL*/webhooks/endpoints/{id}](#webhook-endpoints)

[*POST*/webhooks/endpoints/{id}/rotate-secret](#webhook-endpoints)

[*POST*/webhooks/endpoints/{id}/test](#webhook-endpoints)

[*GET*/api-keys](#api-keys)

[*POST*/api-keys](#api-keys)

[*DEL*/api-keys/{id}](#api-keys)

Contacts

[*GET*/contacts](#list-contacts)

[*GET*/contacts/{id}](#get-contact)

[*POST*/contacts](#create-contact)

[*PATCH*/contacts/{id}](#update-contact)

[*POST*/contacts/{id}/tags](#add-contact-tag)

[*DEL*/contacts/{id}/tags/{tag}](#remove-contact-tag)

[*DEL*/contacts/{id}](#delete-contact)

Custom fields

[*GET*/custom-fields](#list-custom-fields)

[*POST*/custom-fields](#create-custom-field)

[*PATCH*/custom-fields/{id}](#update-custom-field)

Conversations

[*GET*/conversations](#list-conversations)

[*GET*/conversations/{id}](#get-conversation)

[*PATCH*/conversations/{id}](#update-conversation)

[*POST*/conversations/{id}/assign](#assign-conversation)

[*DEL*/conversations/{id}](#delete-conversation)

Messages

[*GET*/conversations/{id}/messages](#list-messages)

[*POST*/messages](#send-message)

Tags

[*GET*/tags](#list-tags)

[*POST*/tags](#create-tag)

[*DEL*/tags/{id}](#delete-tag)

Appointments

[*GET*/appointments](#list-appointments)

[*GET*/appointments/{id}](#get-appointment)

[*GET*/appointments/availability](#availability)

[*POST*/appointments](#book-appointment)

[*PATCH*/appointments/{id}](#update-appointment)

[*POST*/appointments/{id}/cancel](#cancel-appointment)

[*POST*/appointments/{id}/reschedule](#reschedule-appointment)

Flows

[*GET*/flows](#list-flows)

[*POST*/flows](#create-flow)

[*GET*/flows/nodes](#flow-nodes)

[*POST*/flows/validate](#validate-flow)

[*GET*/flows/{id}](#get-flow)

[*PATCH*/flows/{id}](#update-flow)

[*DEL*/flows/{id}](#flow-lifecycle)

[*GET*/flows/{id}/definition](#update-flow)

[*POST*/flows/{id}/publish](#flow-lifecycle)

[*POST*/flows/{id}/activate](#flow-lifecycle)

[*POST*/flows/{id}/pause](#flow-lifecycle)

[*POST*/flows/{id}/discard-draft](#flow-lifecycle)

[*POST*/flows/{id}/duplicate](#flow-lifecycle)

Flow sessions

[*GET*/flow-sessions](#list-flow-sessions)

[*GET*/flow-sessions/{id}](#get-flow-session)

[*GET*/flow-sessions/{id}/events](#list-flow-session-events)

Analytics

[*GET*/flows/{id}/analytics](#flow-analytics)

[*GET*/analytics/overview](#analytics-overview)

[*GET*/flow-events](#list-flow-events)

Opt-outs

[*GET*/opt-outs](#list-opt-outs)

Team

[*GET*/team](#get-team)

[*GET*/team/invitations](#team-invitations)

[*POST*/team/invitations](#team-invitations)

[*POST*/team/invitations/{id}/resend](#team-invitations)

[*DEL*/team/invitations/{id}](#team-invitations)

[*PATCH*/team/{id}](#update-team-member)

[*DEL*/team/{id}](#update-team-member)

AI configuration

[*GET*/ai/config](#get-ai-config)

[*PATCH*/ai/config](#update-ai-config)

[*GET*/ai/channels](#get-ai-channels)

[*PATCH*/ai/channels/{channel}](#update-ai-channel)

Knowledge base

[*GET*/knowledge](#knowledge-usage)

[*GET*/knowledge/faqs](#knowledge-faqs)

[*POST*/knowledge/faqs](#knowledge-faqs)

[*PATCH*/knowledge/faqs/{id}](#knowledge-faqs)

[*DEL*/knowledge/faqs/{id}](#knowledge-faqs)

[*GET*/knowledge/services](#knowledge-services)

[*POST*/knowledge/services](#knowledge-services)

[*PATCH*/knowledge/services/{id}](#knowledge-services)

[*DEL*/knowledge/services/{id}](#knowledge-services)

[*GET*/knowledge/documents](#knowledge-documents)

[*PATCH*/knowledge/documents/{id}](#knowledge-documents)

[*DEL*/knowledge/documents/{id}](#knowledge-documents)

[*GET*/knowledge-gaps](#knowledge-gaps)

Media library

[*GET*/media](#media)

[*POST*/media](#media)

[*PATCH*/media/{id}](#media)

[*DEL*/media/{id}](#media)

Blocked users

[*GET*/blocked-users](#blocked-users)

[*POST*/blocked-users](#blocked-users)

[*DEL*/blocked-users/{id}](#blocked-users)

Comment triggers

[*GET*/comment-triggers](#comment-triggers)

[*POST*/comment-triggers](#comment-triggers)

[*GET*/comment-triggers/{id}](#comment-triggers)

[*PATCH*/comment-triggers/{id}](#comment-triggers)

[*DEL*/comment-triggers/{id}](#comment-triggers)

[*POST*/comment-triggers/{id}/duplicate](#comment-triggers)

[*GET*/comment-triggers/{id}/stats](#comment-triggers)

[*GET*/comment-triggers/simulate](#simulate-comment)

[*GET*/recent-posts](#list-posts)

Comments (AI Comment Manager)

[*GET*/comments](#comments)

[*GET*/comments/{id}](#comments)

[*GET*/comments/stats](#comments)

[*POST*/comments/{id}/approve](#comments)

[*POST*/comments/{id}/reject](#comments)

[*POST*/comments/approve](#comments)

[*POST*/comments/reject](#comments)

Posts (Content Studio)

[*GET*/posts](#posts)

[*POST*/posts](#posts)

[*GET*/posts/capabilities](#post-capabilities)

[*GET*/posts/{id}](#posts)

[*PATCH*/posts/{id}](#posts)

[*DEL*/posts/{id}](#posts)

[*POST*/posts/{id}/media](#post-media)

[*PATCH*/posts/{id}/media](#post-media)

[*DEL*/posts/{id}/media/{mediaId}](#post-media)

[*POST*/posts/{id}/publish](#post-publishing)

[*POST*/posts/{id}/schedule](#post-publishing)

[*POST*/posts/{id}/unschedule](#post-publishing)

[*POST*/posts/{id}/retry](#post-publishing)

Market Intel

[*GET*/competitors](#competitors)

[*POST*/competitors](#competitors)

[*GET*/competitors/{id}](#competitor-compare)

[*DEL*/competitors/{id}](#competitors)

[*GET*/market-intel/leaderboard](#market-intel-leaderboard)

[*GET*/market-intel/history](#market-intel-leaderboard)

[*GET*/market-intel/wins](#market-intel-leaderboard)

[*GET*/market-intel/audience](#market-intel-audience)

[*GET*/market-intel/top-posts](#market-intel-audience)

[*GET*/market-intel/insights](#market-intel-audience)

[*POST*/market-intel/refresh](#market-intel-refresh)

### Machine-readable spec

Everything on this page, as an OpenAPI 3.1 document: [https://sumgenius.ai/api/v1/openapi.json](https://sumgenius.ai/api/v1/openapi.json). No key needed. Every endpoint, parameter, field, and allowed value is in it, including the scope each endpoint needs and the full list of webhook event names.

Give that URL to an SDK generator, an API client, or an AI assistant and it has the complete contract. The enums in it are generated from the same constants the API validates against, so they cannot drift from what the API accepts.

This page is also available as Markdown at [/docs/rest-api.md](https://sumgenius.ai/docs/rest-api.md).

Using an AI assistant instead of writing code? The [MCP server](https://sumgenius.ai/docs/mcp) wraps this API as 50 tools for Claude, Cursor and any MCP client.

**Fetch the spec**

```
curl https://sumgenius.ai/api/v1/openapi.json
```

### Authentication

Every request is authenticated with your API key from the portal's *Webhook & Send API* page. The same key opens the REST API, the [Send API](https://sumgenius.ai/docs/send-api), and [webhooks](https://sumgenius.ai/docs/chatgenius-webhook-api). Send it as any one of three headers; `X-SumGenius-Api-Key` is preferred.

The API is server-to-server. CORS is intentionally disabled, so call it from your backend and keep the key out of browsers.

- 401 unauthorized No API key sent.
- 401 invalid_api_key Key not recognized.
- 403 addon_inactive Key is valid but the add-on is not active.

**Base URL**

```
https://sumgenius.ai/api/v1
```

**Authenticated request**

```
curl https://sumgenius.ai/api/v1/account \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### Envelope, errors & pagination

The HTTP status carries success or failure. A single resource arrives as `{"data": {...}}`; a list adds `has_more` and `next_cursor`. Errors carry a stable `code` to branch on; `message` is human-facing and may change.

Lists are newest-first and cursor-paginated: pass `limit` (1 to 100, default 25) and feed `next_cursor` back as `starting_after` until `has_more` is `false`. Tags and team return their whole set in one page.

| HTTP | code | When |
| --- | --- | --- |
| 400 | invalid_param | A parameter failed validation; `param` names it |
| 400 | invalid_json | Body is not valid JSON |
| 403 | insufficient_scope | The key's [scope](https://sumgenius.ai/docs/api-authentication#scopes) does not cover this endpoint |
| 403 | plan_limit_reached | The plan cap for this resource is reached |
| 403 | feature_unavailable | The plan does not include this feature; `param` names it |
| 404 | not_found | Missing, or not owned by your account |
| 409 | resume_blocked · blocked · opted_out · window_closed | A conversation pause or resume the portal rules refuse; the message says why |
| 409 | already_blocked | That person is already on the AI blocklist |
| 409 | purge_active · sms_not_ready | SMS AI cannot be switched on: inbound body purge is on, or no Messaging Service yet |
| 409 | flow_conflict | A comment trigger cannot be activated because of its linked flows: more than one, a paused one, or a broken link; the message says which |
| 409 | not_connected | The channel you asked about is not connected to this account |
| 409 | idempotency_in_progress | A request with this `Idempotency-Key` is still running; retry after `Retry-After` |
| 400 | invalid_idempotency_key | `Idempotency-Key` is not 1 to 128 printable ASCII characters |
| 405 | method_not_allowed | Wrong method; the `Allow` header lists the valid ones |
| 422 | idempotency_key_reused | Same `Idempotency-Key`, different request |
| 422 | unprocessable_send | A send was permanently rejected by the channel: unsupported action, expired window, invalid recipient |
| 405 |  | Wrong method; the `Allow` header lists valid ones |
| 429 | rate_limited | REST request quota or burst ceiling reached |
| 503 | service_unavailable | Temporary safety or infrastructure failure; retry as directed |
| 500 | server_error | Unexpected failure on our side |

**List envelope**

```
{
  "data": [ ... ],
  "has_more": true,
  "next_cursor": "18son403"
}
```

**Error envelope**

```
{
  "error": {
    "code": "invalid_param",
    "message": "stage must be one of: new, contacted, qualified, customer, lost.",
    "param": "stage"
  }
}
```

### Idempotency

Send an `Idempotency-Key` header on any `POST`, `PATCH` or `DELETE` and a retry of the same request returns the same response instead of running twice. Use it whenever a network failure would leave you unsure whether the first attempt landed: creating records, sending messages, and later, bookings and posts.

- Idempotency-Keyheader, 1 to 128 printable characters Any string unique to the operation on your side, for example your own order or job id, or a UUID. Keys are scoped to your account and remembered for 24 hours.
- Replay A retry with the same key and the same method, path and body gets the original status and body back, with the header `Idempotent-Replayed: true`. Both successes and 4xx errors are replayed. A 5xx is never stored, so the same key can be retried after a server fault.
- Conflict The same key with a different body, path or method is `422 idempotency_key_reused`. A retry that arrives while the first request is still running is `409 idempotency_in_progress` with `Retry-After`.

[POST /messages](#send-message) also accepts `idempotency_key` in the body, which the send engine deduplicates on its own. The header works there too, and using both is harmless.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/contacts \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Idempotency-Key: crm-sync-8842" \
  -H "Content-Type: application/json" \
  -d '{"channel":"instagram","platform_user_id":"17845629031224067","name":"Riley"}'
```

**Retry (same key, same body)**

```
HTTP/1.1 201 Created
Idempotent-Replayed: true
Content-Type: application/json

{ "data": { ...the original response... } }
```

### Rate limits

Each account may make up to **200 authenticated REST requests per minute**, with short bursts of up to **20 requests per second**. All active API keys for the account share the same quota, so creating another key does not increase it.

The per-minute quota is measured over a rolling 60-second window rather than a fixed clock minute, so capacity returns gradually as individual requests age out instead of all at once on a boundary.

A separate system-wide safety ceiling protects API availability during aggregate traffic spikes. When either ceiling is reached, the API returns `429 rate_limited`. Wait for `Retry-After` before retrying; use exponential backoff rather than an immediate retry loop.

Every authenticated REST response includes `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`. Reset is a Unix timestamp. The equivalent `X-RateLimit-*` headers are also sent for compatibility.

This quota controls requests accepted by `/api/v1`. Message platforms enforce their own separate, account-specific delivery limits; temporary platform throttles continue through the existing queue and retry behavior.

**Rate-limit headers**

```
RateLimit-Limit: 200
RateLimit-Remaining: 182
RateLimit-Reset: 1785456060
```

**Response 429**

```
Retry-After: 1

{
  "error": {
    "code": "rate_limited",
    "message": "Too many REST API requests. Retry after the number of seconds in Retry-After."
  }
}
```

### Data conventions

- timestampsUTC ISO-8601 Everywhere, e.g. `2026-07-25T04:35:23+00:00`. The `updated_since` filters accept the same format.
- channelenum `facebook` · `instagram` · `sms` · `telegram` · `whatsapp`
- stageenum `new` · `contacted` · `qualified` · `customer` · `lost`
- statusenum `active` · `resolved` · `pending` · `archived` · `escalated` (conversations)
- custom_fieldsobject Your own per-contact data, as `{field_key: value}`. Only fields that are set and active appear; unset fields are omitted, never `null`. Define fields with [POST /custom-fields](#create-custom-field), write values through [PATCH /contacts/{id}](#update-contact).

**Identity**

```
// A contact is keyed by its natural identity:
(channel, platform_user_id)

// e.g. an Instagram user:
("instagram", "17845629031224067")
```

## Account

### GET /account

Your account identity, plan, and configured webhook endpoints. A good first call to verify a key works.

**Request**

```
curl https://sumgenius.ai/api/v1/account \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{
  "data": {
    "account": {
      "id": 1042,
      "business_name": "Riverside Dental",
      "plan": "Professional",
      "addon": "webhook_api"
    },
    "api_key": { "id": 6, "scope": "write" },
    "webhooks": {
      "endpoints": [
        {
          "id": 7,
          "name": "Production endpoint",
          "url": "https://api.example.com/webhooks",
          "enabled": true,
          "event_filters": ["message.received"],
          "envelope_style": "standard"
        }
      ]
    }
  }
}
```

## Webhooks & keys

What the portal's Webhook & API page does, for a program. A person creates the first key in the portal; from there an integration can register the endpoint it wants events delivered to, test it, rotate its secret, and mint narrower keys for its own components. Writes need the `admin` scope. The wire format (envelope, signature) is not configurable here.

### GET /webhooks/endpoints · POST /webhooks/endpoints · GET PATCH DELETE /webhooks/endpoints/{id} · POST …/{id}/rotate-secret · POST …/{id}/test

An endpoint is a URL you own that receives [events](https://sumgenius.ai/docs/webhook-events). Up to three per account. Each has its own signing secret.

Create body

- name · urlrequired `url` must be https on a public host.
- eventsarrayoptional Event names to deliver. `[]` or omitted means every event. Names come from the [events reference](https://sumgenius.ai/docs/webhook-events) or `x-webhook-events` in the OpenAPI document.
- governanceobjectoptional The data-sharing declaration: `attested`, `purpose` (at least 8 characters), `destination_owner`, `payload_profile` (`full` carries message text, `minimal` omits it). Partial on PATCH.

**Going live is a separate step.** An endpoint is created paused. `PATCH {"enabled": true}` turns it on, and is refused with `422 governance_incomplete` until the declaration is attested with a purpose. Pausing stops new events from queueing; anything already queued waits and delivers when you enable it again.

**Secrets.** `signing_secret` is returned once on create and once on rotate, never on read. After a rotation the previous secret keeps verifying for about 24 hours.

**Test.** `POST …/{id}/test` with `{"channel": "meta"}` queues a sample event and delivers it right away, paused or not. `delivered` tells you whether your server answered 2xx.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/webhooks/endpoints \
  -H "X-SumGenius-Api-Key: sgwh_your_admin_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM sync",
    "url": "https://crm.example.com/hooks/chatgenius",
    "events": ["lead.captured", "contact.updated", "appointment.booked"],
    "governance": {"attested": true, "purpose": "Sync leads and bookings into our CRM", "destination_owner": "Example Co", "payload_profile": "minimal"}
  }'
```

**Response 201**

```
{
  "id": 12,
  "name": "CRM sync",
  "url": "https://crm.example.com/hooks/chatgenius",
  "enabled": false,
  "events": ["lead.captured", "contact.updated", "appointment.booked"],
  "governance": { "attested": true, "complete": true, "purpose": "Sync leads and bookings into our CRM", "destination_owner": "Example Co", "payload_profile": "minimal" },
  "timeout_ms": 10000, "max_attempts": 6, "secret_rotated_at": null,
  "stats": { "delivered": 0, "failed": 0, "last_delivered_at": null },
  "signing_secret": "whsec_…"
}
```

**Go live**

```
curl -X PATCH https://sumgenius.ai/api/v1/webhooks/endpoints/12 \
  -H "X-SumGenius-Api-Key: sgwh_your_admin_key" \
  -H "Content-Type: application/json" -d '{"enabled": true}'
```

### GET /api-keys · POST /api-keys · DELETE /api-keys/{id}

Up to ten active keys per account. The list shows every key with a `preview` and its `scope`; `is_current` marks the one making the call. Key material is never listed.

**Create** takes `name` and `scope` (`read` by default). The plaintext comes back once as `api_key`. A key can mint keys up to its own reach: a `full` key can mint any scope, an `admin` key can mint `read`, `write` or `admin`.

**Revoke** stops the key at once. A key cannot revoke itself (`409 cannot_revoke_self`); use another key or the portal.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/api-keys \
  -H "X-SumGenius-Api-Key: sgwh_your_admin_key" \
  -H "Content-Type: application/json" \
  -d '{"name": "Reporting bot", "scope": "read"}'
```

**Response 201**

```
{
  "id": 14,
  "name": "Reporting bot",
  "preview": "9f2c11ab",
  "scope": "read",
  "is_current": false,
  "revoked": false,
  "created_at": "2026-09-07T09:12:40+00:00",
  "last_used_at": null,
  "revoked_at": null,
  "api_key": "sgwh_9f2c11ab…"
}
```

## Contacts

A contact is a person, keyed by `(channel, platform_user_id)`. Contacts are created automatically when someone messages you, or explicitly via the API.

### GET /contacts

List contacts, newest first. Filters combine with AND. An invalid filter value returns `400 invalid_param`.

Query parameters

- channelenumoptional
- stageenumoptional
- tagstringoptional Exact tag name; contacts carrying that tag.
- emailstringoptional Exact match.
- phonestringoptional Exact match.
- namestringoptional Substring match on the display name.
- updated_sincetimestampoptional Contacts updated at or after this time.
- limit · starting_afterpaginationoptional

**Request**

```
curl "https://sumgenius.ai/api/v1/contacts?stage=qualified&limit=25" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the contact resource**

```
{
  "data": [
    {
      "id": 19214,
      "channel": "instagram",
      "platform_user_id": "17845629031224067",
      "name": "Riley Chen",
      "email": "riley@example.com",
      "phone": null,
      "ig_username": "rileychen",
      "profile_pic_url": null,
      "stage": "qualified",
      "tags": ["VIP"],
      "lead_score": 30,
      "lead_temperature": "warm",
      "notes": null,
      "custom_fields": { "crm_deal_id": "D-2214" },
      "first_seen_at": "2026-07-25T04:35:23+00:00",
      "first_user_message_at": "2026-07-25T04:36:10+00:00",
      "last_seen_at": "2026-07-26T08:05:04+00:00",
      "created_at": "2026-07-25T04:35:23+00:00",
      "updated_at": "2026-07-26T08:05:04+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### GET /contacts/{id}

One contact by id, in the same shape as the list. A non-numeric, unknown, or unowned id returns `404`.

**Request**

```
curl https://sumgenius.ai/api/v1/contacts/19214 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### POST /contacts

Create or update a contact by its natural identity. A new identity returns `201`; an existing one is updated and returns `200`. Updates are partial: omitted fields are untouched and existing tags are kept (`tags` adds, it does not replace).

Body

- channelenumrequired
- platform_user_idstringrequired The person's id on that channel, 1 to 255 characters. For SMS this is the phone number.
- namestringoptional Up to 255 characters.
- emailstringoptional Valid email address.
- phonestringoptional Digits and `+ ( ) . - # x`, 3 to 50 characters.
- stageenumoptional
- notesstringoptional Up to 5,000 characters.
- tagsstring\[\]optional Tag names; missing tags are created automatically.
- custom_fieldsobjectoptional Merged into the contact's fields, applied atomically with the rest of the upsert. Same semantics as [PATCH](#update-contact). Fields must already be defined.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/contacts \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "platform_user_id": "+17025550100",
    "name": "Riley Chen",
    "stage": "new",
    "tags": ["Imported"]
  }'
```

**Response 201 · 200 on update**

```
{
  "data": {
    "id": 19301,
    "channel": "sms",
    "platform_user_id": "+17025550100",
    "name": "Riley Chen",
    "stage": "new",
    "tags": ["Imported"],
    ...
  }
}
```

### PATCH /contacts/{id}

Partial update by id. Only fields present in the body change. Send `""` to clear a field. Idempotent by nature, so no idempotency key is needed. An empty body returns `400` listing the updatable fields.

Body · all optional

- stageenum
- namestring Up to 255 characters, or `""` to clear.
- emailstring Valid email, or `""` to clear.
- phonestring 3 to 50 characters, or `""` to clear.
- notesstring Up to 5,000 characters, or `""` to clear.
- custom_fieldsobject Merge semantics: only the keys you send change, `null` as a value clears that field, other fields are untouched. Values are validated against each field's type (`400` names the offending key). Every actual change fires a [contact.field_changed](https://sumgenius.ai/docs/webhook-events#contact-field-changed) webhook event.

**Request**

```
curl -X PATCH https://sumgenius.ai/api/v1/contacts/19214 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "stage": "customer",
    "notes": "Paid annual plan on Jul 29"
  }'
```

**Response 200**

```
{
  "data": {
    "id": 19214,
    "stage": "customer",
    "notes": "Paid annual plan on Jul 29",
    ...
  }
}
```

### POST /contacts/{id}/tags

Attach a tag by name. A tag that does not exist yet is created, then attached. Re-attaching is a no-op `200`. Returns the updated contact.

Body

- namestringrequired 1 to 50 characters. Created if missing, with the default color.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/contacts/19214/tags \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "VIP"}'
```

### DELETE /contacts/{id}/tags/{tag}

Detach a tag from a contact. `{tag}` is a tag id or a URL-encoded name; a name that is all digits or contains `/` must go by id. Detaching a tag the contact does not have is an idempotent `200`; a tag that does not exist at all is `404`. Only the attachment is removed; the tag itself survives.

**Request**

```
curl -X DELETE https://sumgenius.ai/api/v1/contacts/19214/tags/VIP \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### DELETE /contacts/{id}

Permanently erases a person: the contact and every conversation, message, and appointment they have. Built for right-to-erasure requests.

> **Warning**
>
> **This cannot be undone.** To remove one thread and keep the person, use [delete conversation](#delete-conversation) instead.

Tag definitions and send-history audit records survive. Repeating the call is a safe `404`.

**Request**

```
curl -X DELETE https://sumgenius.ai/api/v1/contacts/19214 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{
  "data": {
    "id": 19214,
    "object": "contact",
    "deleted": true
  }
}
```

## Custom fields

Your own per-contact data. Define up to 15 fields (text, number, date, or boolean), then write values through [PATCH /contacts/{id}](#update-contact). Values appear in every contact resource and in full-profile webhook payloads, and every change fires a [contact.field_changed](https://sumgenius.ai/docs/webhook-events#contact-field-changed) event. Don't store passwords, API keys, or payment card numbers in field values.

### GET /custom-fields

Your field definitions, in creation order. Active fields only by default.

Query parameters

- include_archivedbooleanoptional Also return archived definitions (with `archived` true) so their ids stay discoverable for unarchiving.

**Request**

```
curl https://sumgenius.ai/api/v1/custom-fields \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{
  "data": [
    {
      "id": 3,
      "object": "custom_field",
      "field_key": "crm_deal_id",
      "label": "CRM deal ID",
      "type": "text",
      "archived": false,
      "created_at": "2026-08-01T18:20:11+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### POST /custom-fields

Define a field. Returns `201` on create, `200` if an equivalent field already exists (same key and type), `409` if the key exists with a different type or archived, and `400` at the 15-active-field limit.

Body

- field_keystringrequired 1 to 50 characters of `a-z 0-9 _`. **Permanent after create**, it is the JSON key in every payload.
- labelstringrequired Display name, 1 to 100 characters. Renameable later.
- typeenumoptional `text` (default, up to 500 chars) · `number` · `date` (`YYYY-MM-DD`) · `boolean`. Permanent after create.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/custom-fields \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "field_key": "crm_deal_id",
    "label": "CRM deal ID",
    "type": "text"
  }'
```

**Then set a value**

```
curl -X PATCH https://sumgenius.ai/api/v1/contacts/19214 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "custom_fields": { "crm_deal_id": "D-2214" }
  }'
```

### PATCH /custom-fields/{id}

Rename a field's label, or archive / unarchive it. `field_key` and `type` are immutable, sending either returns `400`. There is no delete: archiving hides a field from every surface without touching any contact data, and unarchiving restores it (subject to the 15-active limit).

Body

- labelstringoptional
- archivedbooleanoptional `true` hides the field everywhere; `false` restores it, values intact. Writes to an archived field's values return `400`.

**Request · archive**

```
curl -X PATCH https://sumgenius.ai/api/v1/custom-fields/3 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "archived": true }'
```

## Conversations

One thread per person per channel. Deleted conversations are never returned.

### GET /conversations

List conversations, newest first. `contact_id` links each thread to its person.

Query parameters

- channelenumoptional
- statusenumoptional
- assigned_tointegeroptional A team member id from [team](#get-team).
- updated_sincetimestampoptional
- limit · starting_afterpaginationoptional

> **Note**
>
> **`assigned_to` id space:** positive is a team member (`assigned_to_name` resolves); negative is an owner-level assignment (`assigned_to_name` is `null`); `null` is unassigned.

**Request**

```
curl "https://sumgenius.ai/api/v1/conversations?status=escalated" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the conversation resource**

```
{
  "data": [
    {
      "id": 188892,
      "channel": "instagram",
      "platform_user_id": "17845629031224067",
      "customer_name": "Riley",
      "status": "escalated",
      "assigned_to": 90011,
      "assigned_to_name": "Sam Ortiz",
      "ai_paused": true,
      "ai_pause_reason": "assigned",
      "contact_id": 19214,
      "lead_email": "riley@example.com",
      "lead_phone": null,
      "last_message_at": "2026-07-26T08:05:04+00:00",
      "created_at": "2026-07-25T04:35:23+00:00",
      "updated_at": "2026-07-26T13:00:54+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### GET /conversations/{id}

One conversation by id, in the same shape as the list. A non-numeric, unknown, or unowned id returns `404`.

**Request**

```
curl https://sumgenius.ai/api/v1/conversations/188892 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### PATCH /conversations/{id}

Change a conversation's status, or take it away from the AI and hand it back. Two optional fields, send one or both. Returns the conversation, which now carries `ai_paused` and `ai_pause_reason` so you can see the effect. Needs the `write` scope.

- statusstringoptional `active`, `resolved` or `archived`. Resolving or archiving hands the thread back to the AI, exactly like the inbox. `pending` and `escalated` are set by the system and cannot be sent.
- ai_pausedbooleanoptional `true` takes the thread away from the AI as the account owner, the inbox Claim button. It reopens a resolved thread. Refused with `409` when the customer is blocked, has opted out, or the Meta messaging window has closed. `false` hands the thread back to the AI, the inbox Transfer to AI. Refused with `409 resume_blocked` when the pause came from a solicitation pause, a fraud flag or a connected-account pause; those are lifted in the portal only.
- ai_pause_reasonin the response `escalated`, `assigned`, `owner_claimed`, `solicitation`, `fraud`, `connected_account`, or `null` when the AI is live. The AI is muted on any thread that is assigned or escalated; this field tells you which.

Status is applied first, then `ai_paused`. Fires [conversation.resolved](https://sumgenius.ai/docs/webhook-events#conversation-resolved) and [conversation.assigned](https://sumgenius.ai/docs/webhook-events#conversation-assigned) as appropriate.

**Pause the AI on one thread**

```
curl -X PATCH https://sumgenius.ai/api/v1/conversations/188892 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"ai_paused": true}'
```

**Resolve it**

```
curl -X PATCH https://sumgenius.ai/api/v1/conversations/188892 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"status": "resolved"}'
```

**409 resume_blocked**

```
{
  "error": {
    "code": "resume_blocked",
    "message": "The AI is paused on this conversation by a fraud flag. That can only be lifted from the portal.",
    "param": "ai_paused"
  }
}
```

### POST /conversations/{id}/assign

Assign or unassign a conversation, exactly like the inbox does it: a `pending` or `escalated` thread becomes `active`, history is recorded, and a system line is added to the transcript. Returns the updated conversation.

Body

- assigned_tointeger | nullrequired A team member id from [team](#get-team), or `null` to unassign. An unknown id is `400`.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/conversations/188892/assign \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"assigned_to": 90011}'
```

### DELETE /conversations/{id}

Permanently deletes one conversation: the full transcript, in-chat appointments, tag labels, and assignment records.

> **Warning**
>
> **This cannot be undone.** The contact survives; to erase the person entirely use [delete contact](#delete-contact).

The response counts what was removed. Analytics rows are kept but stripped of message text and unlinked; send-history audit survives. A repeat delete is a safe `404`.

**Request**

```
curl -X DELETE https://sumgenius.ai/api/v1/conversations/188892 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{
  "data": {
    "id": 188892,
    "object": "conversation",
    "deleted": true,
    "removed": {
      "messages": 15,
      "appointments": 1,
      "conversation_tags": 2,
      "contexts": 1,
      "metrics_detached": 0
    }
  }
}
```

## Messages

### GET /conversations/{id}/messages

The transcript of one conversation, newest first (default limit 50). Copilot drafts are excluded; this is the conversation as the customer saw it.

- directionderived `user` is inbound; `bot`, `agent`, `followup` are outbound; the rest is system.
- typeenum `text` · `image` · `video` · `audio` · `file` · `quick_reply` · `button` · `postback` · `comment_trigger`
- body_purgedboolean When a body was purged for compliance, `text` is `null` and this is `true`; the message is still listed.

**Request**

```
curl "https://sumgenius.ai/api/v1/conversations/188892/messages" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the message resource**

```
{
  "data": [
    {
      "id": 56698,
      "message_id": "usr_6a65bfb099523",
      "direction": "inbound",
      "sender_type": "user",
      "sender_name": null,
      "type": "text",
      "text": "Do you have anything open Friday?",
      "attachments": [],
      "is_template": false,
      "body_purged": false,
      "created_at": "2026-07-26T08:05:04+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "56610"
}
```

### POST /messages

Send on any channel. Same engine as the [Send API](https://sumgenius.ai/docs/send-api) with full parity; recommended for new integrations. Route by `conversation_id`, by `platform` + `platform_user_id`, or by `to_phone` for SMS and WhatsApp. To reply privately to an Instagram or Facebook comment (with optional buttons), add `comment_id`, full reference under [private reply](https://sumgenius.ai/docs/send-api#private-reply); the body is identical here.

Body

- idempotency_keystringrequired Your unique key per send. A replay returns the original result without resending. Also accepted as an `Idempotency-Key` header.
- actionenumoptional `send_message` (default) · `react` · `unreact` · `typing_on`. Support varies by channel.
- conversation_idinteger Route by an existing thread.
- platform + platform_user_idstring Route by the person's channel id.
- to_phonestring Route by phone (SMS, WhatsApp).
- message_textstringrequired Not required for `typing_on` or WhatsApp template sends (`template_name`, `template_language`, `template_components`).

Cold outbound

SMS, WhatsApp, and Telegram may send to a recipient with no existing conversation; the thread is created automatically. Facebook and Instagram require the customer to message first.

> **Warning**
>
> A failed cold send still creates the conversation and contact before the delivery attempt. Repeats reuse the same records; remove them with [delete contact](#delete-contact) if unwanted.

Send errors

| HTTP | code | When |
| --- | --- | --- |
| 400 | invalid_request | Missing or invalid field |
| 403 | forbidden | Channel not permitted |
| 404 | not_found | No conversation for the routing given |
| 410 | recipient_opted_out | Recipient opted out (SMS STOP) |
| 412 | not_provisioned | Channel not connected |
| 422 | unprocessable_send | Permanent rejection: unsupported action, expired window, invalid recipient |
| 429 | rate_limited | Too many sends; retry later |
| 503 | service_unavailable | Temporary failure; may queue for retry |

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/messages \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotency_key": "order-4821-confirm",
    "conversation_id": 188892,
    "message_text": "Your order shipped today."
  }'
```

**Response 200**

```
{
  "data": {
    "status": "sent",
    "request_id": "msgreq_c8d83cf861ec1f48",
    "conversation_id": 188892,
    "platform": "instagram",
    "action": "send_message",
    "sent_at": "2026-07-30T06:14:18+00:00",
    "message": "Message sent successfully."
  }
}
```

**Error 422**

```
{
  "error": {
    "code": "unprocessable_send",
    "message": "action 'react' is not supported for platform 'telegram'.",
    "status": "dead_letter"
  }
}
```

## Tags

Tag definitions live account-wide; attaching them to contacts is a [contact operation](#add-contact-tag).

### GET /tags

All tag definitions, ordered by name, in one page. `name` is what the contacts `?tag=` filter matches.

**Request**

```
curl https://sumgenius.ai/api/v1/tags \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{
  "data": [
    {
      "id": 14,
      "name": "Booking",
      "color": "#DD6B20",
      "created_at": "2026-03-06T19:55:17+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### POST /tags

Create a tag definition. Idempotent on name: new returns `201`, existing returns `200` with its color unchanged.

Body

- namestringrequired 1 to 50 characters, unique per account.
- colorstringoptional 6-digit hex like `#FF0000`; anything else is `400`.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/tags \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "VIP", "color": "#FF0000"}'
```

### DELETE /tags/{id}

Deletes a tag definition account-wide and removes it from every contact and conversation carrying it. By numeric id only.

**Request**

```
curl -X DELETE https://sumgenius.ai/api/v1/tags/14 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{"data": {"id": 14, "deleted": true}}
```

## Appointments

Bookings made through the [AI booking agent](https://sumgenius.ai/docs/appointment-booking). Read-only.

### GET /appointments

List bookings, newest first.

Query parameters

- statusstringoptional Exact match, e.g. `confirmed` or `cancelled`. Free text: an unknown value returns an empty list, not `400`.
- conversation_idintegeroptional
- limit · starting_afterpaginationoptional

> **Note**
>
> **`datetime` is UTC, booked in `timezone`.** Convert back with the `timezone` field to display the time the customer chose. `meeting_link` and `google_event_id` are `null` without Google Calendar sync.

**Request**

```
curl "https://sumgenius.ai/api/v1/appointments?status=confirmed" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the appointment resource**

```
{
  "data": [
    {
      "id": 91772,
      "confirmation_code": "SG-FHZS9V",
      "conversation_id": 187864,
      "contact_id": 16240,
      "channel": "instagram",
      "datetime": "2026-07-24T23:00:00+00:00",
      "timezone": "America/Los_Angeles",
      "duration_minutes": 60,
      "service_type": "Consultation",
      "cost": "0.00",
      "status": "confirmed",
      "meeting_link": "https://meet.google.com/abc-defg-hij",
      "google_event_id": "oa9daqujesg8dktkb777ds5gs0",
      "reminder_sent": false,
      "location": {"city": null, "state": null},
      "customer": {
        "name": "Riley",
        "email": "riley@example.com",
        "phone": "+17025550100",
        "address": null
      },
      "notes": "Prefers mornings",
      "created_at": "2026-07-24T07:33:22+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### GET /appointments/{id}

One appointment by id, same shape as the list. Unknown or unowned id returns `404`.

**Request**

```
curl https://sumgenius.ai/api/v1/appointments/91772 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### GET /appointments/availability

The open slots for one day: business hours minus existing bookings, stepped by the service duration plus the buffer, and never sooner than the minimum notice. Exactly what the booking bot offers and the portal picker shows. An empty list means the day is closed or full.

- dateYYYY-MM-DDrequired In the account timezone, returned as `timezone`.
- service_typestringoptional A bookable service name (see `booking.bookable_services` on [/ai/config](#get-ai-config)). Sets the slot length; unknown or omitted means 30 minutes.
- city · statestringoptional Location-specific availability for multi-location accounts.

**Response 200**

```
{
  "data": {
    "date": "2026-09-10",
    "timezone": "America/Los_Angeles",
    "service_type": "ChatGenius Setup",
    "duration_minutes": 60,
    "slots": [
      { "time": "09:00", "label": "9:00 AM" },
      { "time": "10:15", "label": "10:15 AM" }
    ]
  }
}
```

### POST /appointments

Book a slot. Same engine as the portal calendar and the bot: the account's required customer fields are enforced, the time must be in the future and free, the appointment is confirmed at once, it is synced to Google Calendar and GoHighLevel when connected, and [appointment.booked](https://sumgenius.ai/docs/webhook-events#appointments) fires. Needs the `write` scope.

- date · timeYYYY-MM-DD · HH:MMrequired The customer's wall-clock time in `timezone` (the account timezone when omitted). Use a `time` from [availability](#availability).
- customerobjectrequired `{name, email, phone, address}`. `name` is always required; the rest follow the account's `booking.required_fields`. Missing ones are `422 missing_fields` with the list.
- conversation_idintegeroptional Book for the person you are chatting with. Sets the channel, links the contact, and sends them the confirmation DM unless `send_confirmation` is false. Without it the booking is a walk-in with no DM.
- service_type · duration_minutes · location · vehicle_info · cost · notesoptional `duration_minutes` defaults to the service's duration from the service menu, else 30.

A taken or out-of-hours slot is `409 slot_unavailable`. A past time is `400`.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/appointments \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "date": "2026-09-10",
    "time": "10:15",
    "service_type": "ChatGenius Setup",
    "conversation_id": 236067,
    "customer": {"name": "Jared", "email": "jared@example.com"}
  }'
```

**Response 201 · the appointment resource**

```
{ "data": { "id": 92150, "confirmation_code": "SG-K2M7QX", "status": "confirmed", "datetime": "2026-09-10T17:15:00+00:00", ... } }
```

### PATCH /appointments/{id}

Edit details and/or status, the portal's edit form. Send only what changes: `customer`, `location`, `service_type`, `duration_minutes`, `timezone`, `vehicle_info`, `cost`, `notes`, `status` (pending, confirmed, completed, cancelled, no_show). Detail edits are pushed to the connected calendars; a status of `cancelled` removes the calendar event and fires the cancelled event. To move the time use [reschedule](#reschedule-appointment), which checks the slot; this endpoint does not take a date.

The response carries `sync_warning: true` when a connected calendar refused the change. The appointment itself was saved.

**Request**

```
curl -X PATCH https://sumgenius.ai/api/v1/appointments/92150 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"notes": "Bring the login", "status": "completed"}'
```

### POST /appointments/{id}/cancel

Sets the status to cancelled, removes the Google Calendar and GoHighLevel events, and fires [appointment.cancelled](https://sumgenius.ai/docs/webhook-events#appointments). `409 already_cancelled` if it already is. Send `{}` as the body. The row is kept; there is no delete.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/appointments/92150/cancel \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" -d '{}'
```

### POST /appointments/{id}/reschedule

Moves a pending or confirmed appointment to a new `date` and `time` (in the appointment's timezone) after checking the slot is free, updates the connected calendars, and fires [appointment.rescheduled](https://sumgenius.ai/docs/webhook-events#appointments). When the booking came from a conversation the customer is told in the DM, like the bot does. `409 slot_unavailable` when the slot is taken, `409 not_active` when the appointment is cancelled, completed or a no-show.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/appointments/92150/reschedule \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"date": "2026-09-11", "time": "14:00"}'
```

> **Note**
>
> **Recording consent.** On Instagram and Messenger the basis for replying is that the person messaged you first, and `first_user_message_at` on the contact is when that happened. It is `null` for someone who only ever commented and never opened a DM. If your flow collects an explicit consent answer, the value lives in `custom_fields`, and the [contact.field_changed](https://sumgenius.ai/docs/webhook-events) webhook delivers the `changed_at` and `source` alongside it at the moment it is set. Reading the contact back later returns the current value only, so keep the webhook if you need the audit trail.

## Flows

Your automations, as built in the [flow builder](https://sumgenius.ai/docs/chatgenius-flow-builder). Read them, and build them: a flow is a JSON **definition** (one trigger, a list of nodes, the edges between their ports), and [GET /flows/nodes](#flow-nodes) is the catalogue that tells a person or an LLM exactly which node types, sources, config keys and enum values exist. [Validate](#validate-flow) a definition, [create](#create-flow) it as a draft, then [publish, activate or pause](#flow-lifecycle) it. A flow created here opens in the visual builder like any other; positions are laid out automatically the first time.

This is also the parent of [flow sessions](#list-flow-sessions): a session records the `flow_id` and `flow_version` it ran under.

### GET /flows

List flows, newest first.

Query parameters

- statusstringoptional `draft`, `active` or `paused`.
- trigger_typestringoptional `keyword`, `first_message`, `comment_trigger` or `button_tap`.
- limit · starting_afterpaginationoptional

> **Note**
>
> **Definitions are not in the list.** `GET /flows/{id}` and `GET /flows/{id}/definition` return the definition. What starts the flow is: `trigger.sources[]` lists each source (`dm`, `comment`, `ai_intent`, `story_reply`...) with its keywords; a `comment` source with `mode: linked` and a `comment_trigger_id` belongs to a [comment trigger](#comment-triggers), while `mode: native` means the flow matches its own keywords. This endpoint gives you what you need to read session and event data. `completion_rate` is `null` when nobody has entered the flow, which is not the same as 0%.

**Request**

```
curl "https://sumgenius.ai/api/v1/flows?status=active" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the flow resource**

```
{
  "data": [
    {
      "id": 178,
      "name": "Comment to guide",
      "status": "active",
      "trigger_type": "comment_trigger",
      "channels": ["instagram"],
      "version": 4,
      "published_version": 4,
      "priority": 0,
      "stats": {
        "entries": 312,
        "completions": 188,
        "completion_rate": 0.6026
      },
      "created_at": "2026-08-02T14:10:03+00:00",
      "updated_at": "2026-08-26T15:41:55+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### GET /flows/{id}

Retrieve one flow. The list resource plus `definition` (the draft when one exists, otherwise the published version), `has_draft`, `draft_behavior_changed`, `node_count` and `tag_id`. `404` if it does not exist, was deleted, or belongs to another account.

**Request**

```
curl https://sumgenius.ai/api/v1/flows/178 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### GET /flows/nodes

The node catalogue: everything a definition may contain. Read it once before building a flow; it is the contract the validator enforces.

- definitionobject The shape of a definition and the structural rules (one trigger with id `"1"`, every other node reachable, ports must exist, node cap).
- sourcesobject The 13 trigger sources keyed by name (`dm`, `comment`, `story_reply`, `ai_intent`, `first_message`...). Each has `channels` it can fire on, `needs_keywords`, and its `config` schema.
- nodesobject The 12 node types keyed by `type`. Each has `inputs`, `outputs` (the port names, or the rule that derives them: a `message` gets one port per postback button plus `output_timeout`; a `condition` gets one per rule plus a fall-through), `terminal`, a description, and a JSON-Schema-style `config` with required keys and enums.
- enums · placeholdersobject · array Every allowed value (channels, match types, input types, rule types, action types, delay units, HTTP methods, button and media types) and the `{placeholders}` a message may use.
- liveobject This account's options: `custom_fields`, `tags`, `services` (for `book_appointment`), other `flows` (for `goto_flow`), `comment_triggers` (for a linked comment source), plus `node_limit`, `flow_limit` and `active_flows` from the plan.

**Response 200 · excerpt**

```
{
  "sources": {
    "dm": { "label": "Keyword DM", "channels": ["facebook", "instagram", "whatsapp"], "needs_keywords": true, "config": { ... } },
    "story_reply": { "label": "Story reply", "channels": ["instagram"], "needs_keywords": false, "config": { ... } }
  },
  "nodes": {
    "collect_input": {
      "label": "Collect input", "inputs": 1,
      "outputs": { "output_1": "valid answer collected", "output_2": "max retries exhausted" },
      "terminal": false,
      "config": { "type": "object", "properties": { "question": { ... }, "input_type": { "enum": ["email", "phone", "name", "text", "number"] }, ... }, "required": ["question", "input_type", "variable_name"] }
    }
  },
  "live": { "tags": [{ "id": 12, "name": "Hot lead" }], "node_limit": 25, "flow_limit": 10, "active_flows": 3 }
}
```

### POST /flows/validate

Check a definition without saving anything. Runs the same checks as create and update: structure, every node's config against the catalogue, channel compatibility with the trigger source, keyword collisions with your other active flows, and references (tag ids, custom field keys, service ids, target flows, comment triggers) against this account. Always `200`; read `valid`.

Body

- definitionobjectrequired `{trigger, nodes, edges}`. See the example under create.
- channelsarrayoptional Where the flow will run: `facebook`, `instagram`, `whatsapp`. Defaults to facebook and instagram.

**Response 200**

```
{
  "valid": false,
  "errors": [
    "Orphaned node: 3 (message) has no incoming edge.",
    "Collect input node 2: input_type must be one of email, phone, name, text, number."
  ],
  "warnings": [
    "Node 2 (collect_input): port output_2 is not connected; the flow ends there."
  ]
}
```

### POST /flows

Create a flow as a `draft`. Nothing runs until you publish or activate it. Returns the flow with `201` and any `warnings`; a definition with errors is refused with `422 invalid_definition` and the same `errors` list validate returns. `403 plan_limit_reached` when the plan's flow count is full.

Body

- namestringrequired Up to 100 characters.
- definitionobjectrequired `trigger`: the trigger config (`source` plus that source's keys: `keywords` and `match_type` for `dm`, `comment_trigger_id` or `comment_keywords` for `comment`, `intent_description` for `ai_intent`...). `nodes`: `[{id, type, config}]`, ids are strings and the trigger is `"1"`. `edges`: `[{from, from_port, to}]`; `from_port` defaults to `output_1`.
- channelsarrayoptional Defaults to facebook and instagram. Must be channels the trigger source can fire on.
- tag_idintegeroptional Tag applied to everyone who enters the flow. One of `live.tags`.

**Request · keyword DM that collects an email**

```
curl -X POST https://sumgenius.ai/api/v1/flows \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Guide request",
    "channels": ["instagram"],
    "definition": {
      "trigger": { "source": "dm", "keywords": ["guide"], "match_type": "contains" },
      "nodes": [
        { "id": "1", "type": "trigger", "config": { "source": "dm", "keywords": ["guide"], "match_type": "contains" } },
        { "id": "2", "type": "collect_input", "config": { "question": "Where should I send it?", "input_type": "email", "variable_name": "email", "max_retries": 2, "retry_message": "That does not look like an email." } },
        { "id": "3", "type": "action", "config": { "action_type": "tag_conversation", "tag_value": "guide" } },
        { "id": "4", "type": "message", "config": { "text": "Sent to {email}. Check your inbox." } }
      ],
      "edges": [
        { "from": "1", "to": "2" },
        { "from": "2", "from_port": "output_1", "to": "3" },
        { "from": "3", "to": "4" }
      ]
    }
  }'
```

**Response 201 · the flow, with definition**

```
{
  "id": 184,
  "name": "Guide request",
  "status": "draft",
  "channels": ["instagram"],
  "tag_id": null,
  "version": 1,
  "published_version": null,
  "has_draft": true,
  "node_count": 4,
  "definition": { "trigger": { ... }, "nodes": [ ... ], "edges": [ ... ] },
  "warnings": ["Node 2 (collect_input): port output_2 is not connected; the flow ends there."]
}
```

### PATCH /flows/{id} · GET /flows/{id}/definition

Update `name`, `channels`, `tag_id` or `definition`. Sending a definition saves it as the flow's **draft**: the published version keeps running until you publish. A definition replaces the whole graph; send the complete one. The same validation as create applies (`422 invalid_definition`). `GET /flows/{id}/definition` returns `{flow_id, has_draft, definition}` on its own, the draft when one exists.

**Request**

```
curl -X PATCH https://sumgenius.ai/api/v1/flows/184 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Guide request (IG)", "tag_id": 12}'
```

### POST /flows/{id}/publish · activate · pause · discard-draft · duplicate · DELETE /flows/{id}

The builder's buttons. Each POST takes an empty JSON body (`{}`) and returns the flow.

- publish Makes the draft the live definition and bumps `published_version`. A draft flow goes `active` when the plan's active-flow cap allows; otherwise it stays published but paused. A comment flow linked to a comment trigger goes live with that trigger.
- activate Sets the flow live, publishing the draft first when there is one. `403 plan_limit_reached` at the active-flow cap; `422 invalid_definition` when the draft has errors or its keywords collide with another active flow; `409 flow_blocked` for anything else the builder would refuse.
- pause Stops the flow and cancels anyone mid-session. Only an active flow can be paused (`409 not_active`). Pausing a linked comment flow pauses its comment trigger too.
- discard-draft Throws away unpublished changes; the published definition stays.
- duplicate Copies the flow as a new draft named "... (Copy)". `201` with the copy.
- DELETE Deletes the flow for good: its versions go with it and anyone mid-session is dropped. A linked comment trigger is paused, not deleted. `200 {"deleted": true}`.

Publishing, activating and pausing fire [flow.published, flow.activated and flow.paused](https://sumgenius.ai/docs/webhook-events#flow-events) whether done here or in the portal.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/flows/184/activate \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" -d '{}'
```

**Response 403 · at the active-flow cap**

```
{
  "error": {
    "code": "plan_limit_reached",
    "message": "You're at your live flow limit (3). Pause another flow before publishing this one."
  }
}
```

## Flow sessions

One session per person per run of a flow, plus the ordered path they took through it. This is how an automation performed: which version ran, which nodes were reached, which branch was taken, where someone stopped, and which post or keyword brought them in.

### GET /flow-sessions

List sessions, newest first.

Query parameters

- flow_idintegeroptional
- statusstringoptional `waiting_input`, `delayed`, `completed`, `expired` or `cancelled`.
- channelstringoptional `instagram`, `facebook` or `whatsapp`.
- contact_idintegeroptional Every run by one person. An unknown contact returns `404` rather than an empty list, so a bad id cannot look like "no activity".
- updated_sinceISO-8601optional Sessions touched since this time. Use it to poll incrementally.
- limit · starting_afterpaginationoptional

> **Note**
>
> **Structure, never content.** Values a flow collected, and anything a person typed, are not returned by this endpoint. `attribution` carries only the originating post, comment and matched keyword. Those are the same fields that survive the 90-day retention sweep, so a session you fetch today and the same session a year from now have the same shape.

**Request**

```
curl "https://sumgenius.ai/api/v1/flow-sessions?flow_id=178&status=completed" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the flow session resource**

```
{
  "data": [
    {
      "id": 6075,
      "flow_id": 178,
      "flow_name": "Comment to guide",
      "flow_version": 1,
      "channel": "instagram",
      "contact_id": 21877,
      "status": "completed",
      "end_reason": null,
      "current_node_id": "9",
      "attribution": {
        "post_id": "18378452308239549",
        "comment_id": "18001122334455667",
        "matched_keyword": "GUIDE"
      },
      "started_at": "2026-08-26T15:47:23+00:00",
      "last_activity_at": "2026-08-26T15:47:31+00:00",
      "last_user_message_at": null,
      "delay_until": null,
      "window_expires_at": "2026-08-27T15:47:23+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "6074"
}
```

### GET /flow-sessions/{id}

Retrieve one session. Same resource as the list.

**Request**

```
curl https://sumgenius.ai/api/v1/flow-sessions/6075 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

### GET /flow-sessions/{id}/events

The ordered path through the flow, oldest first. Every step a person reached and every branch they took.

Query parameters

- typestringoptional One event type: `session_started`, `node_entered`, `node_exited`, `branch_selected`, `input_received`, `link_clicked`, `outcome_achieved`, `session_completed`, `session_expired`, `session_cancelled`, `session_reactivated`.
- limit · starting_afterpaginationoptional Default `100` here, and ordered ascending so pages read in sequence.

> **Note**
>
> **No message text is stored on an event.** An `input_received` event records that input arrived and what kind it was, never what was said. That is why this data can outlive the conversation it came from.

**Request**

```
curl "https://sumgenius.ai/api/v1/flow-sessions/6075/events" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the event resource**

```
{
  "data": [
    {
      "id": 7813,
      "session_id": 6075,
      "flow_id": 178,
      "flow_version": 1,
      "type": "branch_selected",
      "node_key": "1",
      "node_type": "trigger",
      "output_port": "output_1",
      "input_type": "system",
      "end_reason": null,
      "occurred_at": "2026-08-26T15:47:23+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### GET /flow-events

The same events, flat across every session. Use this one to analyse how a flow performs; use the nested route above when you want one person's path.

Query parameters

- flow_idintegeroptional
- flow_sessionintegeroptional One session's events, in the flat shape. Named `flow_session` rather than `session_id` because web application firewalls commonly block that parameter name outright. The field in the response is still `session_id`.
- typestringoptional Same event types as the nested route.
- node_keystringoptional One step of the flow. Combine with `type=branch_selected` to see every choice made at a single decision point.
- sinceISO-8601optional
- limit · starting_afterpaginationoptional

> **Note**
>
> **Built for incremental syncing.** Events are ordered by id ascending and ids only ever increase, so you can store the last `next_cursor` and keep paging forward without ever re-reading a row. Pulling a whole flow's history is one loop, not one call per session.

**Request**

```
curl "https://sumgenius.ai/api/v1/flow-events?flow_id=178&type=branch_selected" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · same event resource**

```
{
  "data": [
    {
      "id": 7813,
      "session_id": 6075,
      "flow_id": 178,
      "flow_version": 1,
      "type": "branch_selected",
      "node_key": "4",
      "node_type": "question",
      "output_port": "output_2",
      "input_type": "button",
      "end_reason": null,
      "occurred_at": "2026-08-26T15:47:23+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "7813"
}
```

## Analytics

Read-only numbers, computed by the same code the portal uses. Two windows everywhere: the current one and the one before it, so a change is always visible without a second call. No message content.

### GET /flows/{id}/analytics

The builder's Analytics panel for one flow. Analytics are per published version: `version` defaults to the current one and `versions[]` lists the others, with `has_tracked_runs` so you know which are worth asking for. Only runs with journey tracking count; `other_version_runs_excluded` says how many runs in the window belong to other versions, and `other_versions[]` summarises them.

Query parameters

- rangestringoptional `7d`, `30d` (default), `90d` or `all`. `comparison` is null for `all`.
- channelstringoptional `facebook`, `instagram` or `whatsapp`.
- versionintegeroptional A published version from `versions[]`. `404` for a version the flow never had.

What comes back

- summary · comparison · lifetime Runs, completed, expired, cancelled, still active, completion rate and median seconds to complete for the window; the same for the previous window as deltas; and the flow's all-time counters across every version.
- outcomes What the runs produced: emails and phones collected, leads marked, appointments booked, links clicked, handoffs, custom fields saved. Each with a count, a rate against runs, and a median time to reach it.
- per_node · branches · nodes For every node of that version: how many runs reached it, continued past it, answered it, ended there and why (`terminal_reasons`), plus link clicks per button. `branches` gives the split per output port in the same order. `nodes` maps ids to types; the full definition is on `GET /flows/{id}`.

**Request**

```
curl "https://sumgenius.ai/api/v1/flows/178/analytics?range=30d" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · excerpt**

```
{
  "flow_id": 178, "version": 4, "current_version": 4, "range": "30d", "channel": "all",
  "summary": { "runs": 312, "completed": 188, "expired": 97, "cancelled": 6, "active": 21, "completion_rate": 64.6, "median_completed_seconds": 41 },
  "comparison": { "previous_runs": 240, "runs_change_percent": 30, "previous_completion_rate": 58.1, "completion_rate_change_points": 6.5 },
  "lifetime": { "runs": 1904, "completed": 1211, "not_completed": 693, "completion_rate": 63.6 },
  "outcomes": [ { "key": "email_collected", "label": "Emails collected", "count": 171, "rate": 54.8, "median_seconds": 28 } ],
  "per_node": [
    { "node_id": "2", "node_type": "message", "reached": 312, "continued": 240, "continued_rate": 76.9, "responses": 240, "loss_exits": 72, "terminal_reasons": { "response_timeout": 72 }, "buttons": { "postback_1": { "clicks": 201, "click_rate": 64.4 } } }
  ],
  "branches": [ { "output_1": { "selected": 201, "selection_rate": 64.4 }, "output_2": { "selected": 39, "selection_rate": 12.5 } } ]
}
```

### GET /analytics/overview

The numbers behind the portal's Overview page, from the same data layer: a rolling 30-day window against the 30 days before it. No parameters; the window is fixed like the page, and every count is a `{current, previous}` pair.

- messages Inbound, outbound and conversations touched, in total and `by_channel`.
- replies Outbound bot messages split into `ai` (written by the model) and `automation` (flows, comment triggers, welcomes).
- flows · comment_triggers Entries per flow and fires per standalone trigger, each with `all_time`. A comment trigger attached to a flow is the top of that flow's funnel, so its fires ride on the flow row as `comment_trigger_fires` and never appear as their own row. One comment is never counted twice.
- contacts · appointments · knowledge_gaps · needs_you · api_sends New contacts and totals with phone or email; appointments created, upcoming, and the next few; knowledge gaps by status; escalated conversations and Copilot drafts waiting; Send API requests this window.
- usage This billing cycle: conversations used against the plan cap, overage state, SMS used, and whether the account is on a trial. Account-level, never channel-scoped. `null` if the plan lookup fails, the way the page degrades.

**Response 200 · excerpt**

```
{
  "window": { "days": 30, "current_from": "2026-08-07T21:34:48+00:00", "previous_from": "2026-07-08T21:34:48+00:00" },
  "messages": {
    "inbound": { "current": 1412, "previous": 1180 }, "outbound": { "current": 1890, "previous": 1544 }, "conversations": { "current": 402, "previous": 351 },
    "by_channel": { "instagram": { ... }, "facebook": { ... }, "sms": { ... } }
  },
  "replies": { "ai": { "current": 1105, "previous": 902 }, "automation": { "current": 785, "previous": 642 } },
  "flows": [ { "id": 178, "name": "Comment to guide", "entries": { "current": 312, "previous": 240, "all_time": 1904 }, "comment_trigger_id": 90077, "comment_trigger_fires": { "current": 640, "previous": 515 } } ],
  "contacts": { "new": { "current": 298, "previous": 233 }, "total": 4120, "with_phone": 1877, "with_email": 2410 },
  "appointments": { "upcoming": 7, "created": { "current": 18, "previous": 14, "all_time": 69 }, "next": [ { "id": 92149, "channel": "instagram", "customer_name": "Maya R.", "scheduled_at": "2026-09-17T16:00:00+00:00", "service_type": "consultation", "status": "confirmed" } ] },
  "knowledge_gaps": { "open": 3, "resolved": 12 },
  "needs_you": { "escalated": 2, "copilot_drafts": 0 },
  "api_sends": { "requested": 10, "sent": 10 },
  "usage": { "conversations": { "used": 402, "limit": 1000, "percent": 40.2 }, "overage": { "enabled": false, "eligible": true, "units": 0 }, "sms": { "used": 0, "limit": 0 }, "billing_cycle": { "start": "2026-08-21", "end": "2026-09-20" }, "is_trial": false }
}
```

## Opt-outs

Who has opted out of messaging, on which channel, when, and how. Your suppression list, in a form you can export and keep.

### GET /opt-outs

List opt-outs, most recent first, across every channel.

Query parameters

- channelstringoptional `instagram`, `facebook`, `whatsapp`, `telegram` or `sms`.
- statusstringoptional `opted_out` or `opted_in`. Only SMS can be opted back in, so `status=opted_in` returns SMS records only.
- scopestringoptional `comment_dm`, `broadcast` or `all`. Meta channels only, so passing it excludes SMS.
- sinceISO-8601optional Filters on `recorded_at`, the same field the list is sorted by, so someone who opts back in today shows up in today's sweep even if their original opt-out was long ago. Use it to sync consent changes incrementally.
- limit · starting_afterpaginationoptional

> **Note**
>
> **`id` is a string here**, prefixed by source (`"meta:13"`, `"sms:32"`), because Meta and SMS opt-outs are recorded separately and their numbering is independent. Treat `next_cursor` as opaque on this endpoint. SMS records return `method` and `keyword` as `null`: a carrier-level stop reaches us without the wording the person used, and we would rather say nothing than guess.

**Request**

```
curl "https://sumgenius.ai/api/v1/opt-outs?channel=instagram" \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200 · the opt-out resource**

```
{
  "data": [
    {
      "id": "meta:13",
      "channel": "instagram",
      "contact_id": 20668,
      "identifier": "17841400000000000",
      "status": "opted_out",
      "scope": "all",
      "method": "dm_reply",
      "keyword": "STOP",
      "opted_out_at": "2026-07-26T21:04:07+00:00",
      "opted_in_at": null,
      "recorded_at": "2026-07-26T21:04:07+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

### GET /team/invitations · POST /team/invitations · POST /team/invitations/{id}/resend · DELETE /team/invitations/{id}

An invitation is how someone joins: they get an email with a setup link, choose a password, and appear in `GET /team`. Pending invitations hold a seat against the plan cap until accepted, expired (7 days) or cancelled.

Create body

- emailstringrequired
- namestringrequired Used in the email greeting only. They set their own name when they accept.
- rolestringoptional `agent` (default) answers conversations. `manager` also gets the dashboard, leads, AI settings and content. Billing, team, integrations and keys stay owner-only.
- channelsarray or nulloptional Restrict them to some of `facebook`, `instagram`, `whatsapp`, `sms`, `telegram`. Omit for every channel.

`403 plan_limit_reached` when every seat is taken. `409 already_member` or `409 invitation_pending` for a known address. The setup link is never returned, except as `invite_link` when `email_sent` is false, so you can deliver it yourself.

**Resend** issues a fresh link (the old one stops working), resets the expiry and emails the reminder. **Cancel** removes the invitation and frees the seat.

**Request**

```
curl -X POST https://sumgenius.ai/api/v1/team/invitations \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"email": "sam@example.com", "name": "Sam", "role": "agent", "channels": ["instagram", "facebook"]}'
```

**Response 201**

```
{
  "id": 41,
  "email": "sam@example.com",
  "role": "agent",
  "channels": ["facebook", "instagram"],
  "status": "pending",
  "expires_at": "2026-09-14T21:10:03+00:00",
  "created_at": "2026-09-07T21:10:03+00:00",
  "email_sent": true
}
```

### PATCH /team/{id} · DELETE /team/{id}

**PATCH** changes `role` and/or `channels` (`null` for every channel). It takes effect at their next login, the same as the portal. Returns the member. **DELETE** removes them: their login stops at once and their conversations become unassigned. `conversations_unassigned` says how many. No event fires for that.

**Request**

```
curl -X PATCH https://sumgenius.ai/api/v1/team/90011 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"role": "manager", "channels": null}'
```

**Response 200 · DELETE**

```
{ "id": 90011, "object": "team_member", "deleted": true, "conversations_unassigned": 2 }
```

## Team

The people who log in to your portal and can be assigned conversations. Reading is `read` scope. Inviting, changing roles or access, and removing need the `admin` scope, and do exactly what the portal's team page does for the account owner.

### GET /team

Your team members, ordered by name, in one page. A member's `id` is what `assigned_to` references and what [assign](#assign-conversation) accepts. Credentials and permission data are never exposed.

- roleenum `agent` · `manager`
- statusenum `online` · `away` · `busy` · `offline`
- channelsarray or null The channels this member can see. `null` means every channel.

**Request**

```
curl https://sumgenius.ai/api/v1/team \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response 200**

```
{
  "data": [
    {
      "id": 90011,
      "name": "Sam Ortiz",
      "email": "sam@example.com",
      "role": "manager",
      "status": "online",
      "avatar_url": null,
      "active_conversations": 1,
      "total_resolved": 0,
      "channels": null,
      "created_at": "2026-07-22T06:16:06+00:00"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

## AI configuration

Every settings tab of the AI Configuration screen, as sections of one resource. Read all of it in one call, change any subset. The Claude setup helper, the magic wand, and the test bot are portal tools and are not part of the API.

### GET /ai/config

Nine sections. `available` on `file_handling`, `followups`, `csat` and `comment_ai` tells you whether the plan includes that feature. Needs the `read` scope.

- identity `name`, `tone` (casual, professional, formal), `instructions` (up to 15,000 characters), `instructions_source` (auto or manual, read-only), `welcome_message` (up to 500), `welcome_mode` (combined or standalone), `website_url`, `business_contact_email`, `business_contact_phone`.
- behavior `goals` (any of answer_questions, book_appointments, capture_leads, qualify_prospects), `batch_messages`, `batch_window_ms` (minimum 5000), `auto_escalation`, `copilot_enabled`, `business_hours` (seven days, read-only for now).
- restrictions `off_limits`: legal, medical, competitors, pricing, personal, offtopic, each true or false. `solicitation_pause`.
- starters `instagram.enabled` and `instagram.questions` (up to 4, 80 characters each), `facebook.enabled`, `facebook.mode` (ice_breakers or get_started), `facebook.questions`. `published` carries when each platform was last published and any error, read-only.
- file_handling `enabled` (one switch for images, documents and videos), `auto_escalate_on_image`, `auto_escalate_on_document`, `auto_escalate_on_video`, `escalate_if_team_online`. Enabling it forces batching on.
- followups `enabled`, `first_delay_minutes` (5 to 60), `second_delay_hours` (1 to 23), `quiet_hours.enabled`, `quiet_hours.start`, `quiet_hours.end` (HH:MM).
- csat `enabled`, `template` (simple, friendly, professional, custom), `custom_message` (up to 500).
- comment_ai The AI Comment Manager, which handles comments no trigger claimed. `enabled`, `mode` (`auto` posts replies at once, `approval` queues them under [/comments](#comments)), `instructions` (up to 5000), `reply_length` (short, medium, detailed), `moderation` (spam, profanity, negativity, competitor_links, self_promotion, each true to hide), `dm_on_hide` and `dm_message` ({name} is the commenter), `exclude_rules` (a list of `{type: post_id | keyword, value}`, replaced whole). Pro plans and above.
- booking The Booking Configuration modal. `enabled` (AI Booking; off means the AI declines booking requests, the API and the portal can still book), `google_meet_invites` (needs Google Calendar connected, forces email to required), `required_fields` (name, email, phone, address, vehicle, cost, each true or false, plus `address_parts` {city, state, street}). Read-only alongside: `timing` (buffer, minimum notice, max advance, reminder hours, timezone), `calendar` (which provider is connected), and `bookable_services` (the service menu rows with a duration, managed under [/knowledge/services](#knowledge-services)).

**Request**

```
curl https://sumgenius.ai/api/v1/ai/config \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here"
```

**Response (abridged)**

```
{
  "data": {
    "identity": {
      "name": "Aria",
      "tone": "casual",
      "instructions": "Aria persona and core guidance. ...",
      "instructions_source": "auto",
      "welcome_message": null,
      "welcome_mode": "combined",
      "website_url": "https://sumgenius.ai",
      "business_contact_email": "info@sumgenius.ai",
      "business_contact_phone": "833-365-7318"
    },
    "behavior": {
      "goals": ["answer_questions", "book_appointments"],
      "batch_messages": false,
      "batch_window_ms": 7000,
      "auto_escalation": true,
      "copilot_enabled": false,
      "business_hours": [ { "day": "sunday", "closed": true, "open": null, "close": null }, ... ]
    },
    "restrictions": { "off_limits": { "legal": true, ... }, "solicitation_pause": true },
    "starters": { ... },
    "file_handling": { "available": true, "enabled": false, ... },
    "followups": { "available": true, "enabled": false, ... },
    "csat": { "available": true, "enabled": false, ... },
    "comment_ai": { "available": true, "enabled": false, "mode": "approval", ... },
    "booking": { "enabled": true, "google_meet_invites": false, "required_fields": { "name": false, "email": false, ... }, "bookable_services": [ ... ], ... }
  }
}
```

### PATCH /ai/config

Send any subset of sections, each with any subset of its fields. Everything you leave out keeps its current value. Returns the full configuration after the change. Needs the `admin` scope.

- Validation Same rules as the portal. Out-of-range values are clamped where the portal clamps them (welcome message, batch window, name) and rejected where the portal rejects them (instructions over 15,000 characters, follow-up delays, starters without questions, an unknown CSAT template). An unknown section or field is `400 invalid_param` with `param` naming it.
- Plan gates `file_handling`, `followups`, `csat` and `comment_ai` return `403 feature_unavailable` on a plan that does not include them. Check `available` on the GET first.
- Starters publish on save Changing `starters` publishes to Instagram and Facebook at once, exactly like the portal. A publish failure still saves locally and is reported in `starters.published.*_error`.
- What this never touches The AI on/off switch per channel. Saving configuration leaves those alone; see channel switches.

**Request**

```
curl -X PATCH https://sumgenius.ai/api/v1/ai/config \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "identity": { "tone": "professional", "name": "Aria" },
    "behavior": { "goals": ["answer_questions", "capture_leads"] },
    "restrictions": { "off_limits": { "pricing": false } }
  }'
```

**403 feature_unavailable**

```
{
  "error": {
    "code": "feature_unavailable",
    "message": "AI Follow-ups are not available on this plan.",
    "param": "followups"
  }
}
```

### GET /ai/channels

The on/off switches per channel. Four channels: `meta` (Facebook and Instagram share one switch), `sms`, `whatsapp`, `telegram`. For each: `connected`, whether the channel is set up on the account; `ai_enabled`, whether the AI answers customers on it; `automation_enabled`, whether flows and triggers run on it. Needs the `read` scope.

**Response**

```
{
  "data": {
    "meta": { "label": "Facebook + Instagram", "connected": true, "accounts": { "facebook_page_id": null, "instagram_id": "17841400000000000" }, "ai_enabled": true, "automation_enabled": true },
    "sms": { "label": "SMS", "connected": false, "ai_enabled": false, "automation_enabled": false },
    "whatsapp": { ... },
    "telegram": { ... }
  }
}
```

### PATCH /ai/channels/{channel}

Switch the AI or automations on one channel. Send `ai_enabled` and/or `automation_enabled` as booleans. Returns that channel's state. Needs the `admin` scope. `facebook` and `instagram` are accepted as aliases of `meta`.

- Off always works Turning a switch off never fails. This is the call to make when the bot must stop answering right now.
- SMS AI on has two gates `409 sms_not_ready` until carrier registration assigns a Messaging Service to your number. `409 purge_active` while inbound body purge is on in your webhook settings, because the purge clears the SMS context the AI needs. Turn the purge off first.
- What this never touches Any other setting. The AI configuration, external reply mode and the purge flag are separate.

**Request**

```
curl -X PATCH https://sumgenius.ai/api/v1/ai/channels/meta \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"ai_enabled": false}'
```

**Response**

```
{
  "data": {
    "channel": "meta",
    "label": "Facebook + Instagram",
    "connected": true,
    "accounts": { "facebook_page_id": null, "instagram_id": "17841400000000000" },
    "ai_enabled": false,
    "automation_enabled": true
  }
}
```

## Knowledge base

What the AI knows: FAQs, services with pricing, and uploaded documents. One plan cap covers all three. Every write here is what the Knowledge tab does, including re-indexing for retrieval, so a change is live in the bot's answers within moments.

### GET /knowledge

Items against the plan cap, broken down by FAQs, services and documents, plus document storage. Check `items.can_add` before creating. Needs the `read` scope.

**Response**

```
{
  "data": {
    "items": { "current": 25, "limit": 100, "remaining": 75, "can_add": true, "breakdown": { "faqs": 13, "services": 12, "documents": 0 } },
    "document_storage": { "available": true, "current_mb": 0, "limit_mb": 50 }
  }
}
```

### /knowledge/faqs

`GET` lists (active only unless `include_inactive=true`), `POST` creates, `GET /{id}`, `PATCH /{id}` and `DELETE /{id}` do what they say. Writes need the `write` scope.

- questionstring, up to 1,000required on create What a customer would ask.
- answerstring, up to 10,000required on create What the AI should say. Written in the bot's voice by the model, so plain facts work best.
- keywordsstringoptional Comma-separated hints that help matching. Empty string clears.
- activebooleanoptional Switch the FAQ off without deleting it. Changing only `active` does not re-index; changing content does.

Create returns `403 plan_limit_reached` at the cap. Responses to create and update carry `embedding_pending`, true when indexing has not finished yet.

**Create**

```
curl -X POST https://sumgenius.ai/api/v1/knowledge/faqs \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"question": "Do you offer gift cards?", "answer": "Yes, in any amount from $25, at the front desk or online."}'
```

**Response**

```
{
  "data": {
    "id": 412,
    "question": "Do you offer gift cards?",
    "answer": "Yes, in any amount from $25, at the front desk or online.",
    "keywords": null,
    "category_id": null,
    "active": true,
    "usage_count": 0,
    "has_embedding": true,
    "source": "manual",
    "created_at": "2026-09-05T04:10:00+00:00",
    "embedding_pending": false
  }
}
```

### /knowledge/services

Same five operations as FAQs. A service is something the AI can quote and, with booking on, offer.

- namestring, up to 200required on create
- pricenumber, zero or morerequired on create Returned as a two-decimal string. `price_unit` is `fixed`, `hourly`, `per_unit` or `quote`. `currency` is an ISO 4217 code, or null for the account default.
- description · duration_minutes · display_order · category_id · activeoptional
- is_virtualbooleanoptional Bookings for this service get a Google Meet link. Honoured only when Google Calendar is connected and Meet is switched on; otherwise stored as false.
- image_urlread-only Service images are added in the portal. The API never changes or removes one.

**Create**

```
curl -X POST https://sumgenius.ai/api/v1/knowledge/services \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Deep tissue massage", "price": 120, "currency": "USD", "duration_minutes": 60, "description": "Firm pressure, targeted at knots and chronic tension."}'
```

**Switch one off**

```
curl -X PATCH https://sumgenius.ai/api/v1/knowledge/services/88 \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"active": false}'
```

### /knowledge/documents

Uploaded PDFs and text files. `GET` lists and `GET /{id}` reads metadata: name, type, size, embedding state. Content is never returned. `PATCH /{id}` takes `name` and `active`. `DELETE /{id}` removes the document and its embeddings.

There is no create. Uploading a document means parsing a file against a storage cap, which stays in the portal.

**Document**

```
{
  "id": 31,
  "name": "Spring menu.pdf",
  "type": "pdf",
  "url": null,
  "active": true,
  "size_bytes": 48213,
  "embedding_status": "completed",
  "embedding_count": 14,
  "last_embedded_at": "2026-08-30T17:02:11+00:00"
}
```

### GET /knowledge-gaps

Questions the AI could not answer well. Each gap carries the customer's message, the AI's reply, a generic form of the question so repeats group together, how often it came up, and where the classifier thinks the fix belongs: a new FAQ or a line in the instructions. Open gaps by default. Needs the `read` scope.

Read-only on purpose. Fix a gap with [POST /knowledge/faqs](#knowledge-faqs) or by updating `identity.instructions` in the [AI configuration](#update-ai-config). Dismissing stays in the portal.

Query parameters

- statusstringoptional `open` (default), `pending`, `resolved`, `dismissed`, or `all`.
- signal_typestringoptional `no_knowledge`, `weak_match`, `negative_feedback` or `escalation`.
- channel · since · limit · starting_afteroptional `since` filters on `last_seen_at`.

**Gap**

```
{
  "id": 832,
  "status": "open",
  "signal_type": "no_knowledge",
  "channel": "instagram",
  "conversation_id": 236067,
  "user_message": "can you confirm my 4:15 demo tomorrow?",
  "ai_response": "I can't see bookings from here, but our team can confirm shortly.",
  "canonical_question": "Can you confirm my booking?",
  "top_similarity": 0.41,
  "occurrence_count": 3,
  "suggested_answer": "If you cannot confirm a booking from the chat, tell the customer a person will confirm by message within the hour.",
  "suggested_target": "instructions",
  "last_seen_at": "2026-08-05T05:27:46+00:00"
}
```

## Media library

Images and short videos the AI can send in a reply. Creator plans and above; other plans get `403 feature_unavailable`.

### /media

`GET /media` lists every item with its public URL. `POST /media` uploads one as `multipart/form-data`: the file in a field named `file`, plus `name` (required), `description` and `category`. `PATCH /media/{id}` changes name, description or category. `DELETE /media/{id}` removes the item and its file. Writes need the `write` scope.

- Limits JPEG, PNG, MP4 or MOV. 8 MB for images, 25 MB for videos. 20 items per library; the 21st is `403 plan_limit_reached`.
- categorystring `transformation`, `testimonial`, `before_after`, `product`, `pricing`, `general`. The AI uses the category and description to pick the right item for a reply.

**Upload**

```
curl -X POST https://sumgenius.ai/api/v1/media \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -F "file=@before-after.jpg" \
  -F "name=Kitchen before and after" \
  -F "category=before_after"
```

**Media item**

```
{
  "id": 17,
  "name": "Kitchen before and after",
  "description": null,
  "category": "before_after",
  "url": "https://sumgenius.ai/uploads/media-library/50/a1b2c3.jpg",
  "mime_type": "image/jpeg",
  "kind": "image",
  "size_bytes": 184220,
  "width": 1200,
  "height": 900,
  "usage_count": 0,
  "created_at": "2026-09-05T05:00:00+00:00"
}
```

## Blocked users

People the AI never answers. Their messages still arrive and are stored; the bot just stays silent.

### /blocked-users

`GET /blocked-users` lists active blocks. `POST /blocked-users` blocks someone: send exactly one of `ig_username` (an Instagram handle, with or without the @, works before they have ever messaged you) or `conversation_id` (the person behind an existing conversation on Facebook or Instagram). `DELETE /blocked-users/{id}` unblocks; the AI answers their next message. Writes need the `write` scope.

Blocking someone already on the list is `409 already_blocked`. The block row is kept after an unblock for audit, so ids are never reused.

**Block by handle**

```
curl -X POST https://sumgenius.ai/api/v1/blocked-users \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"ig_username": "@spam.account"}'
```

**Blocked user**

```
{
  "id": 41,
  "channel": "instagram",
  "platform_user_id": null,
  "ig_username": "spam.account",
  "display_name": null,
  "conversation_id": null,
  "source": "manual",
  "blocked_at": "2026-09-05T05:00:00+00:00"
}
```

### /comment-triggers

A comment trigger watches comments on your posts, stories or Instagram Lives for keywords. When one matches it can post a public reply, send a DM with up to three buttons, hide the comment, tag the person, and start a flow. Standard CRUD: `GET /comment-triggers` (filters `platform`, `source_type`, `active`, `post_id`), `POST`, `GET /comment-triggers/{id}`, `PATCH` (partial), `DELETE`, plus `POST /comment-triggers/{id}/duplicate` (send `{}` as JSON; a bodiless POST is refused) and `GET /comment-triggers/{id}/stats`. Writes need the `write` scope.

Fields

- keywords · match_type · exclude_keywordsarray · string · array Any keyword matches. `match_type` is `contains` (default, anywhere in the text), `exact` (whole word) or `fuzzy` (tolerates a typo). An excluded word anywhere in the comment stops the trigger.
- platform · source_type · post_idstring · string · string `platform` is `instagram`, `facebook` or `both`. `source_type` is `comment` (posts and Reels), `story` (story replies), `both`, or `live`; story and live are Instagram only. A `post_id` from [GET /recent-posts](#list-posts) scopes the trigger to one post; null means every post.
- response_mode · public_replies · dm_templatesstring · array · array `both`, `public_only` or `dm_only`. Up to five variations each; one is picked per fire. Set `dm_use_ai` with a `dm_ai_goal` to have the AI write the DM from your knowledge base instead of templates.
- buttonsarray, 1 to 3 Required for any DM mode: Meta needs a button on a comment-triggered DM. Each is `{title, type, response | url}`; titles are 20 characters max, `text` buttons send `response` when tapped, `url` buttons open `url`.
- activeboolean, PATCH only `true` activates, `false` pauses (and pauses linked flows). Plans cap how many triggers are live at once; creating past the cap saves the trigger paused with `created_paused: true`, and activating past it is `403 plan_limit_reached`. Activating a trigger whose linked flow is paused, broken, or one of several is `409 flow_conflict`.
- linked_flowsread-only Flows that start from this trigger, with `entry` `immediate` (starts when the trigger fires) or `button` (starts when the person taps a trigger button). A flow can also match comment keywords of its own with no trigger; that shows on [GET /flows](#list-flows) as a source with `mode: native` and is not a comment trigger.

> **Warning**
>
> **Changing buttons on a linked trigger pauses things.** If you change a button's title or type on a trigger that has linked flows, those flows and the trigger are paused so the flow branches can be reviewed; the response carries `flow_impact`. Other edits re-sync the flows silently. Deleting a trigger detaches and pauses its flows.

The AI Comment Manager, which answers public comments with your knowledge base when no trigger fires, is a separate feature and is not a trigger.

**Create**

```
curl -X POST https://sumgenius.ai/api/v1/comment-triggers \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Guide",
    "platform": "instagram",
    "keywords": ["guide", "link"],
    "response_mode": "both",
    "public_replies": ["Sent you a DM {name}!"],
    "dm_templates": ["Here is the guide."],
    "buttons": [{"title": "Open guide", "type": "url", "url": "https://example.com/guide"}]
  }'
```

**Comment trigger**

```
{
  "id": 212,
  "name": "Guide",
  "active": true,
  "platform": "instagram",
  "source_type": "comment",
  "post_id": null,
  "keywords": ["guide", "link"],
  "match_type": "contains",
  "exclude_keywords": [],
  "response_mode": "both",
  "public_replies": ["Sent you a DM {name}!"],
  "dm_templates": ["Here is the guide."],
  "dm_use_ai": false,
  "buttons": [{ "title": "Open guide", "type": "url", "url": "https://example.com/guide" }],
  "hide_comment_after_dm": false,
  "tag_id": null,
  "linked_flows": [],
  "stats": { "fires": 0, "dms_sent": 0, "public_replies": 0, "last_fired_at": null },
  "created_paused": false,
  "created_at": "2026-09-05T18:00:00+00:00"
}
```

### GET /comment-triggers/simulate

A dry run. Pass `text`, `platform` and optionally `post_id` and `source_type` as query parameters, and get back what would happen: the trigger that fires and what it would send, the linked flow that would start, or the flow with native keywords that would answer if no trigger fires. Every trigger on the account is listed under `candidates` with the reason it did not fire (`inactive`, `platform`, `source_type`, `post_scope`, `excluded_keyword`, `no_keyword_match`, `earlier_trigger_wins`). It runs the same matcher as live traffic, sends nothing and records nothing. Read scope.

`outcome` is `trigger_fired`, `native_flow`, `no_match`, or `automation_off` when the account's Meta automation switch is off. Per-person rules (opt-out, blocked, once per user) are not simulated.

**Request**

```
curl -G https://sumgenius.ai/api/v1/comment-triggers/simulate \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  --data-urlencode "platform=instagram" \
  --data-urlencode "text=Can I get the guide?"
```

**Response 200**

```
{
  "outcome": "trigger_fired",
  "automation_enabled": true,
  "trigger": {
    "id": 212, "name": "Guide", "matched_keyword": "guide",
    "actions": ["public_reply", "dm"],
    "public_replies": ["Sent you a DM {name}!"],
    "dm": { "type": "template", "templates": ["Here is the guide."] },
    "buttons": [{ "title": "Open guide", "type": "url", "url": "https://example.com/guide" }]
  },
  "linked_flow": null,
  "native_flow": null,
  "candidates": [
    { "id": 198, "name": "Pricing", "would_fire": false, "reason": "no_keyword_match" },
    { "id": 212, "name": "Guide", "would_fire": true, "reason": null, "matched_keyword": "guide" }
  ]
}
```

### GET /recent-posts

Recent posts and Reels already on the connected Instagram or Facebook account, newest first, fetched live from Meta (not Content Studio drafts; those are [/posts](#posts)). Use a post's `id` as `post_id` to scope a trigger to it. `platform` is `instagram` (default) or `facebook`; `limit` up to 50; `starting_after` takes the returned `next_cursor`. `409 not_connected` when that channel is not connected, `503 service_unavailable` when Meta does not answer.

**Post**

```
{
  "id": "17912345678901234",
  "channel": "instagram",
  "caption": "New guide is out. Comment GUIDE.",
  "media_type": "IMAGE",
  "permalink": "https://www.instagram.com/p/example/",
  "thumbnail_url": "https://scontent.cdninstagram.com/...",
  "posted_at": "2026-09-04T22:15:00+00:00"
}
```

### /comments

The AI Comment Manager handles post and Reel comments that no [comment trigger](#comment-triggers) claimed. For each one it checks your exclusion rules, hides the comment if moderation flags it (spam, profanity, negativity, competitor links, self-promotion, whichever you turned on, with an optional DM to the person), writes a reply from your knowledge base, and either posts it (auto mode) or queues it for a person to approve (approval mode). This resource is that log and that queue. Settings are the `comment_ai` section of [/ai/config](#get-ai-config). Pro plans and above; below that every route is `403 feature_unavailable`.

`GET /comments` lists handled comments, newest first, kept 30 days. Filters: `status`, `channel`, `post_id`, `since`. `status=pending` is the approval queue. `GET /comments/stats` gives the queue depth and today's and this month's counts against the plan's caps.

The approval queue

- POST /comments/{id}/approvewrite Posts the pending reply publicly under the comment, as your account. Send `{"text": "..."}` to post your own wording instead of the AI draft; the draft is kept and `reply.edited` becomes true. Send `{}` to post the draft as is. Only a pending comment can be approved (`409 not_pending`). If Meta refuses the reply the row becomes `failed` and you get `422 send_failed`.
- POST /comments/{id}/rejectwrite Drops the pending draft. Nothing is posted. Body `{}`.
- POST /comments/approve · /comments/rejectwrite, bulk Body `{"ids": [...]}`, up to 100. Approve returns `approved`, `failed` and one error line per failed id; reject returns how many changed. Ids that are not pending are skipped, never an error.

status

`pending` a draft is waiting for approval · `sent` the reply was posted · `moderated` the comment was hidden · `rejected` a person dropped the draft · `failed` Meta refused the reply, or the AI could not draft one · `skipped` the plan's daily or monthly cap was reached.

Every state change also fires the [comment.handled](https://sumgenius.ai/docs/webhook-events#comment-handled) webhook, so a queue can be worked from Slack or a help desk without polling.

**Approve with an edit**

```
curl -X POST https://sumgenius.ai/api/v1/comments/57/approve \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"text": "Thanks! Prices are in the link in our bio."}'
```

**Handled comment**

```
{
  "id": 57,
  "status": "sent",
  "channel": "instagram",
  "post_id": "17912345678901234",
  "comment": { "id": "17987654321098765", "text": "How much is this?", "commenter_id": "17841400123456789", "commenter_username": "janedoe" },
  "sentiment": { "label": "neutral", "score": 0.1 },
  "moderation": { "action": "none", "flags": [], "dm_status": "none", "dm_text": null },
  "reply": {
    "draft": "Hi! Pricing is in the link in our bio, or DM us for details.",
    "sent_text": "Thanks! Prices are in the link in our bio.",
    "edited": true,
    "sent_at": "2026-09-05T19:02:11+00:00",
    "meta_reply_id": "17999999999999999"
  },
  "reviewed_by": "API key 6",
  "reviewed_at": "2026-09-05T19:02:11+00:00",
  "created_at": "2026-09-05T18:40:00+00:00"
}
```

### /posts

Content Studio: draft, schedule and publish posts to Instagram, Facebook and TikTok, with the same publisher the portal uses. The flow is: read [capabilities](#post-capabilities), create the post, upload its media, then publish now or schedule. Content Studio plans only; below that every route is `403 feature_unavailable`. Writes need the `write` scope.

Fields

- platform · post_typestring · string `platform` is a target set: `instagram`, `facebook`, `tiktok`, `both` (Instagram and Facebook), `instagram_tiktok`, `facebook_tiktok`, `all`. The response expands it as `targets`. `post_type` is `feed_post`, `carousel`, `reels` or `story`. TikTok takes feed posts and Reels only.
- caption · hashtags · scheduled_at Caption up to 2,200 characters; hashtags without the #. `scheduled_at` is ISO-8601; set it on create or PATCH to schedule, send null on PATCH to go back to draft. Scheduling needs a caption (stories excepted) and a future time.
- advancedfirst_comment · collaborators · comment_auto_reply Sent as top-level fields, returned under `advanced`. `first_comment` is posted under the post right after publish. `collaborators` are up to three Instagram usernames invited to co-own the post. `comment_auto_reply` `{keywords, match_type, response_mode, public_replies, dm_templates, buttons}` becomes a [comment trigger](#comment-triggers) scoped to this post when it publishes; any DM mode needs at least two buttons. None of the three apply to a TikTok-only post.
- tiktokobject The TikTok Settings block: `privacy_level` (must be one of the creator's options from capabilities), `title` (up to 150, defaults to the caption), `allow_comments`, `allow_duet`, `allow_stitch`, and the content disclosure: `content_disclosure` with `promote_self` and/or `promote_third_party`. Ignored unless TikTok is a target; null in the response otherwise.
- statusread-only `draft` · `scheduled` (the scheduler publishes it within a minute of `scheduled_at`) · `publishing` · `published` (see `published_ids`) · `failed` (see `error_message`, then retry). Only draft, scheduled and failed posts can be edited; published posts cannot be deleted.

> **Note**
>
> **The plan cap counts scheduled and published posts this month.** A plain draft never holds a slot. A comment auto-reply on a scheduled post also needs a free live comment-trigger slot. Both are `403 plan_limit_reached`.

**Create a scheduled Reel for Instagram and TikTok**

```
curl -X POST https://sumgenius.ai/api/v1/posts \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "instagram_tiktok",
    "post_type": "reels",
    "caption": "Behind the scenes of the new setup.",
    "hashtags": ["studio", "setup"],
    "scheduled_at": "2026-09-12T16:00:00Z",
    "first_comment": "Comment SETUP for the checklist.",
    "tiktok": {"privacy_level": "PUBLIC_TO_EVERYONE", "allow_comments": true}
  }'
```

**Post**

```
{
  "id": 90050,
  "status": "scheduled",
  "platform": "instagram_tiktok",
  "targets": ["instagram", "tiktok"],
  "post_type": "reels",
  "caption": "Behind the scenes of the new setup.",
  "hashtags": ["studio", "setup"],
  "scheduled_at": "2026-09-12T16:00:00+00:00",
  "published_at": null,
  "media": [],
  "advanced": { "first_comment": "Comment SETUP for the checklist.", "collaborators": [], "comment_auto_reply": null, "cover_offset_ms": null },
  "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE", "title": null, "allow_comments": true, "allow_duet": true, "allow_stitch": true, "content_disclosure": false, "promote_self": false, "promote_third_party": false },
  "published_ids": { "instagram_media_id": null, "facebook_post_id": null, "tiktok_video_id": null, "tiktok_publish_id": null },
  "error_message": null,
  "retry_count": 0,
  "created_at": "2026-09-06T08:30:00+00:00"
}
```

### GET /posts/capabilities

Read this before building a post. It says which of Instagram, Facebook and TikTok are connected, the TikTok creator's allowed `privacy_level_options` and video duration limit (fetched live from TikTok, the full list when TikTok is not connected or does not answer), the post types, and the plan's monthly post cap and storage cap with what is used.

**Response 200**

```
{
  "data": {
    "platforms": { "instagram": { "connected": true }, "facebook": { "connected": false }, "tiktok": { "connected": true, "username": "knightsoundslv" } },
    "post_types": ["feed_post", "carousel", "reels", "story"],
    "tiktok": { "privacy_level_options": ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"], "max_video_post_duration_sec": 600, "duet_disabled": false, "stitch_disabled": false, "comment_disabled": false },
    "limits": { "posts_per_month": 100, "posts_used_this_month": 4, "storage_mb": 2048, "storage_used_mb": 131, "caption_max": 2200, "collaborators_max": 3, "carousel_items": { "min": 2, "max": 10 } }
  }
}
```

### /posts/{id}/media

`POST /posts/{id}/media` uploads one file as `multipart/form-data` in a field named `file`, with an optional `sort_order`. Call it once per item. JPEG, PNG or WebP up to 8 MB and 320 to 1440 px wide; MP4 or MOV up to 1 GB, 3 seconds to 15 minutes for Reels and up to 60 seconds for stories. A carousel takes 2 to 10 items. Uploads count against the plan's storage cap (`403 plan_limit_reached`). Every media call returns the post.

`PATCH /posts/{id}/media` with `{"order": [mediaId, ...]}` reorders; list every id. `DELETE /posts/{id}/media/{mediaId}` removes the file.

**Upload**

```
curl -X POST https://sumgenius.ai/api/v1/posts/90050/media \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -F "file=@reel.mp4" -F "sort_order=0"
```

### /posts/{id}/publish · schedule · unschedule · retry

- POST /posts/{id}/publishbody {} Publishes to every target at once. Needs a caption (stories excepted), media for any Instagram target, and two or more items for a carousel. When a platform rejects it the post becomes `failed` and the call is `422 publish_failed` with the post and per-platform `results`. [post.published](https://sumgenius.ai/docs/webhook-events#post-events) or [post.failed](https://sumgenius.ai/docs/webhook-events#post-events) fires either way.
- POST /posts/{id}/schedule{"scheduled_at"} Sets or moves the publish time of a draft, failed or scheduled post. The scheduler publishes it within a minute of that time and fires the same events. `post.scheduled` fires now.
- POST /posts/{id}/unschedulebody {} Scheduled back to draft.
- POST /posts/{id}/retrybody {} Publishes a failed post again, up to the retry cap.

Any of these on a post in the wrong status is `409 not_editable`.

**Publish now**

```
curl -X POST https://sumgenius.ai/api/v1/posts/90050/publish \
  -H "X-SumGenius-Api-Key: sgwh_your_api_key_here" \
  -H "Content-Type: application/json" -d '{}'
```

## Market Intel

Competitor tracking and Instagram account insights, the data behind the portal's Market Intel page. Two things gate it. The account must be connected with **Facebook Login**, because Instagram-only login cannot read competitors, and every route answers `403 feature_unavailable` otherwise. Competitor slots are capped by plan: 1 on Free, 3 on Creator, 5 on Professional, 10 on Business.

Everything except `/market-intel/insights` reads stored snapshots, so it is instant and spends no Meta quota. `POST /market-intel/refresh` is what writes those snapshots, and it is the only call that costs you Meta requests.

### GET /competitors

Every account you track, with the numbers from its latest stored snapshot, plus how many slots your plan allows. The whole set comes back in one page: `has_more` is always false.

`last_snapshot_date` is `null` until the first refresh, and `status` is `error` when Meta last refused the lookup, which usually means the account went private or changed handle.

Beyond the usual envelope

- trackedintegerSlots in use.
- max_trackedintegerYour plan's cap.
- remainingintegerWhat is left.

### POST /competitors

Body is `{"username": "nasa"}`, with or without the leading @. The handle must be a public Instagram business or creator account: personal and private accounts are invisible to the API for everyone, and come back `422 invalid_param`. At your plan's cap this is `403 plan_limit_reached`, and an account you already track is `409 conflict`. The first snapshot is taken immediately, so the response already carries follower and engagement numbers.

### DELETE /competitors/{id}

Frees the slot. Snapshot history is kept, so re-adding the same handle later picks up where it left off rather than starting from zero.

**Track an account**

```
curl -X POST https://sumgenius.ai/api/v1/competitors \
  -H "X-SumGenius-Api-Key: sgwh_..." \
  -H "Content-Type: application/json" \
  -d '{"username": "nasa"}'
```

**201 Created**

```
{
  "data": {
    "id": 22,
    "username": "nasa",
    "display_name": "NASA",
    "is_verified": true,
    "followers": 104383728,
    "engagement_rate": 0.6412,
    "last_snapshot_date": "2026-09-08"
  }
}
```

### GET /market-intel/leaderboard

Your account and every competitor ranked together, which is the one call to make for any "how do I compare" question. Each row carries followers, following, posts, engagement rate, average likes and comments, posts per week, follower growth per day and the content mix as percentages of reels, photos and carousels. Your own row has `is_own_account: true`, and `your_rank` saves you finding it.

Query parameters

- periodintegeroptional`7`, `30` (default) or `90`. Anything else is a 400.

### GET /market-intel/history

Follower count and engagement rate over the window, one series per account, oldest point first, yours first in the list. The account label is the competitor's handle, or the literal `you` for your own. One point per day a snapshot exists, so gaps mean nobody refreshed that day. Same `period` parameter.

### GET /market-intel/wins

The comparisons already computed against each competitor over the last 30 days, split into `wins` where you lead and `opportunities` where they do. Each carries a `metric` (engagement, growth, posting), the `competitor` it is measured against, and a `message` written for a person with the gap in it. No AI is involved; this is arithmetic on the snapshots.

**Leaderboard**

```
{
  "data": {
    "period_days": 30,
    "your_rank": 1,
    "total_accounts": 3,
    "accounts": [
      {
        "username": "sumgeniusai",
        "is_own_account": true,
        "followers": 39,
        "engagement_rate": 14.3162,
        "posts_per_week": 0.32,
        "follower_growth_per_day": 0.1,
        "content_mix": {
          "reels": 4,
          "photos": 37,
          "carousels": 59
        }
      }
    ]
  }
}
```

**A win**

```
{
  "metric": "engagement",
  "competitor": "chatgpt",
  "message": "Higher engagement than @chatgpt (+13.91%)"
}
```

### GET /competitors/{id}

One competitor and your own account side by side, as the portal's comparison table. `metrics[]` holds a row per measure with `you`, `them`, the `difference` and `you_lead`. That last one is `null` for a metric with no better side, such as Following, so do not read a null as a loss. `your_wins` and `competitor_wins` count the decided rows.

Stored snapshots, no Meta call. A competitor id from another account is a `404`, like everywhere else in this API.

**Comparison**

```
{
  "data": {
    "competitor": { "username": "hawaiientrepreneurs" },
    "metrics": [
      {
        "metric": "Engagement Rate",
        "you": 14.3162,
        "them": 0.1291,
        "difference": 14.1871,
        "you_lead": true
      },
      {
        "metric": "Following",
        "you": 6,
        "them": 4859,
        "difference": -4853,
        "you_lead": null
      }
    ],
    "your_wins": 3,
    "competitor_wins": 3
  }
}
```

### GET /market-intel/audience

The latest stored breakdown of your own followers. `gender_percent` and `age_percent` are shares, not counts, because that is all Instagram reports. `top_countries` and `top_cities` are lists of `{country|city, count, percentage}`, biggest first. `online_followers_by_hour` is what "when should I post" is answered from, and Instagram often omits it.

`404` until the first refresh. Instagram withholds all of this for accounts under roughly 100 followers, which arrives as zeros and empty lists rather than an error.

### GET /market-intel/top-posts

Your own recent posts with their stored performance: reach, impressions, likes, comments, saves, shares, video views, engagement rate and save rate, newest first. `limit` is 1 to 50, default 12. Stored, so refresh first if you need today's numbers.

### GET /market-intel/insights

Reach, profile views, accounts engaged, interactions, saves, shares and follows for your own account. `days` is 1, 7 (default) or 30. **This one calls Instagram live**, so it takes a moment and counts against Meta's rate limit. Which metrics come back depends on what the account is eligible for, so read the object rather than assuming a fixed set. A connection made without the insights permission is `403 permission_required`; reconnect in the portal to fix it.

**Audience**

```
{
  "data": {
    "snapshot_date": "2026-09-08",
    "gender_percent": { "male": 49.2, "female": 36.2 },
    "age_percent": { "25-34": 35.6 },
    "top_countries": [
      { "country": "US", "count": 12166, "percentage": 96.6 }
    ],
    "top_cities": [
      { "city": "Chicago, Illinois", "count": 3716, "percentage": 49.8 }
    ]
  }
}
```

**Insights**

```
{
  "data": {
    "days": 7,
    "metrics": {
      "views": 20,
      "reach": 2,
      "profile_views": 8,
      "accounts_engaged": 2,
      "total_interactions": 5
    }
  }
}
```

### POST /market-intel/refresh

Pulls current numbers from Meta for your account and every tracked competitor, then writes today's snapshot. Exactly what the portal's Refresh button does, and the source of everything the other routes read.

One Meta call per account, so it takes several seconds on a full slate. **Five minutes are enforced between refreshes**: too soon and you get `429 too_soon` with `retry_after_seconds` telling you exactly how long to wait. Send `{}` as the body.

`refreshed` counts the competitors updated, `errors[]` names the ones Meta refused, usually because the account went private.

**Too soon**

```
{
  "error": {
    "code": "too_soon",
    "message": "Please wait 285 seconds before refreshing again.",
    "retry_after_seconds": 285
  }
}
```
