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/graphqlStandard 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 toolsdw_get_or_create_business_api_key/dw_rotate_business_api_key, or in the developer portal athttps://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 withcode: "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.
# 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 #
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 #
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 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
itemsarray (max 100 per call) of typed inputs. - An item with an
idUPDATES that row — only the fields you pass change; the rest are left as-is. - An item without an
idCREATES 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:
{
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 |
|
| 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.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):
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 #
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.
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 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).
mutation {
upsertCrmCompany(input: {
name: "Acme Pty Ltd"
website: "https://acme.com"
notes: "Key account — renewal due Q3"
}) {
company { id name website notes }
created
}
}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.
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 #
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, … |
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.
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) |
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.
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
clientsquery only withpublicMentionOk: true. - Reads accept any token; writes require the deploy token.
- All dates are ISO 8601 (UTC).