Prepared for {buyerName}
}Loading acceptance status…
; if (error || !data) returnAcceptance controls are unavailable.
; return ( ); } ``` Render that file through an Island only on a live quote; use a static representation in the editor, previewer, and PDF. The SDK also provides `useQuotePayment()` with analogous loading, data, error, and control behavior. Acceptance cannot be disabled after the quote is accepted or when the method is print-and-sign; payment cannot be disabled after it reaches a paid or processing state. Let the SDK guards win. Runtime rules: - invoke actions only on a live quote where the capability is available; - hide or disable controls in the editor and preview; - check whether the action is enabled before presenting it; - provide loading, success, and error states; - prevent repeated submission; - do not claim that a button completed acceptance until the SDK confirms it; - verify the resulting quote status and signed document. Disabling HubSpot's acceptance control is not the same as persisting an auditable consent record. If the acknowledgement itself must be recorded, write it through a validated serverless function and define what happens after refresh or on a second device. Acceptance and payment are contractual workflows. Keep decorative interaction separate from action state, and test with the exact quote approval, signature, and payment configuration used by the account. ## 13. Serverless functions: the safe integration boundary Browser code must not contain a HubSpot private-app token or third-party secret. If a module needs privileged data or must update CRM records, call a project serverless function and let that function call the external API. ```text Quote Island | | fetch('/hs/serverless/quote-action', ...) v HubSpot serverless function | | authenticated API request v HubSpot CRM or third-party service ``` ### Client call ```ts async function runQuoteAction(payload: unknown) { const response = await fetch('/hs/serverless/quote-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const result = await response.json().catch(() => null); if (!response.ok) { throw new Error(result?.message || 'The quote action failed'); } return result; } ``` ### Serverless handler ```js const hubspot = require('@hubspot/api-client'); const reply = (statusCode, body) => ({ statusCode, body: JSON.stringify(body), }); exports.main = async (context = {}) => { try { const token = process.env.PRIVATE_APP_ACCESS_TOKEN; if (!token) { return reply(500, { message: 'Server configuration error' }); } const body = typeof context.body === 'string' ? JSON.parse(context.body) : (context.body || {}); const quoteId = String(body.quoteId || ''); if (!/^\d+$/.test(quoteId)) { return reply(400, { message: 'Invalid quote ID' }); } const client = new hubspot.Client({ accessToken: token }); const quote = await client.crm.quotes.basicApi.getById( quoteId, ['hs_title', 'hs_status'] ); return reply(200, { id: quote.id, title: quote.properties.hs_title, status: quote.properties.hs_status, }); } catch (error) { console.error('quote-action failed', error); return reply(500, { message: 'The quote action failed' }); } }; ``` In the project app-function runtime tested by Works by Design, the body needed to be stringified; returning a plain object produced a successful response with an empty body. HubSpot also documents serverless variants that accept a body object, so treat this as runtime-specific and verify it for the project metadata/platform version you deploy. ### Project configuration matters The app metadata must permit every external hostname the function calls, using the correct permission category for each request type. The package must include runtime dependencies such as `@hubspot/api-client`. HubSpot injects the app's private access token into the supported serverless environment; do not copy it into module fields, source files, client props, or a public repository. An app-function metadata file connects the public endpoint to the bundled handler: ```json { "uid": "quote_action", "type": "app-function", "config": { "entrypoint": "/app/functions/quote-actionBundled.js", "endpoint": { "path": "quote-action", "methods": ["POST"] }, "secretKeys": [] } } ``` The surrounding `app-hsmeta.json` must declare the app's required HubSpot scopes and every permitted external URL. Bundle dependencies into the configured entrypoint when required by the runtime. After the first successful deployment, install the private app portion in the target account; without that installation, `PRIVATE_APP_ACCESS_TOKEN` is not injected. A function that deploys is not necessarily a function that is installed or authorised. ### Serverless security checklist - Validate every identifier and enum from the browser. - Authorise the requested quote rather than trusting a submitted quote ID. - Re-fetch prices and state server-side before mutations. - Use allowlists for properties and actions. - Make writes idempotent where a retry could occur. - Rate-limit abuse-prone endpoints when the platform allows it. - Return generic errors to the buyer and detailed logs to operators. - Avoid logging tokens, signatures, personal data, or full quote payloads. - Restrict outbound URLs in project configuration. - Test expired, missing, and insufficient-scope credentials. ## 14. Optional line items and buyer-controlled changes A checkbox in an Island changes only browser state. It does not automatically change the quote's authoritative line items, total, acceptance snapshot, or PDF. If buyers can select options, define the commercial workflow explicitly: 1. Render selectable items with a stable server-side identifier and current price. 2. Send the requested selection to a serverless function. 3. Re-fetch and validate the quote, option eligibility, currency, and price. 4. Apply supported CRM changes. 5. Move the quote through the required draft/publish cycle. 6. Wait for the new published state and updated totals. 7. Refresh the buyer experience from authoritative quote data. 8. Prevent acceptance while an update is pending. Do not accept a browser-submitted amount as truth. Submit identifiers and choices; calculate or retrieve the actual commercial values on the server. ## 15. Quote lifecycle constraints that affect module design Published CPQ quotes behave like controlled snapshots. API behavior is not equivalent to freely editing a CMS page. Observed current behavior documented and tested by Works by Design includes: - a published quote is locked; - patching an already associated line item may work while adding or removing line-item associations can return a successful HTTP response without changing the published quote; - quote totals are calculated by HubSpot and should be treated as read-only; - changing commercial contents may require moving the quote back to draft, waiting for unlock, applying changes, and publishing again; - publishing is asynchronous and can need a short wait or retry before the quote URL/state is ready; - acceptance captures the published quote snapshot, so UI-only option state is not contractual state. These are important platform behaviors, but API details can change. Verify the lifecycle in a test account before building a workflow that mutates live quotes. ### A safe republish sequence ```text Published quote -> move to DRAFT -> wait until unlocked -> update quote-specific line items/associations -> verify calculated values -> publish using the supported status -> poll until published data and URL are ready -> reload the buyer view ``` Use bounded retries with backoff. Record errors clearly and never leave the buyer believing an option was saved when the published quote did not change. ## 16. Creating CPQ quotes through the CRM API When a separate application creates the quote, it must construct the associations and lifecycle correctly. At a high level: - create quote-specific line-item records rather than attaching mutable product-library assumptions directly; - create the quote with the required properties and the CPQ template type expected by the account; - associate the deal, contact/company, line items, and template using the correct association definitions; - wait for quote readiness/unlock where required; - publish in a separate step; - poll for the generated public link instead of assuming immediate availability; - treat totals and final acceptance status as HubSpot-managed state. The Quotes CRM object is commonly identified as `0-14`, but association types, required properties, scopes, template IDs, and publish statuses must be verified against the current [HubSpot Quotes API documentation](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/quotes/guide) before implementation. A custom module can display an API-created quote, but it cannot compensate for incorrect quote associations or an incomplete publish sequence. ## 17. A scalable quote-module architecture For a substantial quote, prefer small sections with a shared design system over one enormous component. A useful module journey is: 1. Header 2. Cover 3. Navigation 4. Introduction 5. Line items 6. Pricing summary 7. Mutual action plan 8. Team 9. Proof or case studies 10. FAQ 11. Resources 12. Terms 13. Signature or acceptance This is the structure demonstrated by the Works by Design collection. It is not mandatory. Remove sections that do not help the buyer make or implement the decision. ### Module boundaries A section deserves its own module when it has at least one of these characteristics: - a distinct editor configuration; - optional placement; - its own quote data contract; - independent interaction; - substantially different PDF rules; - a useful standalone purpose in another quote template. Keep shared code limited to stable concerns such as tokens, currency formatting, context guards, and event names. Do not create a hidden dependency on module order unless the editor and fallback behavior make it explicit. ### Document mode and card mode The collection supports both full-width document sections and contained cards. This is a useful general pattern: make layout mode an editor field, while keeping semantic content identical. PDF output should generally prefer the document form to avoid excessive borders, shadows, and fragmented pagination. ## 18. What the free module collection demonstrates The [Works by Design Quote Module Collection](https://worksby.design/downloads/quote-module-collection-latest.zip) is useful as reference code for: - a complete multi-section quote journey; - brand-aware cover and header sections; - a countdown Island; - sticky navigation that discovers available sections; - custom line-item rendering and ordering; - tiered-pricing display; - editor-only line-item shortcuts; - a products anchor module; - pricing summary and commercial context; - mutual action plans; - team, proof, and resource repeaters; - FAQs and terms with PDF-safe accordions; - signature/acceptance presentation; - debug and experimental modules; - shared quote theme tokens; - serverless test scaffolding. Read it as an implementation library, not as a promise that every technique is a supported public API. In particular, internal editor event-bus actions should remain isolated and optional. Experimental/debug modules should not be shipped into a buyer-facing production template without review. ### When to show code and when to link to the download Documentation should include short, complete snippets for the platform seams where developers commonly get stuck. That includes the four exports, `ModuleFields`, the `hublData` assignment, `?island` imports, editor/preview/PDF branching, HubSpot-calculated line-item amounts, SDK guards, the serverless response envelope, and app-function metadata. Omitting those snippets forces a beginner to reverse-engineer the same conventions from a large codebase. Do not paste entire 300–600 line production modules into the article. Use the download for full styling, all field declarations, icons, modals, and exhaustive rendering. Point to an exact file and explain what the reader should learn from it: | Problem | Reference file inside the download | |---|---| | Small static quote module and data bridge | `src/cms-assets/my-react-assets/components/modules/QuoteIntro/index.tsx` | | Interactive Island import and SSR wrapper | `src/cms-assets/my-react-assets/components/modules/QuoteCover/index.tsx` and its `islands/QuoteCoverIsland.tsx` | | Custom line items, sorting, filtering, and authoritative amounts | `src/cms-assets/my-react-assets/components/modules/QuoteLineItems/index.tsx` | | Tiered-pricing editor modal | `src/cms-assets/my-react-assets/components/modules/QuoteLineItems/islands/TierModalIsland.tsx` | | Undocumented editor shortcuts, isolated behind guards | `src/cms-assets/my-react-assets/components/modules/QuoteLineItems/islands/EditorButtonsIsland.tsx` | | PDF-safe accordion | `src/cms-assets/my-react-assets/components/modules/QuoteTerms/index.tsx` and its `islands/QuoteTermsIsland.tsx` | | DOM-discovered sticky navigation | `src/cms-assets/my-react-assets/components/modules/QuoteNav/index.tsx` and its `islands/QuoteNavIsland.tsx` | | Quote-context inspection | `src/cms-assets/my-react-assets/components/modules/DebugQuoteContext/`—development only | | Serverless project wiring | `src/app/functions/` and `src/app/app-hsmeta.json` | This hybrid keeps the guide readable while still teaching the contracts a developer must understand to modify the downloaded code safely. ## 19. Platform limitations to design around The current quote editor is not identical to the CMS page editor. Current documented constraints include: - only one drag-and-drop area; - a HubSpot-controlled status bar that cannot be replaced by a custom module; - custom modules cannot be inserted into an individual quote through the Quotes API; an API-created quote must use a template that already contains them; - a custom Line items or Parties module is a presentation replacement only—the native module must remain, hidden, to preserve editing functionality; - deploying changed module code affects future quotes and unpublished drafts, not quotes that were already published; - removing a module from the project removes it from templates and unpublished quotes, while previously published quotes retain their published snapshot; - a project with quote modules must currently start from the supplied project shape rather than normal CLI scaffolding; - no online Design Manager code editing for React modules; - fixed or opinionated document widths; - limited control over certain native quote sections; - quote data that is available only through the specific template context; - asynchronous publication and PDF generation; - browser interaction that cannot become contractual quote state by itself; - undocumented editor internals that can change without notice. Before promising a design, build one vertical slice containing real line items, an interactive element, acceptance configuration, and a generated PDF. That spike will expose the platform boundaries earlier than a static mock-up. ## 20. End-to-end development workflow ### Step 1: define the document List the sections, owners, editable content, required CRM data, interactive behavior, and PDF result. Mark contractual information separately from decorative content. ### Step 2: inspect real quote context Create a restricted debug module, use a non-production quote, and record the exact paths and data types required. Remove broad dumps after discovery. ### Step 3: build one static module Prove fields, data bridge, styling, editor availability, live rendering, and PDF rendering before creating the entire library. ### Step 4: extract shared foundations Create theme tokens, formatters, null guards, and stable prop types. Avoid premature abstraction across modules that have not yet proven similar. ### Step 5: implement the commercial section Build the line-item and totals experience using real pricing cases. Test discounts, currencies, recurring items, and tiers. ### Step 6: add Islands Add interaction only after the static output is correct. Verify server rendering and hydration after every interactive addition. ### Step 7: add serverless workflows Introduce privileged APIs behind validated serverless endpoints. Treat buyer-controlled commercial changes as a lifecycle workflow, not a UI toggle. ### Step 8: test every runtime Run the full matrix below before release. ### Step 9: deploy and install Upload from the folder containing `hsproject.json`: ```bash hs project upload ``` After the build/deploy succeeds, open **Commerce > Quotes**, create a quote using the intended template, open the module sidebar with the plus icon, and drag the custom module into the quote body. Test both a template/blueprint and an individual quote. If the project includes a private app/serverless function, complete its account installation as well. Follow HubSpot's current [Create quote modules](https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/quotes/create-quote-modules) guide if the UI or CLI flow has changed. ### Step 10: monitor platform-sensitive adapters Maintain a short regression checklist for editor shortcuts, quote SDK actions, lifecycle mutations, and PDF behavior. These are the areas most likely to be affected by platform changes. ## 21. Release test matrix ### Editor and configuration - Module appears in the blueprint/template editor. - Every field has a clear label, default, and help text where needed. - Repeater add, remove, reorder, and empty states work. - Editor-only actions do not appear to buyers. - Missing quote associations show useful guidance rather than an exception. - Module reordering does not break navigation or heading order. ### Live quote - Desktop, tablet, and mobile layouts work. - Keyboard navigation and focus states work. - Screen-reader names and expanded states are accurate. - Hydration produces no browser-console errors. - Slow and failed network requests show recoverable states. - Currency, locale, quantities, discounts, and recurring periods display correctly. - Acceptance/payment controls match the quote's enabled capabilities. ### Commercial accuracy - One-time and recurring line items are tested. - Percentage and fixed discounts are tested. - Tier boundaries are tested. - Quote totals match HubSpot's native values. - Optional item changes persist to authoritative quote state before acceptance. - Refreshing the page does not lose a saved choice. - Concurrent or repeated submissions are handled safely. ### PDF - Generate an actual PDF, not only browser print preview. - Every accordion and essential panel is visible. - Tables wrap and paginate acceptably. - Interactive-only controls are removed or replaced. - Links have meaningful labels. - Terms, totals, and signature content are present. - Brand colours remain legible in print. ### Security and privacy - No token or secret appears in source, rendered HTML, props, logs, or network responses. - Serverless input is validated and authorised. - Debug context modules are removed or restricted. - Error messages do not expose internal payloads. - Outbound hosts and API scopes are minimal. - Personal and commercial data passed to Islands is limited to what the browser needs. ## 22. Troubleshooting | Symptom | Likely cause | What to check | |---|---|---| | Module does not appear in the quote editor | Wrong availability metadata or deployment location | Confirm `QUOTE` and `QUOTE_BLUEPRINT`, project structure, upload, and app installation | | Component renders but quote data is empty | Incorrect context path or missing association | Inspect a real test quote, add null guards, and verify the HubL JSON output | | HubL data causes a parse/render error | Invalid JSON serialisation | Use JSON-safe filters and avoid manual quoting | | Boolean setting behaves backwards | String versus boolean value | Normalise at the component boundary | | Island fails during build or SSR | Browser API accessed server-side | Move browser work into an Island effect and keep props serialisable | | Interactive content disappears from PDF | Content created or expanded only in browser state | Render bodies statically, detect PDF state, and add print CSS | | Line-item ordering is wrong | Positions compared as strings | Convert position values to numbers before sorting | | Displayed total differs from HubSpot | Recalculated amount ignored discounts/tiers | Render HubSpot's provided amount and verify pricing metadata separately | | Serverless call returns 403 or cannot reach host | App permissions, outbound URL, scope, or install issue | Check app metadata, scopes, permitted URLs, deployment, and installation | | Quote update returns success but published quote does not change | Published quote is locked or association mutation was ignored | Move through the tested draft/unlock/update/republish sequence and verify state | | Direct editor shortcut stops working | Internal event-bus contract changed | Feature-detect, fall back gracefully, and update the isolated adapter | | Brand values are missing | Assumed normal website context | Inspect `brand_settings`, quote CSS variables, and configured field fallbacks | ## 23. Supported, observed, and application-specific behavior Good developer documentation should distinguish three categories: ### Supported public platform These patterns should be anchored in current HubSpot documentation: - coded quote module exports and metadata; - CMS React fields; - HubL data templates; - React Islands; - quote development SDK actions; - HubSpot serverless functions and project configuration; - CRM Quotes API operations; - documented PDF/print behavior. ### Observed platform behavior These patterns require a test account and regression coverage: - the exact runtime shape of some quote-context values; - asynchronous publish timing; - locking and line-item association behavior on published quotes; - internal editor event-bus shortcuts; - quote-renderer CSS variables that are not part of an explicit public contract. ### Application conventions These are choices made by your implementation: - SKU prefixes; - section IDs and custom browser events; - document/card layout modes; - fallback colours and spacing tokens; - the order of modules; - whether optional items trigger republishing. Labeling these categories prevents developers from mistaking an effective implementation convention for a guaranteed platform API. ## 24. Where to go next 1. Read HubSpot's current [Create quote modules](https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/quotes/create-quote-modules) guide for the supported starter and deployment workflow. 2. Download the [Works by Design Quote Module Collection](https://worksby.design/downloads/quote-module-collection-latest.zip). 3. Deploy one simple module to a test account. 4. Inspect a real quote's context and replace sample data with a minimal contract. 5. Generate a PDF before adding complex interaction. 6. Build commercial mutations behind serverless validation and a tested quote lifecycle. The shortest path to a complex quote is not one giant component. It is a small, correct static module; a deliberate data bridge; a PDF-safe document; and then carefully layered interaction. ### Official references - [Custom quote modules overview](https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/quotes/overview) - [Create custom quote modules](https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/quotes/create-quote-modules) - [CMS React overview](https://developers.hubspot.com/docs/cms/start-building/introduction/react-plus-hubl/overview) - [React Islands](https://developers.hubspot.com/docs/cms/reference/react/islands) - [Module and theme fields](https://developers.hubspot.com/docs/cms/reference/fields/module-theme-fields) - [Serverless functions for the CMS](https://developers.hubspot.com/docs/cms/start-building/features/serverless-functions/overview) - [Quotes API guide](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/quotes/guide) # Events app — reference > Load this file when: integrating with, seeding, evaluating or troubleshooting the **worksby.design Events app** — what it does, its `events` / `event_registrations` custom objects, the label-resolved associations, and the `api.worksby.design/apps/events/` endpoints for listing events, registering attendees, and QR check-in. > 🔒 **Works by Design system — not a HubSpot platform feature.** The endpoints here live on `api.worksby.design` and work only for portals with the Events app installed. Portable regardless: the custom-object schema shape, resolving association type IDs by label, and the QR check-in decision model. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md) ## What the app is Events runs the whole lifecycle of an event inside HubSpot: an organiser creates the event as a CRM record, visitors register through an embed on the website, the app issues each attendee a QR ticket by email, and staff scan that ticket at the door. Capacity, waitlists and automatic promotion are handled for you; every registration is a CRM record, so reporting, workflows and lists work on it the way they work on anything else in the portal. | | | |---|---| | **Kind** | Public HubSpot app (OAuth), installed per portal | | **Surfaces** | Website embed (registration + ticket) · in-CRM organiser app page · QR scanner page for door staff · "Send Event Email" workflow action | | **Stores data in** | Two custom objects on your own portal — `events` and `event_registrations` | | **Paid tickets** | Optional, through the Commerce payment platform — see [Integration seams](#integration-seams) | | **How to get it** | Not self-service. Ask for an install: [w@reus.ie](mailto:w@reus.ie) | > [!NOTE] > This app is scheduled for an overhaul. This page is its **permanent entry point** — the URL will not change — but expect the detail below to be rewritten rather than extended. Check the `verified` date before relying on a specific field name. ## Overview The Events app stores event data in two HubSpot custom CRM objects: `events` and `event_registrations`. The worksby.design API provides endpoints for listing events, managing registrations, and checking attendees in. Events themselves are created and edited directly in HubSpot CRM (not via this API) — see [Creating and managing events](#creating-and-managing-events) below. **Base URL:** `https://api.worksby.design/apps/events/` All responses are JSON. Successful responses include `"success": true`. Errors include `"error": " }` | nothing |
| Identify a customer from a scanned member card | `keyring-resolve-token` `{ token, staffToken }` | the scanned token proves identity; adding a staff token also records the identification |
| List venues and their capabilities | `keyring-fetch-locations` | nothing |
| Read what can be issued or awarded here | `keyring-staff-config` `{ staffToken }` | staff token |
| Read today's activity at this venue | `keyring-staff-home` `{ staffToken }` | staff token |
| **Redeem** a voucher in full | `keyring-transact` `action: "redeem"` `{ code }` | `redeem` capability |
| **Spend part** of a gift-card balance | `keyring-transact` `action: "redeem"` `{ code, amount }` | `redeem` capability |
| **Top up** a gift-card balance | `keyring-transact` `action: "reload"` `{ recordId, amount }` | `issue` capability |
| **Issue** a voucher from the catalogue | `keyring-transact` `action: "issue"` `{ templateKey, contactId \| email }` | `issue` capability |
| **Award** points, from a spend amount or a literal delta | `keyring-transact` `action: "award"` `{ contactId \| email, programId, points \| amount }` | `earn` capability |
| **Reverse** a transaction | `keyring-transact` `action: "void"` `{ transactionId }` | staff token |
| **Enrol** a customer at the counter | `keyring-enrollment-search` then `keyring-enrollment-create` | staff token |
| Create vouchers in bulk for a campaign | `keyring-generate-incentives` | staff token |
| Read aggregate numbers, with no personal data | `keyring-reports` | nothing |
There is **no hard delete**. Keyring is a ledger: reversing something is `void`, which writes a compensating row and leaves the original visible.
> [!WARNING]
> **There is no unauthenticated contact lookup, and there will not be one.** Two endpoints that once offered it — `keyring-user-lookup` and `keyring-user-create` — were withdrawn in a security review: one returned a contact's phone, address and company to anyone who guessed an email, and the other wrote to a contact record without checking the caller owned it. They still answer, with `410 ENDPOINT_RETIRED`, so a cached client gets an explanation. If your design needs to find a customer without a scan, resolve them through a staff-gated call instead.
## The two flows that matter
### Scan a voucher, show it, redeem it
Read before you charge, so the cashier can see what they are holding:
```http
POST /hs/serverless/keyring-fetch-incentive
{ "incentiveId": "GIFT-9X2K4M" }
```
```json
{
"success": true,
"incentive": {
"hs_object_id": "59134760886",
"incentive_id": "K72M8DMAAA5B",
"incentive_type": "voucher",
"redemption_type": "balance",
"value": 50,
"initial_balance": 50,
"current_balance": 32.5,
"discount_unit": "currency",
"discount_mode": null,
"currency": "EUR",
"expiry_date": null,
"status": "active",
"is_personal": false,
"valid_scope": "all",
"is_expired": false,
"where_valid": [],
"is_claimed": true,
"is_claimable": true,
"display_value": "EUR 32.5 remaining"
},
"message": "Incentive fetched successfully"
}
```
`display_value` is pre-formatted for a cashier-facing screen, so you need not reconstruct it from the balance and currency. `is_claimed` and `is_claimable` are independent — a claimed instrument can still be claimable — so do not treat one as the negation of the other.
Then the mutation:
```http
POST /hs/serverless/keyring-transact
{
"action": "redeem",
"staffToken": "",
"idempotencyKey": "",
"code": "GIFT-9X2K4M",
"amount": 12.5
}
```
```json
{ "success": true, "transactionId": "", "remainingBalance": 20 }
```
- Omit `amount` on a balance instrument to spend the whole remaining value.
- An empty `where_valid` means valid anywhere. Otherwise a redeem at the wrong venue returns `409 INVALID_LOCATION` **with the list of venues where it would work** — show that to the cashier rather than retrying silently.
- A repeat with the same `idempotencyKey` returns `success: true, duplicate: true` and the unchanged state. Never a double charge.
- Pass `recordId` instead of `code` for a voucher created moments ago — code lookup goes through a search index that lags writes by seconds.

That is our own scanner, and it is a plain HTTP client of the endpoints above — the screen your till is replacing, or sitting beside. The venue name and capability chips along the top are the authority model made visible: this device may redeem here because the venue record says so, and the server re-checks that on the call rather than trusting what the screen believes.
### Scan a member card, identify, award points
```http
POST /hs/serverless/keyring-resolve-token
{ "token": "", "staffToken": "" }
```
```json
{
"success": true, "valid": true, "tokenClass": "member",
"expiresAt": 1817818040684,
"identified": false,
"contact": { "id": "236761208998", "firstName": "Sarah", "lastName": "Johnson" },
"memberships": [
{ "membershipId": "…", "programId": "…", "programName": "Rewards Club",
"pointsName": "Points", "accentColor": "#2680c2",
"pointsBalance": 292, "tier": null, "joinedDate": "2026-07-21" }
],
"incentives": [
{ "recordId": "…", "code": "NA3J43C2AEBB", "redemptionType": "balance",
"currentBalance": 37.5, "currency": "EUR", "status": "active",
"engagementStatus": "viewed", "claimedAt": null, "redeemedAt": null }
],
"ledger": [
{ "transactionId": "…", "label": "Earn +42 Points · Rewards Club",
"reason": "earn", "pointsDelta": 42, "valueDelta": null,
"occurredAt": 1786282058164, "voided": false }
],
"pointsEarnedLifetime": 292
}
```
The response carries **name only** — never email or phone. That is deliberate data minimisation on a device that stands on a counter, and it is not a gap to work around.
Two things about this response cost time if you meet them by surprise:
- **`identified` is not "did we recognise them".** It reports whether an identification was *recorded* against a venue, which happens only when you send a `staffToken` and that venue holds the `identify` capability. Reading a member card without one returns `identified: false` alongside a perfectly valid contact — check `valid` and `contact`, never `identified`, to decide whether you know who this is.
- **The casing changes between endpoints.** This response is camelCase (`currentBalance`, `redemptionType`, `pointsBalance`) while `keyring-fetch-incentive` returns the CRM property names in snake_case (`current_balance`, `redemption_type`). The two describe the same instrument. That is a wart rather than a design, and it is not going to change under you — but map both explicitly rather than assuming one shape.
```http
POST /hs/serverless/keyring-transact
{
"action": "award",
"staffToken": "",
"idempotencyKey": "",
"contactId": "",
"programId": "",
"amount": 87.40
}
```
`amount` is the spend, converted by the programme's earn rate. Send `points` instead for a literal delta — which may be negative for a correction, and may never be zero.
## Idempotency
Every `keyring-transact` call requires `idempotencyKey`, or it is refused with `IDEMPOTENCY_KEY_REQUIRED`. The key is the identity of the *operation*:
- Generate one UUID per business operation — per sale line, not per HTTP attempt.
- **Persist it before you send**, alongside the sale, and reuse it for every retry of that operation.
- Treat `duplicate: true` as success. It is: the state it returns is authoritative.
The failure this prevents is the ordinary one. A till sends a redeem, the network times out after the server committed, the till retries. Without a stable key that is two redemptions and an angry customer.
## A reference client
Every integration writes this, and the parts that get written wrong are always the same three: re-minting on a 401, generating the idempotency key in the wrong place, and retrying a network failure as though it were a failed redemption. Here it is, in about fifty lines, with those three done properly.
```js
// keyring.js — minimal reference client. No dependencies; Node 18+ or a browser.
export class Keyring {
// `store` persists idempotency keys across a crash — a Map is fine for a demo,
// but a real till needs the key on disk BEFORE the request goes out.
constructor({ baseUrl, email, locationId, pin, store = new Map() }) {
Object.assign(this, { baseUrl, email, locationId, pin, store });
this.token = null;
}
async #post(path, body) {
const res = await fetch(`${this.baseUrl}/hs/serverless/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, data: await res.json() };
}
async signIn() {
const { data } = await this.#post('keyring-staff-session', {
email: this.email, locationId: this.locationId, pin: this.pin,
});
if (!data.success) throw new Error(`sign-in refused: ${data.code}`);
this.token = data.token;
}
// One re-mint, then give up. Re-minting on every call defeats the point of a
// token; never re-minting turns a routine expiry into a dead till.
async #authed(path, body) {
if (!this.token) await this.signIn();
let r = await this.#post(path, { ...body, staffToken: this.token });
if (r.status === 401 && /STAFF_TOKEN_(INVALID|REQUIRED)/.test(r.data.code || '')) {
await this.signIn();
r = await this.#post(path, { ...body, staffToken: this.token });
}
return r;
}
fetchIncentive(code) {
return this.#post('keyring-fetch-incentive', { incentiveId: code });
}
// saleId is YOUR id for the sale line — stable across retries, which is the
// whole contract. Deriving the key from it means a retry cannot double-spend.
async transact(saleId, action, fields) {
let key = this.store.get(saleId);
if (!key) { key = crypto.randomUUID(); this.store.set(saleId, key); } // persist BEFORE sending
const { status, data } = await this.#authed('keyring-transact', {
action, idempotencyKey: key, ...fields,
});
if (data.success) return data; // duplicate:true is success — state is authoritative
if (status === 429) {
await new Promise((r) => setTimeout(r, data.retryAfterMs ?? 1000));
return this.transact(saleId, action, fields); // same key, so this is safe
}
throw Object.assign(new Error(data.error || data.code), { code: data.code, data });
}
}
```
Using it, with the failure branches a cashier actually meets:
```js
const kr = new Keyring({
baseUrl: 'https://www.example.com',
email: 'pos-frontdesk@venue.example',
locationId: '',
pin: process.env.KEYRING_PIN,
});
const { incentive } = await kr.fetchIncentive(scannedCode); // show the cashier first
try {
const result = await kr.transact(sale.lineId, 'redeem', { code: scannedCode, amount: 12.5 });
applyDiscountOnTill(result); // your side, after Keyring said yes
} catch (e) {
switch (e.code) {
case 'INSUFFICIENT_BALANCE': offerPartial(e.data.currentBalance); break;
case 'INVALID_LOCATION': showValidVenues(e.data.validLocations); break;
case 'ALREADY_REDEEMED':
case 'EXPIRED': tellCashierTerminal(e.code); break;
case 'CAPABILITY_MISSING': tellCashierNotPermitted(); break;
default: tellCashierGeneric(e.code);
}
}
```
> [!WARNING]
> **A thrown network error is not a failed redemption.** The one branch this sketch leaves to you is a timeout or a dropped connection, because the right behaviour is not generic: retry `transact` with the *same* `saleId`, and read what comes back. The server may already have committed, and the idempotency key is what lets you find out safely instead of guessing.
## Machine-readable contract
The same surface as an OpenAPI 3.1 document, for generating a client or pointing an agent at:
**[`/docs/keyring-openapi.json`](/docs/keyring-openapi.json)**
Read what it does and does not promise. The **endpoint inventory is generated** from the app's own deployment and policy manifests, so it cannot list something that is not deployed or omit something that is. The **request and response shapes are hand-written**, carry this page's `verified` date, and exist for the endpoints documented above — every other path is marked `x-shape-documented: false` rather than being given an invented schema. A generated client that types those as `unknown` is telling you the truth.
Authentication appears in the request schemas rather than as a security scheme, because a staff token is a body field rather than a header.
## Errors, and how a till should behave
Every failure carries a `code`. These are the ones a till must handle by name rather than by showing a generic message:
| Code | HTTP | What the till should do |
|---|---|---|
| `STAFF_TOKEN_REQUIRED` / `STAFF_TOKEN_INVALID` | 401 | Re-mint the token, retry the call once |
| `RATE_LIMITED` | 429 | Wait `retryAfterMs` and retry. Do not tighten the loop |
| `INSUFFICIENT_BALANCE` | 409 | Show `currentBalance` and offer to spend that instead |
| `ALREADY_REDEEMED` / `ALREADY_VOIDED` / `EXPIRED` | 409 | Terminal. Tell the cashier; do not retry |
| `INVALID_LOCATION` | 409 | Show the returned `validLocations`. Do not retry elsewhere |
| `CAPABILITY_MISSING` | 403 | This till is not permitted to do this. An operator decision, not a bug |
| `VOID_NOT_ALLOWED` | 403 | Needs a manager. Surface it as such |
| `NOT_FOUND` | 404 | The code does not exist. Check for a scanner mis-read before blaming the customer |
| `NOT_BOOTSTRAPPED` | 503 | The app is not initialised on this portal. Stop and call the operator |
Also returned and worth handling generically: `NOT_REDEEMABLE`, `INVALID_AMOUNT`, `INVALID_POINTS`, `TEMPLATE_NOT_FOUND` (which returns the available keys), `CONTACT_NOT_FOUND`, `PROGRAM_NOT_FOUND`, `MISSING_PARAMETER`, `INVALID_ACTION`.
**Rate limit:** mutations are limited per staff identity — 30 per minute by default. One token per till keeps normal counter traffic far below it; a token shared across a whole site does not.
## Rules for integrators
- **Do not cache voucher state across sales.** Balances move. Re-read at scan time; the read endpoint is unauthenticated and cheap.
- **Do not try to enrich the identity response.** Name-only is the contract, not an oversight.
- **Do not put the staff token anywhere it can be read** — logs, receipts, query strings, crash reports.
- **Do not treat a network failure as a failed redemption.** Retry with the same key and read the answer; the server may already have committed.
## What does not exist yet
Say these out loud before designing around them.
- **No per-integrator credential.** There is no API key, OAuth client or scoped service account. The staff-token lane is the supported path and its authority model is real — per-venue capabilities, per-identity rate limiting, rotation — but the credential is an install-level PIN plus a contact, not a grant issued to you. Revoking one integrator means rotating the secret for every till on the portal. A designed replacement exists on the roadmap; it should be built before a real third party is onboarded, and we will say so rather than pretend otherwise.
- **No webhooks or event push.** Keyring never calls you. If you want activity, poll `keyring-reports` or `keyring-staff-home`.
- **No per-token revoke.** Rotation is all-or-nothing.
- **No self-service sandbox.** Test against a demo portal arranged with the operator. Never develop against a live venue.
## Change policy
- **The endpoint index above is generated from the running app's own deployment manifests.** If an endpoint is listed, it is deployed; if it is deployed, it is listed. That is a structural guarantee, not a promise to keep a table updated.
- **Request and response bodies are hand-written and carry a `verified` date** (in this file's frontmatter). Check it. If it is more than a few months old, confirm the specific field you depend on before shipping.
- **Additive changes** — new optional fields, new error codes, new endpoints — should be expected and must not break your client. Parse defensively and treat an unrecognised `code` as a generic failure rather than crashing.
- **Breaking changes are communicated, not discovered.** There is no version prefix on these paths, so an integration that matters should be registered with us so it can be told. Send it to [w@reus.ie](mailto:w@reus.ie).
## Onboarding checklist
1. **Operator:** create the till's staff contact, confirm the venue's capabilities are exactly what that till may do, and set the PIN.
2. **You:** implement the mint → 401 → re-mint loop, the scan parsing rule, idempotency-key persistence, and the error table above.
3. **Prove it on a demo portal**, in this order: read a voucher · redeem it · replay the same key and confirm nothing changed twice · spend part of a balance · attempt a redeem at the wrong venue and confirm it refuses · void a transaction · rotate the secret and confirm the till re-mints and carries on.
4. **Only then** point at a real install.
Step 3 is the whole test plan. Every line of it is a failure that has bitten someone, and the two most commonly skipped — the replay and the rotation — are the two that surface in production rather than in testing.
# Keyring — install and operate
> Load this file when: installing, configuring, verifying or handing over **Keyring** on a HubSpot portal — prerequisites, what the install provisions, the venue and staff decisions the customer owns, the manual steps that cannot be automated, and how to prove the whole chain works. What Keyring is: [keyring-reference.md](keyring-reference.md). Integrating a till against it: [keyring-integration.md](keyring-integration.md).
> 🔒 **Works by Design system — not a HubSpot platform feature.** Keyring is our HubSpot app; the steps below are its install, not a HubSpot procedure. Portable regardless: the capability-per-venue model, treating a "settings look complete" state as unverified, and the reinstall-survivability questions. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md)
## Before you start
| You need | Why |
|---|---|
| **A HubSpot portal with custom objects** | Keyring's entire data model is custom CRM objects. Without that entitlement there is nothing to install into |
| **Super admin on that portal** | The install grants scopes, and several steps happen in Design Manager and the workflow editor |
| **A published website domain on the portal** | The customer wallet and the staff scanner are pages on the portal's own site. The wallet URL is derived from the primary site domain, not configured |
| **Marketing email, if you want the loyalty card delivered** | The loyalty-card email uses HubSpot's programmable email, which has to be switched on per module |
| **Workflows, if you want automated enrolment, expiry or tiers** | The workflow actions are optional but are how most of the lifecycle runs |
> [!IMPORTANT]
> **Before a real-data deployment, ask for the hardening pass.** Keyring today carries deliberate demo-grade trade-offs — most consequentially, the installer endpoint is deliberately open so the app can bootstrap itself on a fresh portal, and there is no per-till revocation. Both are closable, and there is a defined checklist for doing it, but neither closes by itself. This is a real prerequisite, and we would rather say so here than discover we agreed otherwise later.
## Who does what
| Step | Who |
|---|---|
| Deploying the app to the portal and granting scopes | Us, with a portal admin present |
| Running the one-call bootstrap that creates the schema, settings and standard pages | Us |
| Deciding the venues, their capabilities, and the programmes | **You** — see below. These are business decisions with security consequences |
| Creating staff identities and setting the PIN | **You** |
| Enabling the email module and switching the lifecycle workflows on | Either, but a portal admin must approve — they mutate live records |
| Verifying the chain end to end | Both, together. See [Verifying it works](#verifying-it-works) |
## What the install provisions
The bootstrap is one idempotent call. Running it twice changes nothing, and it returns no secrets. It creates:
- **The custom objects** — venues, programmes, memberships, the transaction ledger — and extends the incentive object with the value and journey property layers. On a portal that has never had one, the incentive object is created outright.
- **Three contact properties** — the magic link, the QR image, and a locale hint.
- **The association labels.** These matter: an incentive relates to a venue in two different ways, so the labels are the contract, not decoration.
- **The settings records**, seeded with defaults. Secrets are seeded empty, never with a value from code.
- **The standard pages** for the wallet, the scanner and enrolment, when asked to.
- **Two lifecycle workflows — created switched off**, when asked to. They are voucher expiry and tier promotion, and they are disabled deliberately so that an install never silently mutates existing records.
## The decisions only you can make
### Venues and capabilities
This is the most consequential configuration in the app, and it is the one most often waved through.
Every venue record carries **capabilities**: whether staff signed in there may `identify`, `redeem`, `earn` or `issue`. Every mutation re-checks that list **on the server**, against the venue record — so the toggles in the staff app are cosmetic and a compromised or misconfigured device cannot exceed what its venue permits.
Give each venue the narrowest set that lets it work. A bar that only accepts vouchers gets `redeem` and nothing else. The one to think hardest about is `issue`: it is the capability that lets a counter create value out of nothing, and it belongs at a small number of supervised venues.
An online checkout integrating with Keyring is a venue too — give it `redeem` only.
### Staff identities and the PIN
Staff sign in as contacts on the portal. A shared till is a contact of its own — a machine identity, named for what it is. That is what gives the ledger per-till attribution.
Set the PIN. There is a setting that additionally refuses staff sign-in while no PIN is configured; on any install that matters, turn it on so the safe state is the default rather than the diligent one.
### Programmes
A programme defines what a point is called, what it is worth per unit spent, and whether it applies across the brand or at named venues. Get the earn rate right before anyone earns anything — changing it later does not retro-fit balances, and it should not.
## The manual steps
These genuinely cannot be automated, in this order:
1. **Install the app and grant the scopes.** Confirm afterwards that the portal reports the app installed with **no outstanding scope changes**. Until it does, anything needing a newly added scope fails in ways that look like unrelated bugs — automation, page provisioning and the wallet URL each depend on one.
2. **Enable the loyalty email module for programmable email.** In Design Manager, open the Keyring loyalty-card module, tick the programmable-email option in the inspector, and publish. The balances block resolves only when this is on — and only in the **email send preview** for a specific contact, never in the Design Manager editor preview. That difference has cost more time than the step itself.
3. **Wire the magic-link workflow action.** Once the app is installed with automation, the action appears in the workflow picker. The usual shape is: enrol → mint the member link → send the loyalty email. The action writes the link and QR onto the contact; the email reads them from there.
4. **Review and switch on the lifecycle workflows** — voucher expiry and tier promotion. Read what each one enrols before enabling it.
5. **Decide the QR image provider** — a data-flow decision, not just a rendering one; see [Security posture](#security-posture) before accepting the default. The install points it at our hosted generator. If you change it to any other host, that host must also be added to the app's image allow-list and the app re-uploaded, or the QR renders as a blocked image inside HubSpot's extension sandbox — the wallet and scanner pages are ordinary web pages and are unaffected, which is why this fails in only one of the two places.
6. **Paste the label-printer token**, if physical loyalty cards are being printed. It is seeded empty and never lives in code. The settings page has a test print.
7. **Lock the installer**, if this portal holds anything real. Once bootstrap has run, adding the install-key secret makes the installer endpoint require it. The gate enforces itself as soon as the secret exists.
## What the app can reach
The scopes it requests, in full, because a reviewer should not have to take "the usual ones" on trust:
| Scope | Why |
|---|---|
| `crm.objects.contacts.read` · `.write` | Members and staff are contacts. Write covers enrolment, and the three added properties for the loyalty card |
| `crm.objects.custom.read` · `.write` | Venues, programmes, memberships, the ledger and incentives are all custom objects |
| `crm.schemas.custom.read` · `.write` | Only used at install, to create those objects and their properties. Without it the app cannot provision itself |
| `crm.objects.products.read` | Product vouchers resolve against your product catalogue. Read only |
| `automation` | The workflow actions — mint a card, enrol, issue a voucher, award points. Drop this and the lifecycle automation is unavailable; nothing else breaks |
| `content` | Provisioning the wallet, scanner and enrolment pages at install |
| `cms.domains.read` | Reading the portal's primary domain, which is how the wallet URL is derived rather than configured |
| `e-commerce` | Product and pricing reads behind product vouchers |
| `oauth` | The install itself |
Two things a reviewer usually asks next. **There is no `crm.objects.deals`, no `tickets`, no email or marketing scope** — Keyring has no reason to read your pipeline or your campaigns, and does not. And **adding a scope later requires a reinstall**; until that happens the affected functions fail rather than degrade, which is why the install step insists on confirming there are no outstanding scope changes.
## Security posture
The shareable summary, for a reviewer who has to sign this off:
- **Customer data stays on your portal.** Keyring's records are CRM records in your own HubSpot account. We do not operate a database holding your customers.
- **One thing does leave the portal, and you should decide about it deliberately: QR image rendering.** A loyalty-card QR is an `
` in an email, and HubSpot's serverless functions return JSON only — they cannot serve an image — so the image URL points at an external renderer. The URL handed to that renderer **contains the member's wallet link, and therefore their token**. The install points this at our own hosted renderer; the built-in fallback is a public third-party QR service. Neither stores anything, but both *see* the link. If that is not acceptable to your review, point the setting at a renderer you host — it is a URL template, and swapping it needs no redeploy on our side. This is the only outbound data flow in the app, and it is worth being explicit about rather than letting it be found.
- **Tokens are stateless and signed**, from a secret held in your portal's settings. Verification is a signature check with no database lookup; the signature is verified before the payload is parsed, so a tampered token never reaches a contact lookup.
- **Identity responses are data-minimised.** Scanning a member card returns a name — never an email address or a phone number. That is enforced server-side, not a UI choice.
- **Authority is per venue and re-checked server-side** on every mutation.
- **The ledger is append-only.** Reversals write a compensating row; nothing is edited away.
- **Rate limiting is per staff identity**, which is why one token per till matters.
- **Secrets in settings are encrypted at rest** and are never returned by a read.
- **Revocation is secret rotation**, and it is all-or-nothing — every token on the portal dies at once. This is the most significant open item; per-client credentials are a designed, not-yet-built replacement.
The full trade-off analysis, including the ones we have deliberately accepted for demonstration portals and exactly how each is tightened, is not published — it is effectively a map of where to push. Ask for it as part of a deployment review and we will walk it through.
## Verifying it works
A green settings page is not evidence. Neither is a successful deploy. In order:
1. **Ask the app for its own status.** `GET /hs/serverless/keyring-status` on the portal's domain should report bootstrapped, with the schema present and the QR provider you expect. Anything else stops here.
2. **Load the wallet page and the scanner page** in a browser. A blank page means the module was provisioned but not wired — a page that renders an empty shell is a different fault from one that 404s, and worth telling apart before escalating.
3. **Mint a staff session** for a real venue, with the PIN. A successful mint is the first proof the secret, the venue record and the staff contact all exist and agree — which is exactly what a "complete-looking" settings page does not tell you.
4. **Scan and redeem a test voucher** at that venue. Then send the *same* request again with the same idempotency key and confirm the balance does not move twice.
5. **Attempt a redeem at a venue that lacks the capability** and confirm it is refused. If it succeeds, the capability model is not doing its job and nothing above it can be trusted.
6. **Send the loyalty email to a test contact** through the send preview and confirm the balances block and the QR both render.
7. **Scan the QR from that email** with the staff scanner and confirm it identifies the contact.
Steps 4, 5 and 7 are the ones that get skipped, and they are the three that prove the system rather than the deployment.
## Reinstalling — what survives
- **Uninstalling or reinstalling the app deletes no CRM data.** The objects, settings records and existing vouchers survive; the bootstrap carries upgrade logic for schema changes.
- **A scope change requires a reinstall**, and until it completes the affected functions fail rather than degrade.
- **Configuration that lives outside the app's own settings does not automatically come back.** Before any reinstall, record the current configuration — provider selections, secrets you pasted in, and any per-portal values. A shared secret that only ever existed in a settings record is unrecoverable if the record is lost, and there is no way to re-derive it.
- **CRM cards only reflect a new build after the reinstall picks it up.** A card showing old behaviour after a deploy is usually this, not a caching problem.
## When something misbehaves
The failures that have actually occurred during installs on our own portals:
| Symptom | Cause | Fix |
|---|---|---|
| Every value endpoint returns `503 NOT_BOOTSTRAPPED` | The one-call bootstrap has not run on this portal | Run it |
| Wallet or scanner page renders blank | The page exists but is not bound to the module | Re-provision the page; a blank render and a 404 are different faults |
| QR image is blocked inside a HubSpot CRM card, but fine on the website | The QR host is not on the app's image allow-list | Add the host and re-upload the app |
| The loyalty email's balances block is empty | Programmable email is not enabled on the module, or you are previewing in the editor rather than the send preview | Enable and publish the module; preview as a specific contact |
| The Keyring workflow actions are missing from the picker | The automation scope was added after the last install | Reinstall and confirm no outstanding scope changes |
| Staff sign-in is refused although the details are right | A PIN is configured and not being sent, or sign-in is blocked because no PIN is set | Check both settings — they fail in opposite directions |
| A redeem is refused at a venue that should work | Either the venue lacks the capability, or the voucher is restricted to named venues | The refusal names the venues where it would work — read it |
| Configuration looks complete but every token mint fails | A setting names a record that has since been archived. Storing an id does not check the record still exists | Re-point the setting and use the connection test, which mints a real session rather than checking for non-empty fields |
That last row is worth generalising past this app: **a settings screen that validates presence rather than reachability will report healthy right up to the first real request.** Test by doing the thing, not by checking that the box is filled.
## Removing it
- Archive the venue records to stop staff signing in at them — capability checks fail closed.
- Rotate the secret to invalidate every outstanding token immediately, including any till's.
- Uninstalling the app removes the surfaces and leaves the CRM data. If the data must go too, that is a separate deliberate deletion, and worth deciding consciously — a transaction ledger is often the thing a business most wants to keep.
# Docs MCP — setup & access
Connect your AI assistant — the Claude app, Claude Code or Codex — to the Works by Design HubSpot documentation, so it builds the way HubSpot is actually built. Setup takes about a minute. Access is limited to approved email addresses. For the overview, see the [Docs MCP page](/mcp).
## Overview
The Docs MCP server connects your AI assistant to the Works by Design HubSpot technical documentation — hundreds of hours of real build and troubleshooting experience, written up as it was solved. Once connected, your assistant searches and reads it live while helping you: ask for a **UI extension, a CMS module, a quote template or a complete HubSpot app** and it builds from patterns proven to work, or point it at existing code and it troubleshoots against current best practice — instead of guessing from general training knowledge.
It works whether you develop on HubSpot daily or you're describing your first build in plain English: you ask, your assistant does the reading. The connection uses the **Model Context Protocol (MCP)** and works with the **Claude app** (web and desktop), **Claude Code** and **Codex**.
**Endpoint:** `https://mcp.worksby.design/` — streamable HTTP transport (the legacy `https://docs-mcp.worksby.design/` address keeps working). The connection is **read-only**.
**How access works:** you add the server to your AI client, and the client walks you through a one-time **OAuth sign-in** in the browser — you confirm your approved work email and the client stores its own credential. There is no key to copy or paste. Access is limited to **approved email addresses**; if yours isn't approved yet, [get in touch](/contact).
**What you can read:** every sign-in grants the HubSpot technical documentation. Additional documentation sets (business & sales-engineering context under `business/`, server infrastructure under `webserver/`) are granted per person — `docs_list` shows exactly what yours includes.
> [!TIP]
> **For AI agents:** for any HubSpot question, call `docs_get` with `README.md` first — it's a routing index with a one-line "Load when…" note per file — then `docs_get` the files it points to. Use `docs_search` only as a fallback when the index doesn't clearly identify the right file.
## Connect — Claude web & desktop app
The simplest route: **no terminal, nothing to install**. Same on Windows and macOS, and in the browser.
1. In Claude, open **Settings → Connectors**.
2. Look for **worksbydesign** in the list. If it's there, click **Connect** and skip to the sign-in below.
3. Otherwise click **Add custom connector**, give it any name you'll recognise (`worksbydesign` works), and paste this as the server URL:
```text
https://mcp.worksby.design/
```
4. Leave **OAuth Client ID** and **Client Secret** empty — the connector registers itself. Click **Connect** to start the [browser sign-in](#signing-in).
> [!NOTE]
> **No "Add custom connector" button, and no worksbydesign connector listed?** That isn't you missing it. On a managed or company Claude plan an administrator can disable custom connectors, and the option simply isn't shown. Ask whoever manages your Claude workspace to allow custom connectors — or use **Claude Code** below, which is unaffected.
## Connect — Claude Code
The standard path is the **plugin**: two commands typed **into Claude Code itself** (the chat input — VS Code panel, desktop app, or the terminal app), no system terminal needed. It installs the documentation server *and* the general-purpose skills in one go:
```text
/plugin marketplace add https://worksby.design/git/plugins.git
/plugin install toolkit@worksby-design
```
When Claude Code asks you to authenticate `worksbydesign` (it prompts on first use — or run `/mcp` and choose **Authenticate**), that opens the browser sign-in described [below](#signing-in). One setup covers **VS Code**, the **desktop app's Claude Code** and the terminal — they share one configuration.
**Then turn on auto-update:** `/plugin` → **Marketplaces** → **worksby-design** → **Enable auto-update**. Claude Code leaves this off for marketplaces outside Anthropic's own; without it, new skills we publish never reach you.
**toolkit** carries the universal skills — starting with **quote-builder** (customer quote PDF → live HubSpot quote), which works with the standard technical access. **Doing presales demos?** If your access includes the **business-context** documentation, also run `/plugin install toolkit-demos@worksby-design` for the four demo skills (demo pages, workspaces, customer context, brand capture) — without that grant they refuse politely, so only install what your access covers.
### Alternative — documentation only, no plugin
Run this in a **normal terminal** — PowerShell or Windows Terminal on Windows, Terminal on macOS. **Not** inside a Claude Code session:
```bash
claude mcp add --scope user --transport http worksbydesign https://mcp.worksby.design/
```
**Keep `--scope user`.** Without it Claude Code defaults to `local` scope and registers the server *only for the folder you ran the command in* — it then silently disappears when you open any other project. This is the single most common setup mistake.
**Adding is not signing in.** The command only registers the address. To finish, open Claude Code, run `/mcp`, and choose **Authenticate** next to `worksbydesign`. It takes effect immediately — confirm with `claude mcp get worksbydesign` (**✓ Connected**).
**`claude` not found?** On **macOS**, open Claude Code and run `/install-cli`, then close and reopen your terminal. On **Windows**, the CLI is installed with Claude Code itself — reopen the terminal so it picks up `PATH`. Check with `claude --version`.
**Had the manual entry before installing the plugin?** No conflict — both point at the same address and Claude Code connects once; your existing sign-in keeps working.
## Connect — Codex
Add the server, then sign in:
```bash
codex mcp add worksbydesign --url https://mcp.worksby.design/
codex mcp login worksbydesign
```
`codex mcp login` opens the same browser sign-in described [below](#signing-in).
Codex reads the same documentation as Claude Code. The optional **demo-builder toolkit** — branded demo pages, run sheets and workspaces — is a **Claude Code plugin** with no Codex equivalent; on Codex you point the assistant at the demo-methodology specs directly (`business/skills/specs/`), when your grant includes them.
## Signing in
The first time your client connects, it opens a Works by Design sign-in page in your browser. The flow is the same for every client:
1. Enter your approved work email. If it's approved, we email you a one-time link (valid 15 minutes); if it isn't, the page tells you straight away rather than leaving you waiting.
2. Click the link and press **Complete sign-in** on the page that opens. (The extra click is deliberate — it stops corporate email scanners from using up the one-time link before you do.) Your client stores its own per-install credential, so **there is no key to copy**.
3. A **"You're connected"** page appears with one final copy-paste, tailored to your access: paste it to your assistant and ask it to remember it permanently. That is what makes the documentation — and new skills — load from the start of every session. Then click **Finish** to return to your client.
Access is **per person and per install**: signing in again from the same client replaces that client's previous install, and each is logged under your address. Nothing is shown on screen to copy or share, and access can be **revoked at any time**.
## First message to your assistant
The **"You're connected"** page at the end of sign-in shows this same message tailored to your exact access — use that one if you can. Skipped it? Paste the version below once. It asks your assistant to store the guidance durably — Claude Code in `~/.claude/CLAUDE.md`, Codex in `~/.codex/AGENTS.md` — so it governs every future session instead of just the current chat.
```text
Persist the following idempotently as global startup guidance in your supported
durable-instruction mechanism. For Claude Code, add or update it in
~/.claude/CLAUDE.md. For Codex, add or update it in ~/.codex/AGENTS.md. Do not
rely only on this conversation or on generated memory, and do not create
duplicate entries. Ensure the guidance is active in every new session and
remains governing across context compaction. If you cannot store or verify
persistent instructions, tell me exactly what I need to configure. After saving
it, confirm where it was stored.
You now have the "worksbydesign" MCP server, the authoritative Works by Design
HubSpot technical documentation.
Before answering any HubSpot question, call docs_get with "README.md" and use
its "Load when…" index to select the relevant documentation. Read each selected
file with docs_get. Use docs_search only when the index does not clearly identify
the right file or when locating a specific term. Ground your answer in the
retrieved documentation and cite its file paths.
If a file contains a verifiably incorrect or outdated statement, an important
omission, or a broken link, submit concise evidence through docs_feedback for
human review. Do not submit questions, opinions, duplicate reports, or
speculative feedback.
```
## Available tools
The server publishes four tools. Three are read-only; the fourth submits feedback to a review queue and never edits the documentation.
| Tool | Type | What it does |
|---|---|---|
| `docs_list` | read | List every documentation file your grant can read (path + title + hub). The starting point; the technical index is `README.md`, other granted hubs index at `/README.md`. |
| `docs_search` | read | Keyword search across the documentation. Returns ranked results with snippets. |
| `docs_get` | read | Return the full Markdown of one file by path. |
| `docs_feedback` | queue | Report a *verified* error or gap in the docs. Submissions are queued for human review and never modify the documentation directly. |
## Security & data
- **Approved email addresses only.** Access is granted per person and can be revoked immediately.
- **Read-only.** The server can read documentation (and accept feedback) — nothing else. It has no access to any CRM, system, or customer data.
- **Per-install OAuth, no shared keys.** Each client holds its own short-lived credential that renews itself and rotates on every renewal — there is no long-lived key to leak, and reusing a rotated credential (the signature of a shared copy) revokes it automatically.
- **Audited.** Every request is logged with the requester and a timestamp.
- **No footprint.** Nothing is installed beyond the one-line client setup above.
> [!NOTE]
> Access is personal and per install. If a teammate leaves or an install should no longer have access, [let us know](/contact) and we'll revoke it — it stops working immediately.