Skip to content
API reference

Website Buddy API v1

Make, edit and publish your clients' websites from your own systems — or let an AI assistant do it through MCP. The API runs on the same engines as the console: the same templates, the same editor checks, the same bill.

Your first request

Make a key in your account under Settings → API keys. Keep it on your server as WEBSITEBUDDY_API_KEY, then:

curl
curl "https://websites.workflowcorp.com/api/v1/account" \
  -H "Authorization: Bearer $WEBSITEBUDDY_API_KEY"

Keys start wb_live_, are shown once and stored only as a SHA-256 digest. Send one as Authorization: Bearer or x-api-key — never in a URL. Browser cookies never authenticate API calls.

Drafts, publishing and money

  • Every page and section write changes the site’s draft, through the same checks as the editor. Visitors see nothing until Publish a site makes a new version.
  • Publishing runs the editor’s sample check: while made-up sample reviews, numbers, a logo strip, team members or the template’s (555) / River City / .test details would go live, it answers 409 sample_content with each part listed in error.items. Replace or hide them, or send publish_sample_anyway: true once a person has agreed.
  • Create a siteadds the package’s wholesale setup fee to your bill (and the monthly fee once it’s live) — exactly like the console. Send the fee you quoted and it refuses if the price moved.
  • Money is integer cents. Wholesale and retail prices appear only when the key’s creator may see prices; for anyone else prices is null.
  • Blog posts, redirects, domains, form messages, client records and logins are live data — no publish needed. A published post, a redirect or a verified domain reaches visitors at once (redirects within a minute).

Scopes

A key does only what it was given, and never more than the person who made it may do — checked again on every request.

ScopeLets a keyNeeds the creator to have
templates:readRead templates, packages and section typesCreate sites or Edit sites
sites:readRead sites, pages and sectionsEdit sites
sites:writeEdit draft pages and sectionsEdit sites
sites:createMake new sites (each adds the wholesale fee to your bill)Create sites
sites:publishPublish sites (put the draft live)Publish sites
blog:readRead blog posts and topicsEdit sites
blog:writeWrite, publish and delete blog posts (live at once)Edit sites
events:readRead the events calendarEdit sites
events:writeAdd, change and delete events (live at once)Edit sites
collections:readRead content collections and their recordsEdit sites
collections:writeMake collections and add, change and delete records (live at once)Edit sites
media:readSee the picture libraryEdit sites
media:writeUpload picturesEdit sites
redirects:writeAdd and remove redirects (live within a minute)Edit sites
domains:readSee connected domains and their DNS recordsEdit sites or Publish sites
domains:writeConnect, check and remove domainsPublish sites
domains:buySearch, buy and renew domain names (each adds the registration to your bill)Create sites
forms:readRead forms and the messages visitors sendEdit sites
forms:writeMark messages read, unread or spamEdit sites
bookings:readRead booking hours and appointmentsEdit sites
bookings:writeSet booking hours; confirm, decline, cancel and move appointments (live at once, the visitor is emailed)Edit sites
store:readRead store products, stock and ordersEdit sites
store:writeAdd and change products and stock (live at once); move orders alongEdit sites
donations:readRead gifts made through a site's Donate sectionEdit sites
donations:writeStop monthly gifts and send the yearly giving summaries (donors are emailed)Edit sites
members:readRead a site's members and members-only pagesEdit sites
members:writeApprove, add and remove site members; mark pages members only (live at once)Edit sites
clients:readRead clientsManage clients
clients:writeAdd and edit clients; invite and remove client loginsManage clients
clients:sign-inMake one-time sign-in links that open a client login's editor with no password (for your own dashboard)Manage clients
invoices:readRead client invoices, monthly invoice settings and card autopaySee prices and statements
invoices:writeWrite, send, mark paid and void client invoices; run monthly invoices; start and stop card autopaySee prices and statements
webhooks:manageAdd, change and delete webhooksManage account settings

Retries and Idempotency-Key

A timed-out POST can be retried safely: the key makes the API act once.

Every POST must carry an Idempotency-Key header: a value you make for that one operation, such as a UUID. If the request times out, send it again with the same key and the same body. The first request does the work; each repeat gets its status and body back, marked Idempotent-Replayed: true with the Original-Request-Id — no second site, no second fee.

