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.
- CRM — create contacts and companies.
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 | ✅ | ❌ |
- 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 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.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
- 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 | |
| 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 | ||
| 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 |
- 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).