# DiscoverWorthy GraphQL API

A single GraphQL endpoint for **reading and writing** your DiscoverWorthy data:
- **Business context** — products, services, locations, knowledge, projects, team, clients.
- **Content collections** — your headless-CMS content types and their items.
- **Blog** — your published posts + topic clusters (headless content).
- **Commerce** — orders and inventory.
- **CRM** — contacts, companies, and deals.
- **Signals** — first-party conversions and traffic.

Read it to render a live site or app; write to it to keep everything in sync from
your own systems. The schema is **fully introspectable**, so every field below (and
more) is discoverable live — this doc covers the shapes and gives you examples.

The schema is fully **introspectable** — point any GraphQL client (or an LLM) at
the endpoint and it self-describes every type, field, and argument below.

## Endpoint

```
POST https://api.discoverworthy.com/graphql
```

Standard GraphQL over HTTP: send `{ "query": "...", "variables": { ... } }` as a
JSON body. `Content-Type: application/json`.

## Authentication & scopes

Send a token via `Authorization: Bearer <token>` **or** `x-api-key: <token>`.
There are two access scopes:

| Token | Where to find it | Reads | Writes |
|-------|------------------|:-----:|:------:|
| **Secret key** (`dwsk_…`) | Developer portal | ✅ | ✅ |
| **Publishable token** (`pk`) | Hosting settings | ✅ | ❌ |
| Deploy token | Hosting settings | ✅ | ❌ |

- The **secret key** (`dwsk_…`) is your server-side write credential — the **only**
  one that can run mutations. Generate and rotate it in the developer portal at
  `https://api.discoverworthy.com`. Never expose it in a browser or client
  bundle. Rotating it immediately revokes the old key.
- The **publishable token** (`pk`) is client-safe (fine to embed in a browser or
  mobile app) but **read-only** — mutations with it fail with `code: "FORBIDDEN"`.
- The **deploy token** is your hosting/build credential; it can **read** here but
  not write, so a leaked deploy token can't mutate your data.

```bash
# reads — any of the three tokens
curl -H "Authorization: Bearer <pk_or_secret_key>" ...
# writes — the secret key only
curl -H "Authorization: Bearer dwsk_..." ...
```

## Discovery

An unauthenticated `GET https://api.discoverworthy.com/graphql` returns a machine-readable document
listing the endpoint, auth scopes, a read/write capability summary, and this docs
URL. No token required.

To introspect the full schema, send a normal GraphQL introspection query with a
valid token, e.g. `{ __schema { queryType { name } mutationType { name } } }`.

---

# Reading

The root `Query` has two fields — both are scoped to the site your token belongs
to, so neither takes an id.

### `site`

```graphql
site {
  id
  name
  slug
  domain                                   # custom domain, if set
  description
  organization { ... }                     # parent org (see below)
  products(active: Boolean, featuredOnly: Boolean) { ... }
  services(active: Boolean, audience: ServiceAudience) { ... }
  locations { ... }
  knowledgeItems(active: Boolean, type: KnowledgeItemType) { ... }
  clients(active: Boolean, featuredOnly: Boolean) { ... }
}
```

**Product** fields: `id, name, description, category, slug, url, keyBenefits,
priceRange, priceCents, compareAtPriceCents, currency, sku, imageUrl, imageUrls,
productType, isFeatured, isActive`.

**Service** fields: `id, name, description, keyBenefits, priceRange, url, audience
(business | freelancer | individual), isFeatured, isActive`.

**Location** fields: `id, city, region, country`.

**KnowledgeItem** fields: `id, itemType (event | fact | milestone | announcement),
title, body, startsAt, endsAt, priority, isActive`. Dates are ISO 8601.

**Client** fields: `id, name, logoUrl, website, brands, segment, since, note,
isFeatured, isActive, sortOrder`. Render logo walls / testimonials from this.
**Only clients who have consented to public mention are returned** (set
`publicMentionOk: true` on the client — see Writing).

### `organization`

```graphql
organization {
  id
  name
  slug
  logoUrl
  businessType                             # e.g. "agency", "freelancer", "brand"
  businessGoals { rank slug name }         # rank: "primary" | "secondary" | "tertiary"
}
```

### Read example

```graphql
query BusinessContext {
  organization { name businessType businessGoals { rank name } }
  site {
    name
    domain
    products(active: true) { id name description priceRange url }
    services(active: true) { id name audience }
    clients(featuredOnly: true) { name logoUrl website }
  }
}
```

---

# Writing

The root `Mutation` exposes one typed `upsert<Kind>` per business-context entity,
plus a single `deleteBusinessItem`. **Writes require the deploy token.**

### Upsert semantics

- Each mutation takes an `items` array (**max 100 per call**) of typed inputs.
- An item **with an `id`** UPDATES that row — only the fields you pass change; the
  rest are left as-is.