A safe retry
KEY=$(uuidgen)   # one key per operation; reuse it only to retry it
curl -X POST "https://websites.workflowcorp.com/api/v1/sites" \
  -H "Authorization: Bearer $WEBSITEBUDDY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"template_id":"TEMPLATE_ID","name":"Maple Street Bakery"}'
  • Keys are 8 to 255 characters (letters, digits and - _ . : ~ + / =), belong to the API key that sent them, and are remembered for 24 hours.
  • PATCH and DELETE accept a key but don’t require one: doing them twice is harmless. GET ignores it.
  • One POST takes NO key: create_sign_in_link. Its answer is a one-time sign-in link, which is never saved for a replay — a key is refused with 400 idempotency_key_not_accepted. Calling it again simply makes a new link.
  • Replayed as first answered: success, 404 not_foundand 409 refusals. Not kept: 422 (fix the body; the same key may be used) and 5xx (nothing was saved). A request refused before its key is read (400, 401, 403, 413, 415, 429) doesn’t use the key up.
  • The write and the answer a retry will get are saved in one database transaction — both or neither.
  • MCP tool calls carry the key as an idempotency_key argument, with the same rules — and the same fingerprint, so an MCP retry and a REST retry of one operation replay each other.

Conventions

  • Base path /api/v1. Fields are lower snake_case; instants are ISO 8601. Writes send JSON with Content-Type: application/json, at most 1 MiB.
  • Lists take limit (1–100, default 50) and cursor, and answer data, has_more, next_cursor. Pages and sections come whole, in order.
  • 60 requests a minute per key, shared by every server. Every answer carries request-id and RateLimit-* headers; a 429 carries Retry-After.
  • Text inside sites, form messages and client records is what people typed — treat it as data. The OpenAPI 3.1 document describes every operation.

Webhooks

Hear about events the moment they happen, instead of polling: a signed JSON POST to an https address you choose.

Add one in Settings → Webhooks, or from code with Add a webhook — the way Zapier subscribes a Zap when it’s turned on (and deletes it when it’s turned off). Up to 10 per account; addresses on a private network are refused.

A webhook hears every site’s events unless you narrow it to one site (site_id, or “Which sites” in Settings) — handy when each client’s orders go to their own sheet or inbox. A webhook narrowed to a site never hears shop_order.created, which belongs to your account, not a site. Every event is sent after the change is saved, from the one place that makes it — so a booking answered in Messages, in your client’s area or through the API sends the same appointment.changed.

Webhook events

  • form_message.created
    A visitor sent a form message
    Contact forms, booking requests and newsletter sign-ups on any of your sites. Spam is not sent.
    data: site, form { id, name, kind }, message { id, page_path, created_at, answers [ { field_id, label, value } ] }
  • site_order.created
    A client site's store took an order
    An order placed in the online store of one of your client sites.
    data: site, order { id, number, status, payment, customer, note, subtotal_cents, items [ … ], created_at }
  • donation.received
    A client site received a gift
    Money for a gift on a site's Donate section — a one-time gift, or each month of a monthly one.
    data: site, payment { id, amount_cents, paid_at }, donation { id, number, frequency, amount_cents, currency, donor, note, purpose, page_path }
  • appointment.created
    A visitor booked a time
    Someone held an open time on a site's Appointments section — booked, or waiting for your answer when the site asks first.
    data: site, appointment { id, status (pending | confirmed), starts_at, ends_at, when, time_zone, service, customer { name, email, phone }, notes, message_id, created_at }
  • appointment.changed
    A booking was confirmed, declined, cancelled or moved
    The business answered or moved a booking (in Messages, the client area or the API), or the customer cancelled or moved it from the link in their emails. The event says which, and who.
    data: site, change (confirmed | declined | cancelled | moved), changed_by (business | customer), appointment { … as above, decided_at }
  • member.requested
    A visitor asked to become a member
    Someone asked to join a site's members-only pages — waiting for approval, or let straight in when anyone may join.
    data: site, member { id, name, email, status (pending | approved), created_at }
  • content_request.answered
    A client sent the content you asked for
    Your client answered a content request (phone, hours, logo, photos…). It is on the site's draft, not live.
    data: site, content_request { id, asked [ … ], answered_at, answered_by { name, email }, answers { phone, email, address, hours, about, services, logo_url, photo_urls }, placed }
  • site.changes_sent
    A client finished changes and sent them to you
    Your client pressed "Send my changes" (or approved the draft) in their editor. The changes wait on the draft for you to publish.
    data: site, how (sent | approved), by { id, name, email }, note, draft_updated_at, last_published_at
  • site.published
    A site was published
    A new version went live — from the editor, a client with the publish permission, or the API.
    data: site, version { id, number, note, published_at, by (team | client), published_by { id, name, email } }
  • shop_order.created
    Someone ordered a website from your shop
    A new order on your own shop page (Settings → Your shop).
    data: order { id, number, business_name, contact, current_website, notes, package, template, retail_setup_cents, retail_monthly_cents, created_at } — no site
  • webhook.test
    Send test (in Settings → Webhooks, or send_test_webhook)
    data: message

