DiscoverWorthy GraphQL API

A single GraphQL endpoint for reading and writing your DiscoverWorthy data:

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 — 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.app/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
# 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.app/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

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

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

Read example

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

Every upsert returns an UpsertResult:

{
  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:

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):

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

curl:

curl -X POST https://api.discoverworthy.app/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):

const res = await fetch('https://api.discoverworthy.app/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

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.

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 } }
}
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.

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.


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

Notes