- An item **without an `id`** CREATES a new row.
- Get existing ids from the read queries above.
- Items that fail validation are **skipped and reported per-index**; the valid ones
  in the same call still apply.

Every upsert returns an `UpsertResult`:

```graphql
{
  created { id label }     # label = the row's name/title
  updated { id label }
  skipped { index error }  # index into the items array you sent
}
```

### Mutations & fields

Required-on-create means the field must be present when creating (no `id`); it is
optional when updating. Unlisted/unknown fields are ignored.

**`upsertServices(items: [ServiceInput!]!)`**

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | Omit to create; include to update |
| name | String | ✅ | |
| description | String | | |
| keyBenefits | [String!] | | |
| priceRange | String | | Human-readable, e.g. `"£200/hour"` |
| url | String | | |
| audience | ServiceAudience | | `business` \| `freelancer` \| `individual`; omit for cross-audience |
| locationIds | [ID!] | | Location ids this service is offered from; omit for all. Must reference real locations |
| isFeatured | Boolean | | |
| isActive | Boolean | | |

**`upsertLocations(items: [LocationInput!]!)`** — service areas. Keep geo-only; never bake geography into service/product names.

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | |
| city | String | ✅ | |
| country | String | ✅ | |
| region | String | | State / region |
| locationType | LocationType | | `service_area` (default) \| `supply_only` (fulfilment centre, no on-site service) |

**`upsertKnowledgeItems(items: [KnowledgeItemInput!]!)`** — temporal facts/events.

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | |
| itemType | KnowledgeItemType | ✅ | `event` \| `fact` \| `milestone` \| `announcement` |
| title | String | ✅ | |
| body | String | | |
| startsAt / endsAt | String | | ISO date strings |
| relevanceWindow | RelevanceWindow | | `always` \| `before` \| `during` \| `after` \| `auto` \| `recent` |
| autoExpireDays | Int | | |
| autoReference | Boolean | | Let agents cite it |
| priority | Int | | Higher = more important |
| isActive | Boolean | | |

**`upsertProducts(items: [ProductInput!]!)`**

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | |
| name | String | ✅ | |
| description | String | | |
| keyBenefits | [String!] | | |
| priceRange | String | | Human-readable, e.g. `"$5–$50"` |
| url | String | | |
| category | String | | |
| imageUrl | String | | |
| sku | String | | |
| productType | ProductType | | `physical` \| `digital` \| `service` |
| priceCents | Int | | Price in cents |
| currency | String | | e.g. `"AUD"` |
| stockQuantity | Int | | |
| trackInventory | Boolean | | |
| locationIds | [ID!] | | Ids this product applies to; omit for all |
| isFeatured | Boolean | | |
| isActive | Boolean | | |

**`upsertProjects(items: [ProjectInput!]!)`** — case studies / portfolio.

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | |
| title | String | ✅ | |
| elevatorPitch | String | | |
| clientName | String | | |
| problem / outcome / technicalDetails | String | | |
| tags | [String!] | | |
| servicesCapabilities | [String!] | | |
| status | ProjectStatus | | `active` \| `completed` \| `archived` |
| startDate / endDate | String | | ISO date strings |
| isFeatured | Boolean | | |
| publicMentionOk | Boolean | | **Consent** to name `clientName` in generated content (default false) |

**`upsertTeamMembers(items: [TeamMemberInput!]!)`** — org-scoped staff profiles.

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | |
| name | String | ✅ | |
| roleTitle | String | ✅ | |
| specialties | [String!] | | |
| background | String | | |
| locationIds | [ID!] | | |
| photoUrl | String | | |
| email | String | | |
| isActive | Boolean | | |
| contentWeighting | ContentWeighting | | `low` \| `normal` \| `high` |

**`upsertClients(items: [ClientInput!]!)`** — customer roster (a strong ICP signal).

| Field | Type | Req. on create | Notes |
|-------|------|:---:|-------|
| id | ID | | |
| name | String | ✅ | |
| logoUrl | String | | Bare managed-asset key (best — stays swappable) or absolute URL |
| website | String | | |
| brands | [String!] | | Sub-brands under this client |
| segment | String | | Free-text tier/segment |
| since | String | | ISO date (YYYY-MM-DD) relationship started |
| note | String | | |
| isFeatured | Boolean | | |
| isActive | Boolean | | |
| publicMentionOk | Boolean | | **Consent gate** (default false): the public `clients` read query returns ONLY clients with this true. A logo wall stays empty until you set it per client you have permission to showcase |
| sortOrder | Int | | |

**`deleteBusinessItem(kind: BusinessKind!, id: ID!)`** — permanent. Returns
`{ deleted, id }` (`deleted: false` if no such row). `kind` is one of
`service, location, knowledge, product, project, team_member, client`. To merely
hide an item, prefer an upsert with `{ id, isActive: false }` where supported.