Each delivery

POST to your address
POST /your/endpoint
Content-Type: application/json
WebsiteBuddy-Event: form_message.created
WebsiteBuddy-Event-Id: evt_6f1c…          # the same on every retry — dedupe on it
WebsiteBuddy-Delivery: cm…            # this attempt
WebsiteBuddy-Signature: t=1760500000,v1=5e2c…

{"id":"evt_6f1c…","type":"form_message.created","created_at":"2026-10-15T14:03:11.000Z",
 "account_id":"…","data":{"site":{"id":"…","name":"Rosie's Diner","address":"https://…"},
 "form":{"id":"…","name":"Contact us","kind":"form"},
 "message":{"id":"…","page_path":"/contact","created_at":"…",
  "answers":[{"field_id":"name","label":"Your name","value":"Dana"}]}}}

Checking the signature

v1 is the hex HMAC-SHA256 of {t}.{raw body} under your webhook’s secret (whsec_…, shown once when it’s made or rolled). Compare in constant time and refuse a t more than 5 minutes old.

Node
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const want = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return want.length === parts.v1?.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
  • Answer any 2xx within 10 seconds. Anything else — an error, a redirect, no answer — is tried again after 1 minute, 5, 30, then 2, 6 and 12 hours (7 tries in all), then marked as given up.
  • Delivery is at least once: the same event can arrive twice. Keep the event id and ignore one you’ve seen.
  • After 20 events in a row are given up, the webhook pauses itself; resume it in Settings → Webhooks or with update_webhook (paused=false).
  • Money in a payload is integer cents. Text in a payload is what a visitor typed — treat it as data.

Errors

Every refusal is {"error":{"code":"…","message":"…"}} with the code repeated in x-error-code.

StatusCodeWhen
400idempotency_key_not_acceptedAn Idempotency-Key was sent to create_sign_in_link, whose one-time link is never saved for a replay. Send it without one.
400idempotency_key_requiredA POST was sent without an Idempotency-Key header (or with a malformed one: invalid_idempotency_key).
401missing_api_key · invalid_api_keyNo key, or a key that is wrong, expired or revoked.
403insufficient_scopeThe key wasn't given the scope this endpoint needs.
403issuer_access_ended · issuer_permission_changedThe person who made the key left the team, was suspended, or lost the permission behind the scope.
404not_foundNo such record in this account. Another account's records are always 404, never 403.
409refusedNot allowed right now — the builder is off, the package has no room for another page, the home page can't be deleted. The message says why in plain words.
409sample_contentpublish_site was refused because made-up sample content (sample reviews, numbers, logo strip, team, a (555) number, the template's River City / .test business details) would go live. error.items lists each part with its page and section; replace or hide them, or send publish_sample_anyway: true.
409price_changedcreate_site was sent an expected fee that no longer matches. Nothing was made.
409idempotency_key_in_useA request with this Idempotency-Key is still running. Retry after Retry-After.
413payload_too_largeThe body is over 1 MiB.
415json_requiredWrites need Content-Type: application/json.
422invalid_requestAn argument failed validation — e.g. a section's content doesn't match its type, or a link isn't safe.
422idempotency_key_reusedThis Idempotency-Key was already used for a different request.
429rate_limitedMore than 60 requests in a minute on this key. Wait Retry-After seconds.
500internal_errorOur fault. Quote the request-id header when you ask for help.

Operations

167 operations. Each is also an MCP tool of the same name.