DW DiscoverWorthy API docs
.md llms.txt
Built for humans and LLMs. · Point your assistant at this page, hit Copy for LLM, or fetch the raw .md. Every type is introspectable, too.

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 #

Text
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. Get or rotate it via the Rigg MCP tools dw_get_or_create_business_api_key / dw_rotate_business_api_key, or 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.
Shell
# 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) { ... }
  teamMembers { ... }
  contact { ... }                          # published email / phone / address / socials / hours
}

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

Service fields: id, name, shortName, description, keyBenefits, priceRange, url, audience (business | freelancer | individual), imageUrl, imageKey, isFeatured, isActive. shortName is an optional short label for a tile or nav item (fall back to name). imageUrl is the service's picture as a servable URL — the owner uploads it in the dashboard, and swapping it there changes the live site with no rebuild; imageKey is the bare managed-asset key behind it, for urlFor() / <DwImage> (null for an external URL). Key tiles and screenshots off the service id and these fields, never off the service name — a name-to-image map in your code breaks the day the owner renames the service.

TeamMember fields (teamMembers, org-wide): id, name, roleTitle, specialties, background, photoUrl, linkedinUrl, links { label url kind }. linkedinUrl is the person's LinkedIn profile; links holds any other public profiles (GitHub, X, a portfolio) in the owner's order, with kind naming the platform for an icon (linkedin | x | github | instagram | facebook | youtube | tiktok | website). Every link is an absolute http(s) URL. Members who opted out of public mention are never returned; email and phone are never exposed. Which people a site shows is per site. Until the owner (or you, with the Rigg tool dw_set_site_team) picks a list, teamMembers returns everyone cleared for public mention. Once a list exists it returns only those people, in the owner's order, so someone added later does not appear until they are added to it. The owner maintains the same list, with names, roles and bios, in Website → Collections → "Your website's team"; saving republishes the site. Don't hardcode who is on the team in your repo, and don't expect internal staff on a client's site.

SiteContact fields (contact): email, phone, telHref, address, socialProfiles { platform url }, openingHours { day opens closes closed }. The business's PUBLISHED front-desk details, for a header, footer, Contact page and Organization JSON-LD (email, telephone, sameAs from the social URLs) — don't hard-code them. The owner edits them in My Business → Contact. Every field is null / empty until set, so render each only when present. telHref is the phone as a ready tel: link. platform is one of linkedin | facebook | instagram | x | youtube | tiktok | github, at most one each. openingHours lists all seven days (Monday first) when the business has a weekly schedule (the shop's per-day trading hours), else is empty. Write with setSiteContact(input: { email, phone, address, socialProfiles: [{ platform?, url }] }) (secret key; only the fields you pass change, socialProfiles replaces the list).

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 shortName audience imageUrl imageKey }
    clients(featuredOnly: true) { name logoUrl website }
    teamMembers { id name roleTitle photoUrl linkedinUrl links { label url kind } }
    contact { email phone telHref address socialProfiles { platform url } openingHours { day opens closes closed } }
  }
}

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
shortName String Short tile/nav label; null clears
imageUrl String Bare managed-asset key (best — stays swappable) or absolute http(s) URL; null clears
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
linkedinUrl String linkedin.com profile, http(s); null clears
profileLinks [ProfileLinkInput!] { label, url } — other public profiles, http(s) only; replaces the whole list
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:

Shell
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
upsertCollectionItem(collectionId: ID!, item: CollectionItemUpsertInput!) Include item.id to update (partial), omit it to create
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 or upsert CRM contacts and companies (organisation-scoped — they belong to your workspace, not a single site). The create* mutations dedupe but return a match UNCHANGED (contactCreated / companyCreated come back false); the upsert* mutations also UPDATE a matched row — only the fields you pass change, an explicit null clears a field.

Mutation Purpose
createCrmContact(input: CreateCrmContactInput!) Create a contact (dedupe by email — match returned unchanged); optionally find-or-create + link its company by name
createCrmCompany(input: CreateCrmCompanyInput!) Create a company (dedupe by name — match returned unchanged)
upsertCrmContact(input: UpsertCrmContactInput!) Match by id or email, patch the fields you pass, insert when new
upsertCrmCompany(input: UpsertCrmCompanyInput!) Match by id or name, patch the fields you pass, insert when new

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

UpsertCrmContactInput: same fields plus optional id; email is the match key when id is absent (and can never be changed); companyName (re)links the company on matched contacts too. UpsertCrmCompanyInput: id, name (match key without id; with id it renames), website, domain, notes. Both return created (true = inserted, false = matched and updated).

GraphQL
mutation {
  upsertCrmCompany(input: {
    name: "Acme Pty Ltd"
    website: "https://acme.com"
    notes: "Key account — renewal due Q3"
  }) {
    company { id name website notes }
    created
  }
}
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).

For a "featured articles" section, use posts(featuredOnly: true): the owner marks posts Featured on the article's page in the dashboard, so the section changes without a code change. Don't hand-pick slugs in code.

GraphQL
site {
  posts(clusterId: ID, limit: Int = 20, offset: Int = 0, featuredOnly: Boolean = false) {
    id slug title excerpt content featuredImageUrl publishedAt isFeatured
    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, …)
upsertDeal(input: UpsertDealInput!) Include id to update (only fields you pass change; stageId moves the deal), omit it to create
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).