### Write examples

Create two products, then update one by id:

```graphql
mutation {
  upsertProducts(items: [
    { name: "Trail Runner", priceCents: 12900, currency: "AUD", productType: physical, isFeatured: true },
    { id: "EXISTING_ID", priceRange: "$99–$149" }
  ]) {
    created { id label }
    updated { id label }
    skipped { index error }
  }
}
```

Add a client to a public logo wall (consent set):

```graphql
mutation {
  upsertClients(items: [
    { name: "KMD Brands", website: "https://kmdbrands.com", brands: ["Kathmandu", "Rip Curl"], publicMentionOk: true }
  ]) { created { id label } }
}
```

curl:

```bash
curl -X POST https://api.discoverworthy.com/graphql \
  -H "Authorization: Bearer dwsk_..." \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation($items:[ServiceInput!]!){upsertServices(items:$items){created{id label} skipped{index error}}}","variables":{"items":[{"name":"Consulting","audience":"business"}]}}'
```

JavaScript (fetch, with variables):

```javascript
const res = await fetch('https://api.discoverworthy.com/graphql', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer dwsk_...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    query: `mutation Upsert($items: [ProductInput!]!) {
      upsertProducts(items: $items) { created { id label } updated { id label } skipped { index error } }
    }`,
    variables: { items: [{ name: 'Widget', priceCents: 4999, currency: 'AUD' }] },
  }),
});
const { data, errors } = await res.json();
```

---

# Content collections (headless CMS)

A **collection** is a content *type* — a named field schema (e.g. `testimonials`,
`faqs`). Its **items** are the records. Because the fields are defined per
collection at runtime, an item's `data` is an untyped `JSON` object keyed by your
field names.

Reserved slugs are refused: anything that belongs to business context
(`products`, `services`, `locations`, `knowledge`, `clients`, `goals`) or the blog
(`posts`, `articles`). Use a distinct slug (e.g. `featured_products`).

### Reading

```graphql
site {
  collections {
    slug
    label
    fields { name type label required }
    items { id data status displayOrder }     # published only by default
  }
  collection(slug: "testimonials") {
    items(status: draft) { id data }
  }
}
```

Field `type` is one of: `text, textarea, number, boolean, date, url, image`
(`image` holds a managed-asset key). Item `status` is `published` or `draft`.

### Writing

| Mutation | Purpose |
|----------|---------|
| `defineCollection(input: DefineCollectionInput!)` | Create/update a collection's schema (idempotent by slug; re-defining keeps items) |
| `deleteCollection(id: ID!)` | Delete a collection and all its items |
| `createCollectionItem(collectionId: ID!, item: CollectionItemInput!)` | Add an item |
| `updateCollectionItem(id: ID!, patch: CollectionItemPatch!)` | Partial update |
| `deleteCollectionItem(id: ID!)` | Delete an item |

`DefineCollectionInput`: `slug` (slug-style, required), `label` (required),
`description`, `fields: [{ name, type, label, required }]` (≥1). `CollectionItemInput`:
`data: JSON!` (object keyed by field name), `status`, `displayOrder`.

```graphql
mutation {
  defineCollection(input: {
    slug: "testimonials"
    label: "Testimonials"
    fields: [
      { name: "quote", type: textarea, label: "Quote", required: true }
      { name: "author", type: text, label: "Author" }
      { name: "photo", type: image, label: "Photo" }
    ]
  }) { id slug fields { name type } }
}
```

```graphql
mutation($id: ID!) {
  createCollectionItem(collectionId: $id, item: {
    data: { quote: "Best decision we made.", author: "Dana R." }
    status: published
  }) { id data status }
}
```

Note: item `data` is stored as-is; it is not validated against the field schema,
so unknown keys are kept and missing fields are allowed.

---

# CRM

Create CRM contacts and companies (organisation-scoped — they belong to your
workspace, not a single site). Both dedupe: `contactCreated` / `companyCreated`
come back `false` when an existing row was matched instead of inserted.

| Mutation | Purpose |
|----------|---------|
| `createCrmContact(input: CreateCrmContactInput!)` | Create a contact (dedupe by email); optionally find-or-create + link its company by name |
| `createCrmCompany(input: CreateCrmCompanyInput!)` | Create a company (dedupe by name) |

`CreateCrmContactInput`: `email` (required), `firstName`, `lastName`, `mobile`,
`linkedinUrl`, `source`, `companyName` (find-or-create + link), `companyWebsite`.
`CreateCrmCompanyInput`: `name` (required), `website`, `domain`, `notes`.

```graphql
mutation {
  createCrmContact(input: {
    email: "sam@acme.com"
    firstName: "Sam"
    companyName: "Acme Pty Ltd"
  }) {
    contact { id email companyId }
    contactCreated
    company { id name }
    companyCreated
  }
}
```

