Docs

API reference

Three APIs, for different jobs. All of them run on WordPress.

Which API should I use?

JobUseWhy
Create / update / delete content/wp-json/wp/v2/…WPGraphQL mutations exist but REST is simpler and covers every field here.
Read content for a page/graphqlOne request for exactly the fields you need. This is what the site build uses.
Users, billing, settings/wp-json/app/v1/…This project's own endpoints, behind a shared secret.

Authentication

REST — application passwords

Create one at Users → Profile → Application Passwords, then send it as HTTP Basic. Reads of published content need no auth; writes do.

curl -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  "https://cms.bowtiekreative.com/wp-json/wp/v2/projects"

app/v1 — shared secret

Header X-App-Secret, matching APP_SHARED_SECRET in wp-config. Server-to-server only — never expose it to a browser. Every route 401s without it.

curl -H "X-App-Secret: $WP_SHARED_SECRET" \
  "https://cms.bowtiekreative.com/wp-json/app/v1/auth/users"

REST CRUD

Base: https://cms.bowtiekreative.com/wp-json/wp/v2

OperationRequest
ListGET /projects?per_page=20&page=1
ReadGET /projects/<id>
Read by slugGET /projects?slug=northside-coffee
CreatePOST /projects
UpdatePOST /projects/<id>
DeleteDELETE /projects/<id> (trash) · ?force=true (permanent)

Endpoints by type

TypeRESTGraphQLField group
Posts/wp/v2/postspostsseo
Pages/wp/v2/pagespageshero, seo
Projects/wp/v2/projectsprojectsprojectDetails, seo
Services/wp/v2/servicesservicesserviceDetails, seo
Testimonials/wp/v2/testimonialstestimonialstestimonialDetails

Taxonomies: /wp/v2/app_capability and /wp/v2/app_industry.

Create a project

curl -X POST "https://cms.bowtiekreative.com/wp-json/wp/v2/projects" \
  -u "$WP_USER:$WP_APP_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{
    "title":   "Northside Coffee Rebrand",
    "slug":    "northside-coffee",
    "status":  "publish",
    "content": "<p>A full identity refresh.</p>",
    "meta": {
      "app_project_client":       "Northside Coffee",
      "app_project_year":         2026,
      "app_project_featured":     true,
      "app_project_deliverables": ["Logo system","Packaging"]
    }
  }'

Update just one field

curl -X POST "https://cms.bowtiekreative.com/wp-json/wp/v2/projects/7" \
  -u "$WP_USER:$WP_APP_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{"meta":{"app_project_year":2027}}'

meta is merged, not replaced — keys you omit keep their value.

Custom fields

Meta Box fields are exposed under meta byapp-rest-fields.php. They use their raw ids there, unlike GraphQL where the shared prefix is stripped — app_project_client in REST is projectDetails.client in GraphQL.

Meta Box typeREST shapeExample
text, textarea, url, selectstring"Acme Co."
number (step 1)integer2026
checkbox, switchbooleantrue
clone: truearray of strings["Logo","Packaging"]
single_imageinteger (attachment ID)42
image_advancedarray of integers[42, 43]
groupnot exposedUse GraphQL

Images are attachment IDs. Upload first via POST /wp/v2/media, then write the returned id. GraphQL resolves the same fields to full objects with url, alt and dimensions.

GraphQL

POST https://cms.bowtiekreative.com/graphql · explore it inGraphiQL. Introspection is disabled for unauthenticated requests by default, which is why GraphiQL runs inside wp-admin.

curl -X POST "https://cms.bowtiekreative.com/graphql" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ project(id: \"northside-coffee\", idType: SLUG) {
        title
        frontendPath
        projectDetails {
          client
          year
          deliverables
          gallery { url alt width height }
        }
      } }"}'

Meta Box groups appear as one object field each. Values resolve lazily, so a field you don't ask for costs no database read. Cursor pagination: projects(first: 100, after: $cursor) — WPGraphQL capsfirst at 100.

App endpoints

Base: https://cms.bowtiekreative.com/wp-json/app/v1 · all require X-App-Secret.

MethodPathDescription
POST/auth/login{ login, password } → user object. 401 on bad credentials, 429 when rate limited.
GET/auth/user/{id}One user, including their subscription.
GET/auth/usersPaged user list. `page`, `per_page` (max 200), `search`.
POST/auth/reset{ login } → always { ok: true }. Sends the WordPress reset email.
GET/billing/{userId}That user’s subscription.
POST/billing/{userId}Write subscription state. Partial — omitted keys are left alone.
GET/billing/lookup?by=stripe_customer|subscription|email&value=… → { id, email }.
GET/settingsAll stored API keys. Used by the front end.
GET/site-modeCurrent coming-soon / maintenance state.

Webhooks this app receives

PathFromVerified by
/api/billing/stripe-webhookStripeHMAC over the raw body (STRIPE_WEBHOOK_SECRET)
/api/billing/paypal-webhookPayPalPayPal's verify-webhook-signature API (PAYPAL_WEBHOOK_ID)
/_refreshWordPress on saveDev only — refreshes the content cache

Webhooks are the only thing that grants a subscription. Unverified events are rejected with 400; a handler failure returns 500 so the provider retries.

Status codes

400Malformed body, or a webhook that failed verification.
401Missing/wrong X-App-Secret, or bad credentials.
403Authenticated but not allowed — includes Astro's cross-origin POST check.
404Not found, or found but not yours to see (admin pages).
429Too many failed logins, or Emailit's rate limit.
503Maintenance mode, or the auth API with no shared secret configured.

WordPress errors come back as { code, message, data: { status } }. Failed logins always say the same thing regardless of whether the account exists.