Passing `companyName` on a contact find-or-creates that company (by name) and links
it. Company linking happens on contact *creation*; matching an existing contact by
email does not re-link it. This is a server-to-server API (deploy token) — it is not
for capturing leads directly from a browser.

---

# Blog & content

Read your **published** blog posts and topic clusters (headless — render them on any
frontend). Read-only; drafts/internal fields are never exposed. Post **body is
Markdown** (`content`).

```graphql
site {
  posts(clusterId: ID, limit: Int = 20, offset: Int = 0) {
    id slug title excerpt content featuredImageUrl publishedAt
    cluster { id name color }
  }
  post(slug: "my-post-slug") { title content publishedAt }
  topicClusters { id name description pillarKeywords color }
}
```

---

# Commerce — orders & inventory

Read orders + stock, and update fulfilment/stock. Order money is in integer cents;
customer PII is your own data (server-side token). Payment references and platform
fees are never exposed.

### Reading

```graphql
site {
  orders(status: "paid", search: "", limit: 50, offset: 0) {
    total
    orders {
      id orderNumber status totalCents currency createdAt
      customerName customerEmail trackingNumber
      items { productName quantity fulfilledQuantity unitPriceCents totalCents }
    }
  }
  order(id: "ORDER_ID") { orderNumber status items { productName quantity } }
  lowStock(threshold: 5) { id name stockQuantity }
  inventoryByLocation { locationName units skus }
}
```

### Writing

| Mutation | Purpose |
|----------|---------|
| `updateOrderStatus(orderId, status, trackingNumber, trackingUrl)` | Move an order's status / add tracking |
| `adjustStock(productId, variantId, adjustment, reason, notes)` | Adjust stock (±). `reason`: restock, sale, correction, return, damaged, manual, … |

```graphql
mutation {
  updateOrderStatus(orderId: "ORDER_ID", status: "shipped", trackingNumber: "AA123") {
    orderNumber status trackingNumber
  }
  adjustStock(productId: "PROD_ID", adjustment: 25, reason: "restock") {
    productId newQuantity
  }
}
```

---

# CRM — contacts, companies, deals

Create (earlier section) and now **read + manage deals**. Organisation-scoped.

```graphql
organization {
  contacts(search: "", limit: 50, offset: 0) {
    total
    contacts { id email firstName lastName companyId stageName createdAt }
  }
  companies(limit: 50) {
    total
    companies { id name domain contactCount openDealCount openDealValueCents }
  }
  deals { id title amountCents currency status stageKey stageName contactId }
  dealStages { id key name rank }
}
```

| Mutation | Purpose |
|----------|---------|
| `createDeal(input: CreateDealInput!)` | Create a deal (`title` required; `amountCents`, `stageId`, `contactId`, `expectedCloseDate`, …) |
| `moveDealStage(id, stageId)` | Move a deal to a pipeline stage (get stage ids from `dealStages`) |

```graphql
mutation {
  createDeal(input: { title: "Website rebuild", amountCents: 850000, currency: "AUD" }) {
    id title status stageName
  }
}
```

---

# Signals — conversions & traffic

First-party conversion + traffic data (site-scoped, read-only). Aggregates plus a
paginated raw conversion feed. Visitor identifiers, email hashes, and click ids are
never exposed — only counts and non-PII fields.

```graphql
site {
  conversions(days: 30) {
    total
    byType { type count valueCents currency }
    funnel { visitors engaged converted }
  }
  conversionEvents(days: 30, limit: 50, offset: 0) {
    id conversionType goalKey valueCents currency source referrerDomain createdAt
  }
  conversionGoals { goalKey displayName kind isActive }
  traffic(days: 30) {
    pageViews uniqueVisitors changePercent
    topPages { path views }
    topReferrers { domain views }
    topCountries { country views }
    deviceSplit { desktop mobile tablet }
  }
}
```

---

## Errors & limits

GraphQL errors come back in the standard `errors[]` array with an
`extensions.code`:

| code | Meaning |
|------|---------|
| `FORBIDDEN` | Mutation attempted with a read-only (publishable/build) token — use the deploy token |
| `RATE_LIMITED` | Per-site daily write cap reached; retry later |
| `BAD_USER_INPUT` | Empty `items`, or more than 100 items in one call |

- **Writes** are capped per site per 24h (generous; guards against runaway loops).
  Reads are uncapped.
- Every successful write is logged to your workspace Activity Log
  ("Business context updated via API").

## Notes

- The API is **world-readable** for product/service data. Client rows appear in the
  read `clients` query only with `publicMentionOk: true`.
- Reads accept any token; writes require the deploy token.
- All dates are ISO 8601 (UTC).
