# worksby.design — full documentation > Every published page from https://worksby.design/docs/ concatenated in reading > order. Per-page index with descriptions: https://worksby.design/llms.txt # Build custom HubSpot CPQ quote modules: zero to hero > Load this file when you need to understand, build, test, or troubleshoot coded modules for HubSpot CPQ quotes. It covers the complete journey from the runtime model to interactive, PDF-safe, production-ready modules. > > Two copies of this file exist and must stay identical: `guides/cpq-quote-modules-zero-to-hero.md` in the Works by Design technical hub, and `docs/cpq-quote-modules-zero-to-hero.md` inside the Quote Module Collection download. Edit both or neither. HubSpot's current CPQ quote system is not the old HubL quote-template system with a new name. It uses React-based coded modules that HubSpot renders inside a controlled quote layout. If you already understand coded email templates, website themes, or drag-and-drop CMS modules, some ideas will feel familiar: developers create the building blocks and marketers or sales teams arrange and configure them. The implementation model, however, is different enough that treating it like a normal page template causes avoidable problems. This guide explains that implementation model, shows the patterns behind complex quotes, and identifies the places where current platform behavior requires care. You can follow the guide with your own project or use the free [Works by Design Quote Module Collection](https://worksby.design/downloads/quote-module-collection-latest.zip) as a working reference. The collection is an example implementation, not a required framework. ## What this guide adds to HubSpot's quickstart HubSpot's [Create custom quote modules](https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/quotes/create-quote-modules) page is the authoritative quickstart. It gets one example module uploaded and explains the essential exports, targeted data, Islands, editor rendering, and print mode. This guide starts there and adds what developers need for a complete buyer-facing implementation: - an exact first-run path from the starter or downloadable collection; - the relationship between the quote template, module, HubL bridge, SSR component, Island, and serverless function; - real quote-context and line-item patterns; - custom line-item presentation without breaking HubSpot's native editing requirements; - PDF-safe interactive components; - SDK actions, serverless security, and quote lifecycle constraints; - the difference between supported APIs, observed behavior, and Works by Design conventions; - a release matrix for commercial accuracy, accessibility, privacy, and published-quote snapshots. Use HubSpot's quickstart for the shortest official path. Use this guide when the first example works and you need to build the actual quote. ## What you can build A coded CPQ quote can be much more than a price table. A well-designed quote may include: - a branded cover and introduction; - sticky navigation between quote sections; - a completely custom line-item table; - recurring, one-time, and tiered-pricing presentation; - totals, discounts, fees, taxes, and terms; - a mutual action plan or implementation timeline; - team profiles, proof points, FAQs, and resources; - interactive accordions or selectors; - buttons that invoke supported quote acceptance or payment actions; - serverless-backed interactions that safely call HubSpot or third-party APIs; - a PDF layout that remains complete when JavaScript is unavailable. The key is to design for every runtime in which the quote appears: the template editor, module preview, published web quote, and generated PDF. ## 1. Start with the right mental model ### Legacy quotes versus CPQ quotes In a legacy coded quote, the developer commonly owns a HubL template and embeds modules in it. In the current CPQ system, HubSpot owns the outer quote application and drag-and-drop area. You build React modules that are made available to that quote editor. The practical differences are: | Area | Legacy coded quote | Current CPQ coded module | |---|---|---| | Primary rendering | HubL template | React module rendered by HubSpot | | Data access | Template context directly in HubL | HubL data bridge into React props | | Interactivity | Browser JavaScript in the template/module | React Islands and quote SDK hooks | | Layout ownership | Developer-controlled template | HubSpot-controlled quote blueprint and module area | | Module placement | Template-defined or drag-and-drop | Quote template editor | | PDF strategy | Template print CSS | SSR-safe markup plus print CSS and PDF detection | Do not begin by trying to recreate the entire quote application. Begin by deciding which sections should be separate modules, which data each module needs, and what the PDF must contain. ### The four layers of a quote module Every useful quote module has four conceptual layers: 1. **Configuration** — fields the editor can change. 2. **Data bridge** — HubL that selects quote data and passes it to React. 3. **Presentation** — the React component and its styles. 4. **Interaction** — optional client-side Islands, SDK actions, or serverless calls. Keeping those layers separate makes the module easier to test and reduces accidental exposure of quote data. ## 2. Prerequisites and project shape You need: - a HubSpot account with **Revenue Hub Professional or Enterprise**; - the current HubSpot CLI installed and authenticated; - Node.js and npm; - working knowledge of React and TypeScript; - a test account and non-production quote data. At the time of writing, existing HubSpot CLI scaffolding commands do **not** create a quote-module project. Start from HubSpot's [`quote-dev-starter`](https://github.com/HubSpot/quote-dev-starter) or from the [Works by Design Quote Module Collection](https://worksby.design/downloads/quote-module-collection-latest.zip). Do not create a normal CMS React project and assume that adding `QUOTE` metadata will turn it into the same project. ### Choose a starting path **Learning or building one module:** download HubSpot's starter, run `npm install` from its root, and change the example incrementally. The starter's post-install process installs the nested CMS asset dependencies. ```bash npm install npm start ``` The starter also contains `ConditionalTermsModule` and `RequiredAgreementModule`. Read those after the base example: they demonstrate line-item-driven content and gating quote acceptance. They are valuable examples, but they are not substitutes for understanding PDF output, custom line-item presentation, serverless persistence, and published-quote lifecycle behavior. **Deploying a complete reference set:** download and unzip the Works by Design collection, open a terminal in the extracted project root—the folder containing `hsproject.json`—and run: ```bash hs project upload ``` The first upload prompts you to create/name the HubSpot project. Do not name it `cpq-theme`; HubSpot reserves that name. A successful build makes the modules available to the quote template editor. Install npm dependencies only when developing or rebuilding locally; follow the package scripts in the download because the root and CMS asset bundle have separate dependency boundaries. If your download contains a serverless source that has been changed, run its documented build step before upload so the bundled entrypoint is current. ### The actual project boundaries A current quote-module project separates CMS assets from the optional private app and serverless functions: ```text hsproject.json # project name, srcDir, platformVersion src/ cms-assets/ cms-asset-hsmeta.json my-react-assets/ Globals.d.ts # QuoteTemplateContext declarations package.json tsconfig.json components/ modules/ QuoteIntroduction/ index.tsx # Component, fields, meta, hublDataTemplate islands/ InteractivePanel.tsx shared/ quoteTheme.ts # optional shared presentation tokens app/ # only needed for app/serverless capabilities app-hsmeta.json functions/ quote-action.js quote-action-hsmeta.json ``` The folder names can differ, but the boundaries matter. React quote modules are CMS assets. Serverless functions belong to the app portion of the same developer project. `Globals.d.ts` can use the `QuoteTemplateContext` type from `@hubspot/quote-dev-sdk` to improve compile-time safety. For the current generation of modules, use the platform version required by HubSpot's quote-module documentation and your account. The examples in this guide assume the 2026.03 React module model. Confirm the supported version before starting a new project because platform versions evolve. Useful dependencies may include: ```json { "dependencies": { "@hubspot/cms-components": "latest", "@hubspot/quote-dev-sdk": "latest", "react": "latest" } } ``` Pin versions according to your release process rather than copying `latest` into a long-lived production project without review. ## 3. Build the smallest complete module A quote module normally exports four things: - `Component`: the React component HubSpot renders; - `fields`: the configuration fields shown to the editor; - `meta`: module name and availability metadata; - `hublDataTemplate`: the HubL-to-React data bridge. ```tsx import { ModuleFields, TextField } from '@hubspot/cms-components/fields'; type QuoteIntroductionProps = { fieldValues: { heading?: string; }; hublData?: { buyerName?: string; quoteTitle?: string; }; }; export function Component({ fieldValues, hublData }: QuoteIntroductionProps) { const heading = fieldValues.heading || hublData?.quoteTitle || 'Your proposal'; const buyerName = hublData?.buyerName; return (

{heading}

{buyerName &&

Prepared for {buyerName}

}
); } export const fields = ( ); export const meta = { label: 'Quote introduction', content_types: ['QUOTE', 'QUOTE_BLUEPRINT'], }; export const hublDataTemplate = ` {% if quoteTemplateContext.buyerContacts|length > 0 %} {% set buyer = quoteTemplateContext.buyerContacts[0] %} {% endif %} {% set hublData = { "buyerName": buyer.firstname if buyer else null, "quoteTitle": quoteTemplateContext.quote.hs_title, "isQuoteBlueprint": isQuoteBlueprint, "isInEditor": is_in_editor, "isInPreviewer": is_in_previewer } %} `; ``` This is the minimum useful pattern: the HubL string assigns a value to `hublData`; HubSpot passes that object to `Component`. It does not print a JSON document. The precise context paths available to your account and template can vary. Inspect the actual quote context and add null guards instead of assuming every association exists. ### Make the module available in both contexts For most reusable quote modules, include both content types: ```ts content_types: ['QUOTE', 'QUOTE_BLUEPRINT'] ``` `QUOTE_BLUEPRINT` makes the module available while building a quote template. `QUOTE` covers quote rendering. Omitting one often produces the confusing result that a valid module cannot be found where expected. ## 4. Configuration fields that behave predictably Field values arrive separately from quote data. Keep the distinction explicit: - `fieldValues` are selected by the template editor; - `hublData` is data deliberately passed through `hublDataTemplate`; - runtime props tell you about the current rendering context. Common field components include text, rich text, number, choice, boolean, color, groups, and repeaters. Use editor labels that describe the outcome, not the implementation. ```tsx import { BooleanField, ChoiceField, ColorField, FieldGroup, ModuleFields, NumberField, RichTextField, TextField, } from '@hubspot/cms-components/fields'; export const fields = ( ); ``` ### Normalise values at the boundary Do not assume all editor values have the ideal JavaScript type in every runtime. A boolean-like value may need normalising: ```ts function asBoolean(value: unknown, fallback = false) { if (value === undefined || value === null || value === '') return fallback; if (typeof value === 'string') return value.toLowerCase() === 'true'; return Boolean(value); } ``` Colour fields commonly produce an object, so read its colour value defensively: ```ts const accent = fieldValues.style?.accent?.color || '#ff5c35'; ``` Number-like values passed through HubL may arrive as strings. Convert only when you need arithmetic: ```ts const position = Number.parseInt(String(rawPosition ?? 0), 10); ``` ### Repeaters and advanced fields Repeaters are ideal for FAQs, milestones, resources, proof points, and team members. Confirm the current CMS React fields support before choosing JSX or `fields.json`; not every familiar legacy-module field pattern maps directly to a React field component. Keep defaults small, make empty states intentional, and test item reordering in the editor. See HubSpot's [module and theme fields reference](https://developers.hubspot.com/docs/cms/reference/fields/module-theme-fields) for the current field contract. Never use field data as trusted HTML, URLs, identifiers, or API input without the appropriate escaping and validation. ## 5. The HubL data bridge React modules do not automatically receive the entire quote template context. `hublDataTemplate` is the controlled bridge between HubSpot's server-rendered quote data and your component. That bridge is one of the most important design decisions in the module: - pass only the values the component needs; - serialise values correctly; - guard missing associations and properties; - keep secrets and access tokens out of the output; - prefer simple JSON-shaped data over exposing a large raw object. Choose the narrowest suitable data source: | Source | Use it for | Important constraint | |---|---|---| | `quoteTemplateContext` | Quote, deal, buyer, signer, and line-item values already in the snapshot | Select only the properties the component needs | | `crm_object()` / `crm_associations()` | Missing CRM properties available during server rendering | Guard IDs and request an explicit property list | | HubDB | Managed content such as legal text or regional guidance | Requires the applicable Content Hub subscription | | Serverless function | Secrets, external APIs, live reads, or controlled writes | Browser input is untrusted; validate and authorise server-side | ```tsx export const hublDataTemplate = ` {% set hublData = { "quoteId": quoteTemplateContext.quote.hs_object_id, "quoteTitle": quoteTemplateContext.quote.hs_title, "currency": quoteTemplateContext.quote.hs_currency, "locale": quoteTemplateContext.quote.hs_locale, "dealName": quoteTemplateContext.deal.dealname if quoteTemplateContext.deal else null, "lineItems": quoteTemplateContext.lineItems, "isQuoteBlueprint": isQuoteBlueprint, "isInEditor": is_in_editor, "isInPreviewer": is_in_previewer, "isPdf": true if quoteTemplateContext.quote.hs_pdf_generation_status == "PDF_GENERATING" else false } %} `; ``` The names above are representative of the current typed context and the public collection, but treat `Globals.d.ts` and a real test quote as the source of truth for your project version. If data is not in `quoteTemplateContext`, fetch only the required properties with supported HubL functions such as `crm_object()` or `crm_associations()`. Do not use that as an excuse to fetch an entire CRM record. ### Useful quote-context areas The quote context commonly includes some combination of: - quote properties, including title, status, currency, dates, totals, terms, and identifiers; - an associated deal; - associated contacts or buyers; - associated company details; - quote signers; - line items, pricing, quantities, discounts, billing periods, and positions; - template, brand, language, and rendering-state information. Start discovery with these current top-level paths: | Need | HubL path | Guard/notes | |---|---|---| | Quote properties | `quoteTemplateContext.quote` | Select individual properties such as `hs_title`, `hs_currency`, and `hs_locale` | | Deal | `quoteTemplateContext.deal` | May be null; guard before property access | | Buyer company | `quoteTemplateContext.buyerCompany` | May be null | | Buyer contacts | `quoteTemplateContext.buyerContacts` | Array; check length before choosing the first contact | | Line items | `quoteTemplateContext.lineItems` | Array; `hs_position_on_quote` can arrive as a string | | Signers | `quoteTemplateContext.signers` | Array | | Counter-signers | `quoteTemplateContext.counterSigners` | Array | | Blueprint state | `isQuoteBlueprint` | Use fictional fallbacks only in the template/blueprint | | Editor state | `is_in_editor` | Use to suppress buyer-only interaction | | Previewer state | `is_in_previewer` | Suppress write-capable interaction here too | Use the `QuoteTemplateContext` type for autocomplete, but do not assume the type package enumerates every runtime or portal-specific property. Confirm required custom properties on a real quote. Some custom properties can already be present on context objects; only fall back to `crm_object()`/`crm_associations()` when the value is genuinely absent. Names and availability are not guaranteed merely because a similarly named CRM property exists. Build a temporary debug module during development to inspect the actual data available, then remove or restrict it before release: raw quote context can contain personal, commercial, or internal information. ### Data minimisation is also a performance feature The data bridge becomes part of the rendered module input. Passing the full quote context can increase payload size, make hydration slower, and expose information to browser code that the component never uses. Select the smallest stable data contract. ## 6. Understand every rendering context The same module behaves differently depending on where it is rendered. | Context | Quote data | Editor controls | Browser JavaScript | Main concern | |---|---|---|---|---| | Module preview | Often fallback or incomplete | No | May run | Useful empty/fallback state | | Blueprint/template editor | May use sample data | Yes | Usually | Editing experience | | Published quote | Real quote snapshot | No | Yes | Buyer experience and accessibility | | PDF generation | Real quote snapshot | No | Do not depend on it | Complete static output | Use runtime/editor props exposed by the platform to distinguish authoring controls from buyer-facing content. Never show editor-only actions to a buyer merely because quote data is present. Fallback data is useful in the blueprint editor, but it should be obviously fictional and should not leak into a real quote when an association is missing. Prefer a meaningful empty state such as “Add line items to preview this section” over invented commercial totals. ## 7. React Islands: add interaction without breaking server rendering The module component should produce useful server-rendered HTML. Put browser-only state and effects inside an Island. An Island is a separate file imported with `?island`; a normal locally declared React component is not enough. Create `islands/AccordionIsland.tsx`: ```tsx import { useState } from 'react'; type Item = { title: string; bodyHtml: string }; export default function AccordionIsland({ items }: { items: Item[] }) { const [open, setOpen] = useState>({}); return (
{items.map((item, index) => (
))}
); } ``` Then import it from the module's `index.tsx`: ```tsx import { Island } from '@hubspot/cms-components'; // @ts-expect-error — the HubSpot build handles the ?island suffix import AccordionIsland from './islands/AccordionIsland.tsx?island'; type AccordionItem = { title: string; bodyHtml: string }; type Props = { fieldValues: { heading?: string; items?: AccordionItem[] }; hublData?: { isInEditor?: boolean; isInPreviewer?: boolean; isPdf?: boolean; }; }; export function Component(props: Props) { const items = props.fieldValues.items || []; const { isInEditor, isInPreviewer, isPdf } = props.hublData || {}; // Never allow a write-capable Island to run in editor/previewer. // For a read-only accordion, the static branch also avoids editor reloads. if (isInEditor || isInPreviewer || isPdf) { return (

{props.fieldValues.heading}

{items.map((item) => (

{item.title}

))}
); } return (

{props.fieldValues.heading}

); } ``` For a simple read-only Island, `hydrateOn="visible"` or `"idle"` may reduce initial JavaScript work. Use `"load"` for controls that must work immediately. See HubSpot's [Islands reference](https://developers.hubspot.com/docs/cms/reference/react/islands) for the current import contract and hydration options. ### Island rules worth treating as architecture - Do not access `window`, `document`, `localStorage`, or browser-only APIs during server rendering. - Use `useEffect` for browser-only work. - Pass serialisable props only. - Do not pass access tokens, secrets, or unnecessary private quote data to an Island. - Provide an explicit static/PDF/editor branch for important content. - Give interactive controls accessible names, focus states, and correct ARIA state. - Test hydration errors in the browser console. For cross-module coordination, browser custom events are a simple option when two Islands need to communicate: ```ts window.dispatchEvent(new CustomEvent('quote:options-changed', { detail: { selectedIds }, })); ``` Listeners should be registered and removed inside `useEffect`. Namescape event names and document their payload. Remember that custom events affect the browser experience only; the PDF must not depend on them. ## 8. Design for PDF from the first commit A quote is both a web experience and a document. PDF generation may not execute your client-side interaction as a buyer's browser would. If content matters contractually or commercially, it must exist in the server-rendered markup. ### The three-layer PDF contract The Quote Module Collection uses a robust three-layer pattern for accordions: 1. Detect PDF/print rendering where the platform exposes that state and render all panels expanded. 2. Keep panel bodies in the DOM; toggle visibility rather than conditionally creating the content only after a click. 3. Add print CSS that forces every panel visible. ```css @media print { .accordionPanel { display: block !important; height: auto !important; max-height: none !important; overflow: visible !important; } .accordionToggle { display: none !important; } } ``` Use both of the currently available signals: - HubSpot's public contract adds `?print=true` during print/PDF rendering. Inside an Island, read it with `usePageUrl()` and suppress web-only behavior. - The collection also detects the observed server-side state `quoteTemplateContext.quote.hs_pdf_generation_status == "PDF_GENERATING"` in `hublDataTemplate`, allowing the outer component to choose expanded static markup before hydration. ```tsx import { usePageUrl } from '@hubspot/cms-components'; export default function QuoteNavigationIsland() { const url = usePageUrl(); if (url.searchParams.get('print') === 'true') return null; return ; } ``` Keep the `@media print` fallback even when both signals work. A buyer can print the web page directly, and important document content should not rely on one runtime flag. ### PDF-safe design checklist - All important content exists without a click. - Accordions expand. - Carousels show all essential slides or a deliberate static alternative. - Video has a useful title, thumbnail, and link. - Navigation and editor controls are removed. - Background colours and text remain legible when printed. - Long tables can wrap and continue across pages. - Headings do not become isolated at page bottoms where CSS can prevent it. - URLs remain understandable if the PDF viewer does not preserve the click target. - Totals and legal terms are never produced only by client-side calculations. Always generate an actual PDF during testing. Browser print preview is useful, but it is not proof that HubSpot's PDF renderer will paginate identically. ## 9. Build a custom line-item module correctly A custom line-item section is usually where a complex quote becomes valuable and where mistakes become expensive. **Do not remove HubSpot's native Line items module.** HubSpot currently requires the native module to remain on the quote because it owns line-item editing. Hide it in the presentation when using your custom display. The same limitation applies when replacing the native Parties presentation. A custom module reads and presents the data; it does not automatically replace the native editing capability. ### Do not recompute HubSpot's commercial truth casually Use HubSpot-provided calculated values when available. In particular, display the line item's provided amount rather than assuming that `quantity × unit price` reproduces every discount, billing, or tiered-pricing rule. A resilient presentation model might include: ```ts type QuoteLineItem = { id?: string; name?: string; description?: string; sku?: string; quantity?: string | number; price?: string | number; amount?: string | number; hs_position_on_quote?: string | number; hs_recurring_billing_period?: string; hs_pricing_model?: string; hs_tiered_pricing_table?: unknown; }; ``` Sort positions numerically because the context may serialise them as strings: ```ts const sortedItems = [...items].sort((a, b) => { return Number(a.hs_position_on_quote || 0) - Number(b.hs_position_on_quote || 0); }); ``` Format money with `Intl.NumberFormat` and the quote's currency. Keep the raw numeric value separate from its display string. ```ts function formatMoney(value: unknown, currency: string, locale = 'en-US') { const amount = Number(value); if (!Number.isFinite(amount)) return ''; return new Intl.NumberFormat(locale, { style: 'currency', currency, }).format(amount); } ``` ### Tiered pricing is a presentation problem and a totals problem Tiered-pricing metadata may explain the rate bands, while the line-item `amount` represents HubSpot's calculated result. Show both when useful: - the table of tiers so the buyer understands how pricing works; - HubSpot's amount as the commercial total. Do not replace the provided amount with a browser-side calculation unless you own and test every pricing rule. Validate boundary quantities, multiple currencies, percentage discounts, fixed discounts, and recurring frequencies. ### Conditional content should be derived from stable properties The starter's conditional-terms module is a useful pattern: derive presentation from line-item properties rather than asking the quote author to remember which legal section to enable. ```ts const hasRecurring = items.some((item) => Boolean(item.recurringbillingfrequency)); const hasOneTime = items.some((item) => !item.recurringbillingfrequency); const sections = [ GENERAL_TERMS, ...(hasRecurring ? [SUBSCRIPTION_TERMS] : []), ...(hasOneTime ? [ONE_TIME_TERMS] : []), ]; ``` Use legal-approved content, define the property-to-clause rules outside the rendering loop, and test quotes containing both recurring and one-time items. If the rules depend on country, product family, or custom properties, document the exact precedence and missing-data behavior. ### Filtering and grouping Some implementations use SKU prefixes or custom properties to group items into categories or suppress operational items. That can be effective, but document the convention and make it configurable where possible. A hard-coded prefix becomes hidden business logic if sales teams do not know it exists. ### Direct links into the line-item editor The Quote Module Collection includes editor-only buttons that open line-item editing actions through HubSpot's internal application event bus. This is useful, but it is not a documented public integration contract. Treat this pattern as **observed and unsupported**: - show the buttons only in the editor; - isolate the event-bus adapter in one small file; - feature-detect the required object and action; - fail silently with a normal instructional fallback; - never make quote rendering depend on it; - regression-test it after HubSpot editor releases. If HubSpot publishes a supported editor navigation API, replace the internal adapter. ## 10. Styling and brand architecture Avoid designing each section as an unrelated card. A quote feels more credible when its modules share typography, spacing, colour, borders, and document width. ### Prefer native quote design tokens HubSpot exposes quote-level CSS custom properties in the rendered experience. Use supported native variables where possible, with a fallback: ```css .section { color: var(--hsQuotes--text-color, #213343); background: var(--hsQuotes--background-color, #ffffff); font-family: var(--hsQuotes--font-family, Arial, sans-serif); } .button { background: var(--hsQuotes--primary-color, #ff5c35); } ``` Inspect the current quote output to confirm the variable names HubSpot emits. Do not build the whole theme around an undocumented variable without a fallback. ### Brand settings in quote context In quote rendering, `brand_settings` can be a more useful source than assumptions borrowed from normal website page context. `site_settings` may be empty or incomplete. Pass only the brand values you need through the data bridge, then fall back to module fields and safe defaults. A practical priority order is: 1. an explicit per-module override; 2. quote or brand settings exposed in context; 3. shared quote CSS variables; 4. a conservative default. ### Share tokens, not hidden coupling A shared TypeScript theme file can keep spacing, radii, typography, and colour fallbacks consistent across modules. Do not make a module fail if another visual module is absent. Each section should be independently renderable. ## 11. Navigation and document structure A sticky navigation module can make a long web quote feel like a small microsite. The Quote Module Collection demonstrates DOM discovery: modules expose stable section identifiers and the navigation Island finds the sections that actually exist. If you use this approach: - assign semantic, stable anchors to sections; - do not infer navigation labels from fragile CSS classes; - account for a sticky header when scrolling; - update the active state accessibly; - hide navigation from PDF output; - preserve a logical heading order even when modules are rearranged. The quote should still read correctly from top to bottom without JavaScript or navigation. ## 12. Acceptance, signature, and payment actions The quote development SDK exposes supported hooks for quote actions such as acceptance and payment. Use those hooks instead of constructing private URLs or imitating HubSpot UI. Install the SDK as a runtime dependency when an Island calls its hooks. A useful acknowledgement pattern is: ```tsx // islands/RequiredAgreement.tsx import { useEffect, useState } from 'react'; import { useQuoteAcceptance } from '@hubspot/quote-dev-sdk'; export default function RequiredAgreement({ reason }: { reason: string }) { const { loading, error, data, control } = useQuoteAcceptance(); const [agreed, setAgreed] = useState(false); useEffect(() => { if (loading || error || data?.accepted) return; if (agreed) control.enable(); else control.disable({ reason }); }, [agreed, loading, error, data?.accepted, control, reason]); if (loading) return

Loading acceptance status…

; if (error || !data) return

Acceptance 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": ""` and an appropriate HTTP status code. > [!TIP] > **For AI agents:** the common read/registration flow is `fetch-events` → `fetch-event-details` → `create-registration` (each call requires `portalId`). For anything belonging to a specific member — their ticket, their registrations, check-in — mint a session with `scan-session` first and read [Contact sessions](#contact-sessions--read-this-before-integrating); passing a `contactId` in the body does not identify a caller. To **seed events and sample registrations** on a portal, write the `events` and `event_registrations` custom objects directly via the HubSpot CRM API — see [Creating and managing events](#creating-and-managing-events). ## Authentication There are **five** auth policies, not one. Which applies to a given endpoint is listed in [Endpoint index](#endpoint-index) below, and that table is generated from the same manifest the service's own route-coverage test enforces — so it cannot drift from the running code. | Policy | What the caller must present | Used for | |---|---|---| | **None** | `portalId` only | `license-status`, callable before install | | **Portal + licence** | `portalId`, and the portal must have completed OAuth install with an active licence | Public reads and registration | | **Portal + contact session** | The above **plus** a session token proving a specific `contactId` | Anything returning or mutating a specific member's data | | **Portal + shared secret** | The above plus a configured secret | Sibling-app configuration and payment callbacks | | **HubSpot signature** | A valid HubSpot request signature (v3 preferred, v2 accepted) | The "Send Event Email" workflow action | Every request carries `portalId` — the numeric HubSpot portal ID. For `GET` pass it as a query parameter; for `POST` include it in the JSON body. No API key is ever passed by the caller. The server validates that the portal has completed the OAuth install flow and holds a stored access token. If not, the endpoint returns `403` with `{ "error": "Portal not authorised for events" }`. If the licence has expired it returns `403` with `{ "code": "LICENSE_INACTIVE" }`. ### Contact sessions — read this before integrating Five endpoints require a **proven** contact and will not accept a `contactId` in the body as proof of identity: `check-in-by-qr`, `door-events`, `my-event-registration`, `fetch-user-registrations` and `update-registration`. For the member-scoped three, the session token's contact **overrides** any id you submit, so a caller can only ever read or change their own registrations; the two scanner endpoints use the session to prove *staff*, not to scope data. Mint the token with [`scan-session`](#post-appseventsscan-session). It is an HMAC token valid for **one hour**. > [!IMPORTANT] > Enforcement is **unconditional** on every portal, and has been since 2026-08-12. There is no > per-portal staging, no `securityVersion` branch and no environment flag: a call to any of these > five endpoints without a valid token is a `401`. Earlier versions of this page described a > per-portal fall-open — that is gone, and integrations written against it will break rather than > degrade. ## Endpoint index 30 endpoints — 9 public, 5 member, 13 admin, 3 server-to-server. | Endpoint | Surface | Auth required | |---|---|---| | `GET /apps/events/license-status` | Public | None | | `POST /apps/events/create-registration` | Public | Portal + licence | | `POST /apps/events/event-stats` | Public | Portal + licence | | `POST /apps/events/fetch-event-details` | Public | Portal + licence | | `POST /apps/events/fetch-events` | Public | Portal + licence | | `POST /apps/events/payment-status` | Public | Portal + licence | | `POST /apps/events/resume-payment` | Public | Portal + licence | | `POST /apps/events/scan-session` | Public | Portal + licence | | `POST /apps/events/subscription-status` | Public | Portal + licence | | `POST /apps/events/check-in-by-qr` | Member | Portal + contact session | | `POST /apps/events/door-events` | Member | Portal + contact session | | `POST /apps/events/fetch-user-registrations` | Member | Portal + contact session | | `POST /apps/events/my-event-registration` | Member | Portal + contact session | | `POST /apps/events/update-registration` | Member | Portal + contact session | | `GET /apps/events/page/dashboard` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/events/page/event-detail` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/events/page/events-list` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/events/page/payments` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/events/admin/migrate-schema` | Admin | Portal + shared secret | | `POST /apps/events/admin/reconcile` | Admin | Portal + shared secret | | `POST /apps/events/admin/set-interop-config` | Admin | Portal + shared secret | | `POST /apps/events/admin/set-marketing-email` | Admin | Portal + shared secret | | `POST /apps/events/admin/set-payments-config` | Admin | Portal + shared secret | | `POST /apps/events/admin/set-subscription` | Admin | Portal + shared secret | | `POST /apps/events/admin/set-transactional-email` | Admin | Portal + shared secret | | `POST /apps/events/page/create-event` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/events/page/mark-complimentary` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/events/payment-callback` | Server-to-server | Portal + shared secret | | `POST /apps/events/promote-waitlist` | Server-to-server | Portal + shared secret | | `POST /apps/events/workflow/send-event-email` | Server-to-server | HubSpot signature | Endpoints marked **Admin** or **Server-to-server** are part of the app's operation, not its integration contract: they are listed for completeness and are not documented in detail below. ## Custom object — `events` Created automatically on first install. The `name` property is unique and acts as the primary identifier across all API calls and navigation links. Object type name: `events`. The numeric `objectTypeId` is portal-specific and resolved by the backend at runtime — callers never need it. | Property | Type | Description | |---|---|---| | `name` | string · unique | Event name. **Must be unique per portal.** Used as the primary event identifier for URL routing and in `fetch-event-details` name lookups. Cannot be duplicated. | | `url_slug` | string | Legacy URL identifier. Also accepted by `fetch-event-details` as a lookup key (tried before name). By convention equals a slugified version of `name`. | | `description` | string · textarea | Full event description. Shown on the event detail page. | | `meta_description` | string | SEO meta description. Keep under 160 characters. Maps to the HubSpot dynamic page meta description field. | | `highlights` | string · textarea | Short bullet-style summary of event highlights. Shown as a callout on event detail pages. | | `start_datetime` | datetime | Event start date and time. ISO 8601 format (`2026-06-15T09:00:00.000Z`). Used as the sort key in `fetch-events` and the upcoming-only filter. | | `end_datetime` | datetime | Event end date and time. ISO 8601 format. | | `event_type` | string | Free-text event category. Common values: `conference`, `webinar`, `workshop`, `meetup`. | | `event_status` | enumeration | Controls visibility. Only `published` events are returned by `fetch-events`. Values: `draft` · `published` · `cancelled` | | `event_tags` | string | Free-text tags for filtering. Semicolon-separated by convention (e.g. `training;certification;online`). | | `image_featured` | string · URL | Full URL to the hero/featured image. Also maps to the HubSpot dynamic page featured image field. | | `image_thumbnail` | string · URL | Full URL to the event card thumbnail image. | | `video_featured` | string · URL | URL to a featured video (embed URL or direct link). Optional. | | `is_online` | boolean | Whether the event is online-only. Values: `true` · `false` | | `location_name` | string | Venue name or platform name for online events (e.g. `Convention Center Amsterdam`, `Online via Zoom`). | | `street_address` | string | Street address of the venue. Leave blank for online events. | | `city` | string | City. Leave blank for online events. | | `postal_code` | string | Postal/ZIP code. | | `country` | string | Country name in full (e.g. `Netherlands`, `Belgium`). | | `location_map_type` | string | Map provider hint (e.g. `google`). Optional. | | `location_map_url` | string · URL | Embed URL for an interactive map. | | `location_image_map` | string · URL | URL to a static map image. | | `price_amount` | number | Ticket price. Use `0` for free events. | | `price_currency` | string | ISO 4217 currency code (e.g. `EUR`, `USD`). | | `payment_link` | string · URL | External payment or booking URL (e.g. Stripe, Eventbrite). Optional. | | `ticket_types` | string · textarea | Available ticket type names, one per line or semicolon-separated (e.g. `standard;vip;student`). Used to populate the ticket type selector on the registration form. The `ticket_type` field on a registration should match one of these values. | | `capacity_total` | number | Maximum number of attendees. Set to `0` for unlimited. `create-registration` blocks new registrations when `capacity_current >= capacity_total` (and `capacity_total > 0`). | | `capacity_current` | number | Current registration count. Incremented automatically by `create-registration`. Starts at `0`. | | `features` | string | Semicolon-separated list of feature/amenity codes shown as icons on event pages. Common values: `wifi`, `parking`, `food_drinks`, `networking`, `certificate`, `recording`, `accessible`. | | `contact_email` | string | Organiser contact email address. Shown on the event detail page. | | `contact_phone` | string | Organiser contact phone number. | | `external_url` | string · URL | Link to an external event page or additional information. Optional. | | `registration_mode` | string | How registrations are collected. Common value: `form` (inline registration form). Optional — used by the front-end to decide which registration UI to show. | | `requires_membership` | boolean | Whether the event is restricted to members only. Values: `true` · `false` | | `meeting_platform` | string | Online meeting platform name (e.g. `Zoom`, `Microsoft Teams`). Shown when `is_online` is true. | | `online_meeting_url` | string · URL | Direct join URL for the online meeting. Typically shared only after registration. | | `associated_projects` | string | Internal field — links the event to a HubSpot Projects record by numeric ID. Events with a value here are hidden when `hideProjectEvents: true` is passed to `fetch-events`. | | `training_type` | string | Optional classification for training events (e.g. `certification`, `induction`). Used by portal-specific filtering logic. | ## Custom object — `event_registrations` One record per attendee per event. Created by `create-registration`. Each registration is associated to its event and (optionally) to a HubSpot contact. | Property | Type | Description | |---|---|---| | `attendee_name` | string | Full name (`firstName + ' ' + lastName`). Set automatically by `create-registration`. Can be edited via `update-registration`. | | `first_name` | string | Given name. | | `last_name` | string | Family name. | | `email` | string | Email address. Used to match or create a HubSpot contact during registration. Editable via `update-registration`. | | `phone` | string | Phone number. Editable via `update-registration`. | | `company` | string | Company name. Editable via `update-registration`. | | `job_title` | string | Job title. Editable via `update-registration`. | | `registration_date` | datetime | ISO 8601 timestamp of when the registration was created. Set automatically. | | `registration_status` | enumeration | Registration lifecycle status. Physical attendance is tracked separately in `attendance_status` — a checked-in attendee stays `confirmed`. Values: `pending` · `confirmed` · `waitlisted` · `cancelled` | | `attendance_status` | enumeration | Physical attendance, tracked separately from the registration lifecycle. Written by `check-in-by-qr` (sets `checked_in`). New registrations default to `not_arrived`. Values: `not_arrived` · `checked_in` · `no_show` | | `ticket_type` | string | Ticket type chosen at registration. Defaults to `standard` if not specified. Should match a value from the event's `ticket_types` property. | | `qr_code` | string | Unique QR check-in code generated at registration time. Format: `EVT-XXXXXXXXXXXX` (16 chars, uppercase alphanumeric). Used by `check-in-by-qr`. | | `check_in_datetime` | datetime | ISO 8601 timestamp of when the attendee was checked in. Set by `check-in-by-qr` when `action: 'checkin'` (alongside `attendance_status: checked_in`). | | `payment_status` | enumeration | Payment state. `check-in-by-qr` grants access when status is `paid`, `free`, or `complimentary`. All five are declared schema options. Values: `pending` · `paid` · `free` · `complimentary` · `refunded` | | `amount_paid` | number | Money that **actually moved** for this ticket, in the event's currency — not the ticket's list price. On a voucher-discounted order it is the ticket's share of what was charged, and on one a voucher covered in full it is `0`. (Before 2026-08-10 it was written as the list price regardless, which over-reported revenue.) | | `voucher_discount_amount` | number | What a voucher covered for this ticket, reported beside `amount_paid` rather than folded into it: `price_amount = amount_paid + voucher_discount_amount`. Written as an explicit `0` when nothing was discounted, so "nothing was discounted" and "nobody has looked" stay distinguishable. | | `special_requirements` | string · textarea | Dietary, accessibility, or other special requirements. Shown on the event attendee list. | | `purchaser_email` | string | Email of the person who registered/paid. Equals the attendee's email for self-registration; differs for group registrations (one purchaser, several attendees). Mirrors the `Purchaser` association so emails can route to the buyer without resolving associations. | | `purchaser_name` | string | Name of the purchaser. See `purchaser_email`. | ## Associations Five association labels are created automatically on install. **Label names are stable contracts** — the backend resolves portal-specific numeric type IDs at runtime by matching on the label name. Do not rename these labels in HubSpot. | From | To | Label | Inverse label | |---|---|---|---| | `events` | `event_registrations` | Event Registrations | Event | | `contacts` | `event_registrations` | Registered Attendee | Event Registration | | `contacts` | `event_registrations` | Purchaser | Purchased Registration | | `contacts` | `events` | Registered | Registered Contact | | `projects` | `events` | Project Event | Project | > [!NOTE] > `create-registration` creates the event→registration, **Registered Attendee**, **Purchaser**, and contact→event links automatically. The attendee and purchaser links point to the same contact for self-registration, or to different contacts for group registrations. (**Project Event** links events to HubSpot Projects and is managed separately.) ## Endpoints ### GET /apps/events/license-status Check whether a portal has an active Events license. Does not require OAuth install — safe to call before the install flow. **Query parameters** | Parameter | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | **Response** ```json { "licensed": true } ``` ### POST /apps/events/fetch-events Return a list of published events, sorted by `start_datetime` ascending. By default returns only upcoming events (start date in the future). **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `timeframe` | string | `upcoming` (default) · `past` · `all`. `past` also flips the sort to most-recent-first. Prefer this over `upcomingOnly`. | | `upcomingOnly` | boolean | Legacy form of `timeframe`, kept for older callers: `true` (default) → `upcoming`, `false` → `all`. Ignored when `timeframe` is present. | | `hideProjectEvents` | boolean | If `true`, exclude events that have a value in `associated_projects` (used to hide internal project-linked events from public listings). Default: `false`. | The endpoint pages through HubSpot results for you (100 per page, up to 10 pages) and returns the full set in one response — there is no cursor to follow. **Response** ```json { "success": true, "total": 3, "events": [ { "id": "12345678", "name": "Monthly Member Webinar", "url_slug": "monthly-member-webinar", "start_datetime": "2026-06-15T14:00:00.000Z", "end_datetime": "2026-06-15T16:00:00.000Z", "event_type": "webinar", "event_status": "published", "event_tags": "online;members", "is_online": "true", "location_name": "Online via Microsoft Teams", "city": null, "country": null, "price_amount": "0", "price_currency": "EUR", "capacity_total": "500", "capacity_current": "12", "image_featured": "https://images.unsplash.com/...", "image_thumbnail": "https://images.unsplash.com/..." } ] } ``` > [!NOTE] > Each event in the array contains all `events` object properties. Use `id` when you need to reference this event in other HubSpot CRM API calls; use `name` when calling `fetch-event-details`. ### POST /apps/events/fetch-event-details Fetch full details for a single event. Looks up the event by `url_slug`, then `name` (exact match), then `name` (partial match) — the first match wins. This is the **public event page** read, so the event body is visible to anonymous callers. The `registrations` array is not: it is scoped to the caller's own proven session contact. > [!WARNING] > `registrations` does **not** return the attendee roster. It contains only the registrations belonging to the contact proven by the session token, and is `[]` for an anonymous caller. Building an attendee list from this endpoint will silently return nothing. For a member's own ticket use [`my-event-registration`](#post-appseventsmy-event-registration); for organiser figures use [`event-stats`](#post-appseventsevent-stats), which returns counts and no PII. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `eventId` **(required)** | string | Event identifier — can be the `url_slug`, the exact `name`, or a partial name. The backend tries each lookup in order until a match is found. | | `isNameLookup` | boolean | Pass `true` when `eventId` is a decoded event name from a URL path segment. This skips the `url_slug` lookup and saves one HubSpot API call. Default: `false`. | | `contactId` | string | Accepted but **not** used for scoping. Which registrations come back is decided by the session token, never by this field. | **Response** ```json { "success": true, "event": { "id": "12345678", "name": "Monthly Member Webinar", "description": "Our regular online gathering...", "start_datetime": "2026-06-15T14:00:00.000Z", "capacity_total": "500", "capacity_current": "12", "...": "...all event properties..." }, "registrations": [ { "id": "98765432", "attendee_name": "Sarah Johnson", "email": "sarah.johnson@example.com", "registration_status": "confirmed", "payment_status": "paid", "qr_code": "EVT-ABC123DEF456", "check_in_datetime": null, "...": "...all registration properties..." } ] } ``` Above, `registrations` holds the session contact's own registration. An anonymous caller gets the same `event` object and `"registrations": []`. ### POST /apps/events/scan-session Mint the contact-session token the member-scoped endpoints require. The caller is already an authenticated CMS member; this verifies their email against the CRM contact before issuing. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `contactId` **(required)** | string | HubSpot contact ID of the signed-in member. | | `contactEmail` **(required)** | string | Their email. It must match the contact's CRM email, or the call returns `403 Identity verification failed`. Omitting it is also a `403` — naming a contact is not proof of being one. | Every refusal returns the same `403 Identity verification failed`, whether the email was absent, wrong, or the contact id does not exist. That is deliberate: a distinguishable response would let a caller discover which contact ids are real. > [!IMPORTANT] > What a successful mint proves is that **the caller knows the contact's e-mail address** — it is > shared knowledge, not a session. HubSpot attaches no signed identity to a **CMS-page** request, > so this is the strongest check available for the surfaces that call this route. (App-page > `hubspot.fetch()` requests ARE signed — see `backend/app-security-model.md` § "REVERSED > 2026-08-12" — but this mint serves the CMS scanner/embed, which that mechanism does not cover.) > Scope what you put behind the token accordingly: one contact's own records, not a roster. **Response** ```json { "success": true, "sessionToken": "eyJwb3J0YWxJZCI6...", "sessionExpiry": 1786351200000, "keyringScannerUrl": null } ``` The token is valid for **one hour**; `sessionExpiry` is epoch milliseconds. `keyringScannerUrl` is the configured hand-off target for the staff scanner, or `null` when no sibling app is configured. > [!NOTE] > A portal with no contact secret provisioned returns `"sessionToken": null` and `"success": true`. Every install provisions one, so this should not occur — and there is **no longer a permissive path behind it**. The member-scoped endpoints always require a valid token, so a caller without one gets `401` rather than legacy behaviour. Treat a null token as "this member cannot be identified" and fall back to the anonymous experience. ### POST /apps/events/my-event-registration The signed-in member's own ticket(s) for one event, plus the online join link. This is the endpoint to use for "my ticket" surfaces — never `fetch-event-details`. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `eventId` **(required)** | string | Numeric event ID, `url_slug`, or exact `name`. | **Response** ```json { "success": true, "registrations": [ { "id": "98765432", "attendee_name": "Sarah Johnson", "email": "sarah.johnson@example.com", "registration_status": "confirmed", "attendance_status": "not_arrived", "ticket_type": "standard", "qr_code": "EVT-ABC123DEF456", "payment_status": "paid" } ], "onlineMeetingUrl": "https://teams.microsoft.com/l/meetup-join/..." } ``` `onlineMeetingUrl` is returned **only** when the member holds a non-cancelled ticket; otherwise `null`. Registrations are matched by the proven contact's email, so a member with no ticket gets `[]` rather than an error. ### POST /apps/events/event-stats Aggregate figures for one event. **Counts only — no attendee PII**, which is why it is safe for a public capacity bar as well as the organiser dashboard. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `eventId` **(required)** | string | Numeric event ID, `url_slug`, or exact `name`. | **Response** ```json { "success": true, "stats": { "eventName": "Annual Member Conference", "capacityTotal": 500, "confirmed": 312, "waitlisted": 18, "pending": 4, "cancelled": 9, "total": 343, "active": 334, "checkedIn": 287, "noShow": 2, "notArrived": 25, "spotsRemaining": 188, "checkInRate": 92, "byEventTicketType": { "standard": 280, "vip": 54 }, "byPaymentStatus": { "paid": 300, "free": 34 }, "revenue": { "amount": 18750.00, "currency": "EUR" }, "registrationsByDay": [{ "date": "2026-06-01", "count": 12 }], "arrivalsByHour": [{ "time": "2026-06-15T09:00", "count": 63 }] } } ``` `spotsRemaining` is `null` for an uncapped event (`capacity_total` of `0`). `checkInRate` is a whole-number percentage of `confirmed`. `notArrived` is `confirmed - checkedIn`, floored at zero. ### POST /apps/events/fetch-user-registrations Find all event IDs that a specific contact is registered for. Matches by contact email address via the `event_registrations` object. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `contactId` **(required)** | string | HubSpot contact ID. The backend fetches the contact's email and uses it to search registrations. | **Response** ```json { "success": true, "eventIds": ["12345678", "87654321"] } ``` > [!NOTE] > Returns an array of event *IDs* (not names or slugs). Cross-reference these with the `id` field returned by `fetch-events` to determine which events a contact has already registered for. ### POST /apps/events/create-registration Register an attendee for an event. Creates the `event_registrations` record, creates or updates the HubSpot contact, and creates all three association links (event → registration, contact → registration, contact → event). Increments `capacity_current` on the event. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `eventId` **(required)** | string | Event identifier — numeric ID from `fetch-events`, or a `url_slug` string. Non-numeric values trigger a slug lookup first. | | `formData` **(required)** | object | Attendee details. See sub-fields below. | | `contactId` | string | Optional HubSpot contact ID. If provided, the contact is updated with the form data rather than searched by email. If omitted, the backend searches by email and creates the contact if not found. | **formData fields** | Field | Type | Description | |---|---|---| | `firstName` **(required)** | string | Attendee first name. | | `lastName` **(required)** | string | Attendee last name. | | `email` **(required)** | string | Attendee email address. Used to find or create the HubSpot contact. | | `phone` | string | Phone number. | | `company` | string | Company name. | | `jobTitle` | string | Job title. | | `ticketType` | string | Ticket type. Defaults to `standard` if not provided. Should match a value from the event's `ticket_types` field. | | `specialRequirements` | string | Dietary, accessibility, or other special requirements. | **Response** ```json { "success": true, "registration": { "id": "98765432", "qr_code": "EVT-ABC123DEF456", "email": "sarah.johnson@example.com" } } ``` > [!IMPORTANT] > **Capacity check:** if `capacity_total > 0` and `capacity_current >= capacity_total`, the registration is rejected with `400 { "error": "Event is at full capacity" }`. Check capacity before attempting to register. > [!NOTE] > **Group registration:** include an optional `attendees` array (each item carries the same fields as `formData`) to register several people under one purchaser in a single call. Each registration stores `purchaser_email`/`purchaser_name` and gets both a `Registered Attendee` link and (for the purchaser contact) a `Purchaser` link. Omit `attendees` for ordinary self-registration — the single `formData` attendee is also the purchaser. ### POST /apps/events/update-registration Update a single editable property on an existing registration. Used for inline attendee detail corrections. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `registrationId` **(required)** | string | Numeric ID of the `event_registrations` record. | | `propertyName` **(required)** | string | Property to update. Only these five values are accepted: `attendee_name` · `job_title` · `company` · `email` · `phone` | | `propertyValue` | string | New value. Pass an empty string to clear the field. | **Response** ```json { "success": true } ``` ### POST /apps/events/check-in-by-qr Look up a registration by QR code and optionally check the attendee in. Returns an access decision that drives the check-in UI: grant entry, deny, flag as already checked in, or report an error. Requires a contact session — mint one with [`scan-session`](#post-appseventsscan-session). Without it the endpoint would hand attendee PII to anyone holding a portal ID and a QR string. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | | `qrCode` **(required)** | string | QR code value from the registration. Format: `EVT-XXXXXXXXXXXX`. | | `action` | string | Pass `"checkin"` to set `attendance_status: checked_in` and record `check_in_datetime` (only on a `GRANT`). Omit (or any other value) for a read-only verification that doesn't mutate the record. | | `eventId` | string | The event this door is staffing. When present, a ticket belonging to any other event returns `WRONG_EVENT` and is never checked in. Omit to accept a ticket for any event on the portal. | **Access decisions** | Decision | Meaning | When | |---|---|---| | `WRONG_EVENT` | Right ticket, wrong door | `eventId` was supplied and the ticket belongs to a different event — evaluated **first**, ahead of `ALREADY_IN`, so a ticket already scanned in elsewhere can't render as an amber "already checked in" | | `ALREADY_IN` | Already checked in | `attendance_status` is `checked_in` (or `check_in_datetime` is set) | | `GRANT` | Entry permitted | Not yet checked in, `registration_status` is `confirmed`, AND `payment_status` is `paid`, `free`, or `complimentary` | | `DENY` | Entry refused | Not yet checked in, but registration isn't `confirmed` (e.g. `pending`/`waitlisted`/`cancelled`) or payment isn't settled | | `ERROR` | QR code not found | No registration matches the provided QR code (404 response) | A registration that carries no event association is **never** refused on scope grounds — that's a data defect on the portal, not a reason to turn an attendee away. The decision proceeds as if unscoped and an `EVENT_UNKNOWN` warning is returned instead. **Warnings** `warnings` is advisory and never changes the decision. Each entry carries a `code` and a server-authored `message`; render the message verbatim for any code you don't recognise, since new codes can appear before a scanner UI is updated. | Code | Meaning | |---|---| | `EARLY` | Scanned before the event's doors-open time (start minus the early grace) | | `LATE` | Scanned after the event's end plus the late grace. When the event has no `end_datetime`, an assumed duration is used and the message says so | | `EVENT_UNKNOWN` | The door was scoped but the ticket's event couldn't be resolved, so the scope check did not happen | | `PAYMENT_ATTENTION` | The decision is `ALREADY_IN` but the ticket isn't settled — e.g. a chargeback suspended it *after* it first scanned in. The decision stays `ALREADY_IN` on purpose: a legitimately checked-in attendee must not become a `DENY` on re-scan | On a `DENY`, `denyReason` carries `{ code, message }` — `REGISTRATION_CANCELLED`, `ON_WAITLIST`, `REGISTRATION_PENDING`, `PAYMENT_PENDING`, `PAYMENT_REFUNDED`, `PAYMENT_SUSPENDED`, or `NOT_GRANTED`. It is `null` for every other decision. Same rule as warnings: render your own copy for codes you know, the server's `message` for ones you don't. Time is **only ever a warning** — entry is never refused on the clock alone. The grace either side is operator-tunable per deployment. **Response** ```json { "success": true, "accessDecision": "GRANT", "checkedIn": true, "warnings": [ { "code": "LATE", "minutes": 300, "message": "This event ended 5 h ago." } ], "doorEventId": "551", "registration": { "id": "98765432", "attendeeName": "Sarah Johnson", "firstName": "Sarah", "lastName": "Johnson", "email": "sarah.johnson@example.com", "company": "Acme Corp", "jobTitle": "Product Manager", "eventTicketType": "standard", "registrationStatus": "confirmed", "paymentStatus": "paid", "attendanceStatus": "checked_in", "checkInDatetime": "2026-06-15T09:32:00.000Z", "specialRequirements": null }, "event": { "id": "551", "name": "Annual Member Conference", "startDatetime": "2026-06-15T09:00:00.000Z", "endDatetime": "2026-06-15T17:00:00.000Z" } } ``` > [!NOTE] > `checkedIn` is `true` only when `action: "checkin"` was passed *and* the decision was `GRANT`. On `DENY`, `ALREADY_IN` or `WRONG_EVENT`, the record is not modified regardless of the action. > [!TIP] > On a `WRONG_EVENT`, `event` describes the event the **ticket** belongs to, while `doorEventId` echoes the event the **door** is staffing — enough to tell someone "this is for Saturday" without a second lookup. ### POST /apps/events/door-events The short list of events a door might be staffing: published events starting within the last 48 hours or the next 30 days, oldest first. Feeds the scanner's "which event are you staffing?" picker, whose selection becomes `eventId` on [`check-in-by-qr`](#post-appseventscheck-in-by-qr). Requires the same contact session as `check-in-by-qr`. The payload is published event names and times — the same data [`fetch-events`](#post-appseventsfetch-events) serves publicly — and carries no attendee information. **Request body** | Field | Type | Description | |---|---|---| | `portalId` **(required)** | string | HubSpot portal ID. | **Response** ```json { "success": true, "events": [ { "id": "551", "name": "Annual Member Conference", "startDatetime": "2026-06-15T09:00:00.000Z", "endDatetime": "2026-06-15T17:00:00.000Z", "locationName": "Main Hall", "live": true } ] } ``` `live` is `true` when now falls inside the door window (start minus the early grace, to end plus the late grace) — the *same* window that produces the `EARLY`/`LATE` warnings, so a scanner's auto-selection and the warning it later shows can never disagree about what "now" means. An event with no `start_datetime` is never `live`. ## Creating and managing events Events are created and edited directly via the HubSpot CRM API — this worksby.design API does not expose a create/update event endpoint. Use the standard HubSpot `/crm/v3/objects/{objectTypeId}` endpoints with the `events` custom object. ### Resolving the objectTypeId HubSpot assigns a different numeric `objectTypeId` to the `events` custom object on each portal. To resolve it, fetch all schemas and find the one whose `name` field equals `"events"`: ```text GET https://api.hubapi.com/crm/v3/schemas Authorization: Bearer {access_token} // In the response, find: results.find(s => s.name === 'events').objectTypeId // → e.g. "2-12345678" ``` ### Creating an event Once you have the `objectTypeId`, create an event with a `POST` to `/crm/v3/objects/{objectTypeId}`: ```text POST https://api.hubapi.com/crm/v3/objects/{objectTypeId} Authorization: Bearer {access_token} Content-Type: application/json { "properties": { "name": "Monthly Member Webinar", "url_slug": "monthly-member-webinar", "description": "Our regular online gathering for members.", "meta_description": "Free monthly online webinar — live panel and open Q&A.", "highlights": "Live panel, member updates, exclusive content.", "event_type": "webinar", "event_status": "published", "start_datetime": "2026-07-15T14:00:00.000Z", "end_datetime": "2026-07-15T16:00:00.000Z", "is_online": "true", "location_name": "Online via Microsoft Teams", "price_amount": "0", "price_currency": "EUR", "capacity_total": "500", "capacity_current": "0", "features": "recording", "registration_mode": "form", "image_featured": "https://images.unsplash.com/photo-...", "image_thumbnail": "https://images.unsplash.com/photo-..." } } ``` ### Required properties for a visible event At minimum, set these properties for an event to appear in `fetch-events` results: - `name` — must be unique; this is the event identifier - `event_status: "published"` — draft and cancelled events are excluded - `start_datetime` — must be in the future (unless `upcomingOnly: false`) ### Updating an event ```text PATCH https://api.hubapi.com/crm/v3/objects/{objectTypeId}/{eventId} Authorization: Bearer {access_token} Content-Type: application/json { "properties": { "event_status": "cancelled" } } ``` > [!WARNING] > **Name uniqueness:** the `name` property has a unique constraint. Attempting to create two events with the same name on the same portal will fail with a HubSpot 409 conflict error. If you need test events, use distinct names. ### Writing registrations directly (sample data) To seed demo attendees, create `event_registrations` records directly via the CRM API rather than calling `create-registration` (which also matches contacts, enforces capacity, and may send email). Resolve the `event_registrations` `objectTypeId` the same way as for events. ```text POST https://api.hubapi.com/crm/v3/objects/{registrationsObjectTypeId} Authorization: Bearer {access_token} Content-Type: application/json { "properties": { "attendee_name": "Sarah Johnson", "first_name": "Sarah", "last_name": "Johnson", "email": "sarah.johnson@example.com", "registration_date": "2026-06-01T10:00:00.000Z", "registration_status": "confirmed", "attendance_status": "not_arrived", "payment_status": "free", "ticket_type": "standard", "qr_code": "EVT-ABC123DEF456", "purchaser_email": "sarah.johnson@example.com", "purchaser_name": "Sarah Johnson" } } ``` Then link the record. Resolve each numeric association type ID by **label** via `GET /crm/v4/associations/{from}/{to}/labels`, then create with `batch/create`. Use the field name `from` (not `_from`) or the link is silently dropped: - `events → event_registrations` · label `Event Registrations` - `contacts → event_registrations` · label `Registered Attendee` (the attendee) - `contacts → event_registrations` · label `Purchaser` (the buyer — same contact as the attendee for self-registration) - `contacts → events` · label `Registered` > [!TIP] > **QR codes:** use a unique value per registration shaped as `EVT-` + 12 uppercase alphanumerics. **Capacity:** the app derives the live attendee count from non-cancelled registrations, so you needn't keep `capacity_current` exact — but set `capacity_total` on the event for display and the full-capacity check. ## Integration seams Events is one of three of our apps that share a portal, and each seam is deliberately thin — a documented contract, never shared code. | With | What crosses | Where it is documented | |---|---|---| | **Commerce** | Paid tickets. Events creates an Order (`externalSource: "events"`, `externalRef` = the registration ids), hands the buyer to Commerce's checkout, and issues tickets only when the signed completion callback says the money moved. The `payment-callback`, `payment-status` and `admin/set-payments-config` routes in the index above are this seam. | [commerce-reference.md](commerce-reference.md) § The payment contract | | **Keyring** | Door scanning. A venue running Keyring's staff scanner can hand a scanned `EVT-…` ticket code straight to the Events scanner instead of failing it, so one device handles both loyalty cards and event tickets. One-directional and off by default. | [keyring-reference.md](keyring-reference.md) § Integration seams | Neither seam is required. Events runs perfectly well as the only one of the three on a portal; free events need no Commerce, and door staff can use the Events scanner directly. # Commerce — reference > Load this file when: evaluating, integrating with, or troubleshooting the **Commerce app** — its three parts (CPQ, eCommerce, the payment platform), the consumer contract another app or feature uses to take money, and the seams to Events and Keyring. Building *inside* Commerce is project documentation, not this page. > 🔒 **Works by Design system — not a HubSpot platform feature.** Commerce is our private HubSpot app; the `/hs/serverless/…` endpoints below exist only on portals where we have installed it. The **contract** is the portable part — server-derived amounts, verify by bucket rather than raw provider status, the callback as a poke rather than a source of truth, money separated from fulfilment — and it is worth copying against whatever payment layer you do have. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md) ## What the app is Commerce is one HubSpot app doing three jobs that share a product catalogue, a pricing engine and a payment layer: it quotes to businesses, sells to consumers, and takes the money for both — and for anything else on the portal that needs to charge for something. | | | |---|---| | **Kind** | Private HubSpot app (platform 2026.03), installed per portal | | **The three parts** | **CPQ** — configure/price/quote for B2B · **eCommerce** — catalogue, cart and checkout for B2C · **Payment platform** — the provider seam both use, and the one other apps buy through | | **Stores data in** | HubSpot's own commerce objects wherever they exist — Products, Line Items, Quotes, Orders, Carts, Invoices, Payments — plus a small number of custom objects for what HubSpot has no native home for | | **Surface count** | 59 HTTP endpoints declared in the project at the `verified` date above | | **How to get it** | Not distributed. Ask for a walkthrough: [w@reus.ie](mailto:w@reus.ie) | > [!NOTE] > This page is Commerce's **permanent entry point** — the URL will not change. It is deliberately an overview plus the one contract outsiders actually need; the internals (provider modules, the payments mirror, the subscription lifecycle) are project documentation and are not published here. ## The payment contract This is the part of Commerce another app touches, and the reason it exists as a platform rather than a checkout page. The division of labour is the whole design: > **Money is Commerce's job. Fulfilment is yours.** You create an Order, hand the buyer to Commerce's checkout, and get told when the money moved. What that means — issue a ticket, mint a voucher, unlock a download, start a subscription — is yours alone. Commerce never touches your records, and you never talk to a payment provider, never hold a key, and never interpret a provider's status vocabulary. Six steps. Every consumer is the same shape; only the last one differs. | | Step | What you do | |---|---|---| | 1 | **Create an Order** | `POST /hs/serverless/create-order` with the buyer's `contactId`, your own app name as `externalSource`, and your own correlation id as `externalRef`. Add line items where you want a real itemised order. **Server-side only** — the order total is what gets charged | | 2 | **Create the payment** | `POST /hs/serverless/create-payment` with the `orderId`, a `redirectUrl` for the buyer and, optionally, a `callbackUrl` for your backend. You get back a hosted checkout URL | | 3 | **Send the buyer** | Redirect to that URL. The provider, the card form and the compliance surface are Commerce's problem from here | | 4 | **Verify** | When the buyer returns, ask Commerce whether the money moved. Verify by **outcome bucket**, never by a raw provider status string — provider vocabularies differ and change | | 5 | **React to the callback** | The signed completion callback is a *poke*, not a source of truth: it tells you to go and check, and step 4 is what you believe. Treat it as optional — it can be missed, duplicated or late | | 6 | **Fulfil** | Your side. Idempotent, keyed on your own `externalRef`, and safe to run twice — because steps 4 and 5 can both fire | Full request and response shapes, the bucket table, and the reconciliation job are in `backend/payments-consuming.md` in our documentation corpus — reachable over the [Docs MCP](/docs/mcp), or ask and we will send it. The complete endpoint inventory lives alongside it in [commerce-endpoints.md](commerce-endpoints.md), generated from the app's own manifests; it is a working document for people building *on* Commerce rather than buying through it, which is why it stays in the corpus rather than appearing here. ### Providers The provider seam is a module contract, so adding a payment service provider is a bounded piece of work rather than a rewrite. Status is not uniform, and the difference matters: | Provider | Status | |---|---| | **Mollie** | Live-verified — the provider the platform was proven against end to end | | **Stripe** | Implemented, **not yet live-verified**. The webhook endpoint is not registered automatically; it must be added in the Stripe dashboard | | **Buckaroo** | Implemented, **not yet live-verified**. Two keys per mode, and test and live are different hosts | | **Simulator** (`mock`) | Provider-independent testing, and the only way to produce a chargeback on demand | "Implemented, not yet live-verified" means the code is written and unit-tested and the runbook exists, but no real money has moved through it. Run the runbook before pointing anything real at one. ## Surfaces | Surface | Where | What it is | |---|---|---| | **CPQ quote modules** | Quote templates | The B2B quote a customer receives — configurable modules, not a fixed template. Documented separately: [CPQ quote modules](../guides/cpq-quote-modules-zero-to-hero.md) | | **eCommerce module** | Website pages | Catalogue, cart and checkout for B2C | | **Commerce app page** | HubSpot, Marketplace menu → Commerce | The operator cockpit — revenue KPIs and trends, the work queue, and the payment-platform status tile | | **Settings** | HubSpot app settings | Provider credentials and mode, the voucher provider, and the per-portal configuration below | ## Integration seams Commerce is one of three of our apps that share a portal. Both seams below run through the payment contract or a public endpoint — no shared code, no privileged lane. | With | What crosses | |---|---| | **[Events](events-app-api.md)** | Paid tickets. Events is a payment consumer exactly as described above: it creates the Order with `externalSource: "events"`, and issues tickets on its own side once the money is confirmed. Commerce never knows what a ticket is | | **[Keyring](keyring-reference.md)** | Vouchers at checkout. A shopper types a Keyring gift-card or voucher code into the cart; Commerce validates it, recomputes the discount **from the live cart** server-side, and spends it against Keyring only when the order genuinely reaches paid. Commerce integrates with Keyring exactly as an external point-of-sale system would — see [keyring-integration.md](keyring-integration.md) | Neither is required, and both ship switched off. ## Conventions and gotchas - **Verify by bucket, never by raw status.** Provider status vocabularies are not portable and do change under you. This is the single rule most likely to be broken by a consumer reading a provider's own documentation instead of this contract. - **One payment settles exactly one invoice.** A HubSpot invoice shows as paid because an associated Payment record rolls up into it, and that association is one-to-one. An order that is expected to produce both a receipt invoice and a subscription invoice will not get both from a single payment — which is why a paid order carrying a recurring line item deliberately gets no receipt invoice. - **A recurring line item leaves a native HubSpot Subscription behind.** HubSpot then generates each cycle's invoice and charges nobody — collection for later cycles is not automated. Verified on a live portal; treat any claim that it "just bills" as wrong. - **The search index lags writes.** A record created a moment ago may not be findable by search yet. Read back by id, or drive lists by `hs_createdate` cursor rather than search, whenever correctness depends on seeing what you just wrote. - **Every serverless response body must be `JSON.stringify(...)`.** Returning a plain object produces an empty HTTP 200 with no error anywhere — a platform behaviour, not a Commerce one, and it has cost real time on every app we have built. - **Per-portal configuration is genuinely per-portal.** Provider credentials, the portal's own public domain, and any host the app is allowed to call are set per install. A setting that *looks* complete can still be dead if it points at a record that has since been archived — which is why the settings surface has connection tests that mint a real session rather than checking that fields are non-empty. ## Verifying it works A green settings page is not evidence. In order, on the portal you care about: 1. Open the **Commerce app page** (Marketplace menu → Commerce) and confirm the payment-platform tile reports a configured provider and mode. 2. Run a payment through the **simulator** (`mock` provider) end to end: create an order, complete the checkout, and confirm your own fulfilment ran exactly once. 3. Confirm the order shows as paid **in HubSpot** — not only in the app — by finding the Payment record associated with the invoice. 4. Produce a **chargeback** in the simulator and confirm a dispute case appears. This is the path that is never exercised by a happy-path test and is the one most likely to be broken. 5. Only then switch the provider to a real one, in test mode, and repeat steps 2–4 with the provider's own test credentials. # File Manager — reference > Load this file when: evaluating, integrating with, installing or troubleshooting the **File Manager app** — what it does, the storage backends it spans, how a member's identity is bound to what they can see, its `p_filerecords` custom object, and the `api.worksby.design/apps/filemanager/` endpoints. > 🔒 **Works by Design system — not a HubSpot platform feature.** File Manager is our app, and these endpoints work only for portals where it is installed. Portable regardless: routing files to different backends by rule rather than by hand, treating a signed capability as the whole credential for a browser navigation, and the reason a download must never be a redirect to a signed CDN URL. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md) ## What File Manager is A file library your customers, members or applicants use on your website — with the documents filed against the CRM records they belong to, rather than in a folder tree nobody maintains. A member signs in to your site and sees the files that are theirs: attached to their company, their ticket, their project, or to a record type you chose. They upload, and the upload lands attached to the right records automatically. Nothing about it requires anyone to have a HubSpot seat. | | | |---|---| | **Kind** | Public HubSpot app (OAuth), installed per portal | | **Where it runs** | A module on your own website pages, behind whatever membership you already use | | **Stores files in** | More than one place, by rule — see [Storage](#storage-that-spans-more-than-one-place) | | **Stores metadata in** | A custom object on your own portal, `p_filerecords`, provisioned automatically at install | | **Also available as** | A component another app can render inside itself — the Portal app shows its file UI without holding a copy of it | | **How to get it** | Not self-service. Installs are allowlisted: [w@reus.ie](mailto:w@reus.ie) | ## What it does **Files follow records, not folders.** A file is attached to CRM records — a company, a contact, a ticket, a project, or a custom object type you nominate — and who can see it follows from that. There is no permission tree to maintain in parallel with your CRM, because the CRM *is* the tree. **One upload, several records.** A member attaches a document once and it lands against every record it belongs to — an invoice against the company and the ticket, a certificate against the contact and the project. This is one component, not a workflow. **Uploads arrive where you decided.** Routing rules decide the backend, and mapping rules decide the records — both configured once by an administrator, not chosen by the member at upload time. **Downloads stream.** A download is a real streamed response with a filename, not a base64 payload the browser has to reassemble, which is what lets it handle files far larger than a page can hold in memory. ## Storage that spans more than one place The point of the multi-source design is that "where the bytes live" is a rule, not a decision anyone makes per file. A member sees one library. | Backend | Status | |---|---| | **HubSpot Files** | Live. The default for ordinary documents | | **Private file server** | Live. Takes what HubSpot should not or cannot — video by extension, and anything above the routing threshold | | **Google Drive** | Live. A folder on the customer's own Drive, swept on a schedule as well as on demand | | **SharePoint** | **Built, and switched off everywhere.** Written against a documented Microsoft Graph contract and a fixture — no real tenant has answered any of it yet. It is not a supported option until credentials arrive and the go-live measurements are taken. We would rather say that than ship it quietly enabled | Adding another backend is a bounded piece of work rather than a rewrite: the registry, the sync engine and the download dispatcher are generic, and a new source supplies the provider-specific parts. **Size:** the server refuses anything above **4 GB** outright. What a given portal actually accepts is usually lower — computed per portal from its own configuration — and what the upload area advertises to a member is lower still. Treat the advertised figure as a hint and the server's answer as the truth. ## What it does not do - **It is not a document management system.** No versioning, no check-out, no approval workflow, no retention policy engine. - **It is not a HubSpot seat replacement for staff.** It is the member-facing side; your team works in the CRM. - **It does not sync a whole Drive.** A configured folder, not a whole account. - **It does not do previews in-app.** A file is downloaded, not rendered inline — deliberately, because the shortcut that makes preview easy is the one that breaks downloads (see [Conventions](#conventions-and-gotchas)). ## Identity — how a member is bound to their files Every request arrives on behalf of a **proven contact**, not a claimed one. The page tells the app who the visitor is; the app verifies that claim against the CRM before minting a **signed session token**, and every contact-scoped route reads the contact from that token. An id in a request body is never taken as proof of identity — if one is supplied, the token's contact overrides it. That is what makes "show me the files for contact 123" safe to expose to a browser. Two consequences worth knowing before you design against it: - **Enforcement is per install.** A portal's posture depends on its own configuration and on having a session-aware client deployed. Send the token whenever you have one, and never treat its absence as optional. - **Downloads carry their own credential.** A browser navigation cannot attach a header, so a download URL is minted on demand, short-lived, and bound to the specific file. The capability *is* the credential, and it is cryptographically distinct from a session token — neither verifies as the other, which is what makes its ten-minute life real rather than decorative. The deeper trust boundary — what the session proves and what it does not — is a deployment-review conversation rather than a published one. Ask for it. ## Endpoint index Generated from the same policy manifest the service's own route-coverage test asserts against, so it cannot drift from what is running. **Member** is the bulk of it — a proven contact acting on their own files. **Scoped** routes are the ones a browser reaches without a session header: a download link, an upload already in progress, an OAuth callback. Each carries a signed capability bound to that one thing, which is what lets a plain navigation or a redirect work without ever being a way in to anything else. 35 endpoints — 22 public, 8 member, 5 scoped. | Endpoint | Surface | Auth required | |---|---|---| | `GET /apps/filemanager/license-status` | Public | None | | `GET /apps/filemanager/settings/companies` | Public | Portal + licence | | `GET /apps/filemanager/settings/records` | Public | Portal + licence | | `GET /apps/filemanager/settings/records/:objectType/:recordId/audience` | Public | Portal + licence | | `GET /apps/filemanager/settings/sources` | Public | Portal + licence | | `GET /apps/filemanager/settings/sources/googledrive/drives` | Public | Portal + licence | | `GET /apps/filemanager/settings/sources/googledrive/folders` | Public | Portal + licence | | `GET /apps/filemanager/settings/sources/sharepoint/folders` | Public | Portal + licence | | `GET /apps/filemanager/settings/sources/sharepoint/libraries` | Public | Portal + licence | | `GET /apps/filemanager/settings/status` | Public | Portal + licence | | `GET /apps/filemanager/settings/target-types` | Public | Portal + licence | | `GET /apps/filemanager/settings/uploads` | Public | Portal + licence | | `POST /apps/filemanager/session` | Public | Portal + licence | | `POST /apps/filemanager/settings/resync-assert-secret` | Public | Portal + licence | | `POST /apps/filemanager/settings/simulate-routing` | Public | Portal + licence | | `POST /apps/filemanager/settings/sources/:source/sync` | Public | Portal + licence | | `POST /apps/filemanager/settings/sources/:source/verify` | Public | Portal + licence | | `POST /apps/filemanager/settings/sources/googledrive/connect-url` | Public | Portal + licence | | `POST /apps/filemanager/settings/sources/sharepoint/resolve-site` | Public | Portal + licence | | `PUT /apps/filemanager/settings/routing` | Public | Portal + licence | | `PUT /apps/filemanager/settings/sources/:source` | Public | Portal + licence | | `PUT /apps/filemanager/settings/uploads` | Public | Portal + licence | | `POST /apps/filemanager/associable-targets` | Member | Portal + contact session | | `POST /apps/filemanager/delete-files` | Member | Portal + contact session | | `POST /apps/filemanager/download-files` | Member | Portal + contact session | | `POST /apps/filemanager/download-url` | Member | Portal + contact session | | `POST /apps/filemanager/fetch-files` | Member | Portal + contact session | | `POST /apps/filemanager/search-files` | Member | Portal + contact session | | `POST /apps/filemanager/upload-file` | Member | Portal + contact session | | `POST /apps/filemanager/upload-init` | Member | Portal + contact session | | `GET /apps/filemanager/download/:capability` | Scoped | A signed capability in the URL, which is the whole credential | | `GET /apps/filemanager/google/callback` | Scoped | A signed capability in the URL, which is the whole credential | | `GET /apps/filemanager/upload-status/:uploadId` | Scoped | Bound to the upload in progress | | `POST /apps/filemanager/upload-chunk/:uploadId` | Scoped | Bound to the upload in progress | | `POST /apps/filemanager/upload-finalize/:uploadId` | Scoped | Bound to the upload in progress | **Base URL:** `https://api.worksby.design/apps/filemanager/`. Every request carries `portalId`; contact-scoped routes additionally carry the session token as a header. ## Data model One custom object, `p_filerecords`, created on your portal automatically during install. It holds the metadata — name, size, type, when and by whom — and the associations to whatever the file belongs to. The bytes live in whichever backend the routing rules chose. One field is worth understanding because it is load-bearing: **the stored path tells you which backend holds the file**, without a separate column to keep in step. A relative path means the private file server; a full URL means HubSpot. A schema field that could disagree with reality was deliberately not added. Object type ids differ per portal — resolve them at runtime from the schema list, and use the `p_` prefix form of the name rather than a portal-stamped one. ## Surfaces | Surface | Where | What it is | |---|---|---| | **File library** | A module on your website pages | The member-facing library — browse, search, upload, download | | **Upload area** | Same module, or standalone | Drag and drop, with the record targets applied by rule | | **Settings** | HubSpot, in the app's settings | Sources, routing, mapping targets, sync schedule, and a security and storage status panel | | **Rendered inside another app** | Wherever that app runs | The library can render inside the Portal app at runtime — one implementation, two products, no fork | ## Integration seams | With | What crosses | |---|---| | **Portal** | Portal renders File Manager's file UI **at runtime** rather than holding a copy of it. That is why a fix here reaches both products at once, and why Portal ships no file code of its own | | **Your website's membership** | Whatever already authenticates your visitors. File Manager takes the identity your page asserts, verifies it against the CRM, and works from there — it does not want to own your login | ## Conventions and gotchas - **Never turn a download into a redirect to a signed CDN URL.** HubSpot's signed URLs carry no `Content-Disposition`, so the browser renders PDFs and images inline instead of saving them, and the bug looks like a UI problem rather than a routing one. Stream it and set the header yourself. This one has cost time on more than one app. - **The advertised upload limit is UI, the server's is truth.** Three numbers are in play — the hard ceiling, the portal's effective maximum, and what the dropzone displays. Only the first is guaranteed; validate against the server's answer, not the label. - **A scope added to the app does not reach a portal until it reinstalls.** Until then the affected calls fail in ways that read like unrelated bugs. Check the install state before debugging the feature. - **Storage routing is one shared module**, used by File Manager and Portal alike, so a routing change is a two-product change. That is deliberate — two copies of a routing policy is how files start landing in two different places. - **Resolve association type ids by label at runtime.** They are per portal, and a hardcoded id fails silently rather than loudly. ## Verifying it works Deploying is not verifying. On the portal you care about, in order: 1. **Check the app reports itself licensed and installed** with no outstanding scope changes. 2. **Load the module on a page as a signed-in member** and confirm the library lists that member's files — and only theirs. 3. **Sign in as a different member** and confirm the list changes. This is the check that actually tests the identity binding; step 2 alone passes just as happily when everyone sees everything. 4. **Upload a file** and confirm it appears against every record the mapping rules should have attached it to — not just the first. 5. **Download it** and confirm it saves with the right filename rather than opening in a tab. 6. **Upload something large enough to route to the other backend** and repeat step 5. Routing is the part that is easy to get right for the common case and wrong for the exception. # ATS — reference > Load this file when: evaluating, demoing, or getting oriented on the **ATS** app — the careers embed, the recruiter's CRM surfaces, the CRM objects it stores candidates and postings in, and how it fits a hiring process. How candidates are scored is [ats-matching.md](ats-matching.md); calling it over HTTP is [ats-integration.md](ats-integration.md); installing and running it is [ats-setup.md](ats-setup.md). > 🔒 **Works by Design system — not a HubSpot platform feature.** ATS is our HubSpot app; nothing here ships with a HubSpot subscription and it is not on your portal unless we installed it. Portable regardless: the candidate-is-a-Contact data model, tiered schema provisioning against HubSpot's custom-object limit, and a scoring engine whose every rule is a stored setting rather than code. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md) ATS turns a HubSpot portal into a careers site and a hiring pipeline. Candidates apply on the customer's own domain; the application lands as a CRM record; recruiters work it from cards on the records they already use. There is no second login, no separate candidate database, and no export step between recruiting and the rest of the business. The design decision underneath all of it: **a candidate is a Contact.** Not a row in a recruiting product that syncs to a Contact — the Contact itself, with recruiting properties on it. So a candidate who later becomes a customer is one record with one history, and every HubSpot list, workflow, report and marketing email works on hiring the same way it works on sales. | | | |---|---| | **Kind** | Public HubSpot app (OAuth), installed per portal | | **Built for** | Teams whose hiring has outgrown a mailbox but does not justify an enterprise ATS | | **Stores data in** | Custom CRM objects and Contact properties on your own portal. No external database holds candidate data | | **Candidate surfaces** | A careers listing, a job page per posting, an application form, and a passwordless status portal | | **Recruiter surfaces** | 5 CRM cards, a full-screen recruiting app page inside HubSpot, and 4 workflow actions | | **Matching** | 10 weighted dimensions, 57 published settings, a dry run that changes nothing — [ats-matching.md](ats-matching.md) | | **How to get it** | Not distributed. Ask for a walkthrough: [w@reus.ie](mailto:w@reus.ie) | > [!IMPORTANT] > **Maturity, stated plainly.** ATS is feature-complete against its build plan and runs on our demo portals, with the matching engine and the résumé pipeline each covered by a unit suite that runs on every change. It has **not** been deployed against a real customer's live hiring. Two things are worth knowing before planning around it: **the parts that cost money need your own provider keys** — reading a résumé, turning an address into coordinates, and measuring a commute are each off until you set one, and each states what it will spend before it spends it — and the public application routes authenticate the *portal*, not the caller, which is appropriate for a careers page and is stated exactly in [ats-integration.md](ats-integration.md). Ask for the hardening pass as part of any real deployment. ## What it actually does ### A careers site on the customer's own domain A listing page with search and department, location, employment-type and remote filters; a detail page per posting on a permanent slug; an application form that upserts the Contact and creates the application. The embed inherits the site's existing theme, so there is no separate design exercise. Each job page is individually indexable and carries Google Jobs–compatible structured data. ### Application intake that produces a record, not an email Name, email, phone, cover letter and an optional CV upload. Duplicate applications to the same posting are refused rather than silently doubled. What lands is an `ats_application` associated to a Contact and a posting — a record a workflow can act on the moment it exists. ### It reads the résumé, into the fields that get scored An uploaded CV is read — PDF or `.docx` — and what it says is written onto the candidate's own Contact properties: skills, seniority, years of experience, education, languages, industry. This is what makes scoring worth anything on a portal where nobody has typed a profile, because the engine scores properties and an unread CV is a file it cannot see. Three rules make it safe to leave switched on. It **never blanks a field the résumé does not mention**, and **never overwrites what a recruiter typed** unless the portal asked it to — a parse is a good guess, a recruiter is someone who spoke to the candidate. And a value the CRM's own option list does not contain is **reported, not dropped**: HubSpot accepts that write and silently discards the value, which would otherwise leave the candidate scoring zero on that dimension with nothing anywhere saying why. It needs your own AI provider key and does nothing until you set one. Parsing runs *after* the candidate's response, so a slow model can never cost somebody their application, and a daily cap you choose bounds what it can spend. ### Candidate self-service without accounts A candidate asks for a status link by email and receives a one-time link. No password, no account creation. The portal deliberately shows a **simplified** status — never the internal pipeline stage name, never a rejection reason — because the same record is the recruiter's working surface and those two audiences must not see the same field. ### A recruiter workspace inside the CRM 5 cards on the records that already exist: a posting command centre with the funnel and candidate ranking, an application workspace with the match breakdown and interviews, a candidate 360 view on every Contact, an interview panel, and a document inspector. Plus a full-screen app page — dashboard, pipeline board, postings, create-posting and reports — for the work that is not about one record. ### Candidate scoring that shows its working The part most worth understanding, and the reason this app has a page of its own for it. A candidate is scored against a posting across 10 dimensions, each producing a 0–100 sub-score that is combined by weight. Every rule is a stored setting a recruiter can change from the settings page, and every score returns the per-dimension breakdown that produced it — which skills matched, which were missing, what the seniority gap was. Two properties make it usable rather than merely present. **A dry run scores real candidates and saves nothing**, so the effect of a weight change is visible before it is committed. And **a dimension the posting says nothing about drops out of the denominator** rather than scoring zero — a posting with no language requirement is not a posting every candidate fails on languages. Full treatment, for recruiters and developers alike: [ats-matching.md](ats-matching.md). ### Distance, and what the commute actually is Two different kinds of fact, worded apart on purpose. **Distance** is a straight line computed from stored coordinates — free, shown beside every ranked candidate once both sides are geocoded, and it only *scores* when radius matching is switched on. **Travel time** is a live driving or public-transport measurement, fetched only when a recruiter presses the button and **stored nowhere**, so its cost follows how often a card is opened rather than how many candidates you hold. It never reaches a score, a filter or a sort, deliberately: a number that differs between two runs an hour apart could not be reproduced or explained afterwards. A commute that could not be measured says so, and says why. Never a blank, and never a zero — both of those read as a candidate who lives next door. ### Automation from HubSpot's own workflow builder 4 workflow actions — set application stage, recalculate the match score, close a posting, create an interview — usable in any workflow the customer builds, with no extra tooling. This is where the CRM-native model pays: "when an application scores above 80, notify the hiring manager" is a workflow, not a feature request. ## What it does not do Stated up front, because these are the ones that reshape a plan if you find them late. - **It does not read a CV you have not paid for, or an old one.** Résumé reading is real, but it runs on your own AI provider key and does nothing until you set one — every surface says which, rather than looking like it is waiting in a queue. Applications accept **PDF and `.docx`**; older `.doc` files are refused at upload, since nothing downstream can read them. - **It does not match on meaning.** Skill comparison is literal, widened by a configurable synonym map (so `Node` matches `Node.js`). The `semantic` option appears in the settings and currently behaves as synonym matching — there are no embeddings behind it. See [ats-matching.md](ats-matching.md) § Skill matching. - **It does not push to job boards.** There is a **pull** feed — one XML URL a board polls on its own schedule, alongside the structured data that makes each page indexable by Google Jobs ([ats-integration.md](ats-integration.md) § The job-board feed). What does not exist is an authenticated push integration that posts into Indeed's or LinkedIn's own systems and reports back a posting id. - **It does not author scorecards or offer letters.** The objects exist at the higher tiers; the authoring experience is record-level property editing. - **It is not a compliance product.** Compliance objects exist at full tier as data. Legal review of a regulated hiring process is not something a data model performs. - **It has no candidate accounts.** The status portal is a link, by design. If a customer specifically wants candidates to log in, this is not that. ## What people see | Who | Surface | What it is | |---|---|---| | **Candidate** | Careers listing | Search and filters over live postings, on the customer's domain and in their theme. Filtered views are shareable links | | **Candidate** | Job page | One posting on a permanent slug, individually indexable, with Google Jobs structured data and related roles | | **Candidate** | Application form | Contact upsert plus an application record; CV optional; duplicate applications refused | | **Candidate** | Status portal | A passwordless emailed link showing a simplified status, and the ability to withdraw | | **Candidate** | Job alerts | Opt-in digest of new postings matching a stated preference | | **Recruiter** | 5 CRM cards | Posting command centre, application workspace, candidate 360, interview panel, document inspector | | **Recruiter** | Recruiting app page | Full-screen inside HubSpot: dashboard, pipeline board, postings list, create posting, reports | | **Operator** | Settings page | Status and install tier, all 57 settings across their own tabs — matching, CV parsing, geolocation — the dry-run calibrator, and sample data | | **Automation** | 4 workflow actions | Set stage · recalculate match · close posting · create interview | ## Data model Everything is a CRM record, and the schema installs in **tiers** because most HubSpot portals cap custom objects at around 10 — a 19-object recruiting model does not fit on a standard portal, so it is not the default. | Tier | Objects | What it is for | |---|---|---| | `minimal` | 2 | Only what the public job board needs at runtime. This is what the OAuth callback provisions | | `lean` (**default**) | 4 | `ats_job_posting`, `ats_application`, `ats_interview`, `ats_candidate_document` — a working ATS | | `standard` | 6 | Adds a candidate profile and an offer object | | `full` | 19 | The enterprise model: requisitions, compliance, scorecards, skill junctions. Requires a raised custom-object allowance and is installed deliberately, never from the OAuth callback | | Object | What it holds | |---|---| | `ats_job_posting` | A role: its public content, its slug, its status, and the matching criteria a candidate is scored against | | `ats_application` | One person applying to one posting: pipeline stage, timestamps, and the persisted match score with its full breakdown | | `ats_interview` | A scheduled interview and its outcome | | `ats_candidate_document` | An uploaded CV or attachment, its metadata, and a parse-status field awaiting a parser | | `contact` (extended) | Recruiting properties on the standard Contact — seniority, skills, languages, education, experience, salary expectation, remote preference, open-to-work | | `app_setting` | Key-value configuration, using our shared app-settings pattern; the matching settings are rows here | Three things about this model are deliberate and worth copying whatever you build on: - **The candidate is the Contact.** Recruiting properties live in their own property group on the standard object. No parallel person record, no sync, no reconciliation. - **The tier is a decision, not a default.** Provisioning 19 objects on a portal that allows 10 fails at install time, on the customer's portal, in a way that is tedious to unwind. Installing the smallest thing that works and upgrading additively is the safe direction, and the upgrade never deletes. - **The match score is stored with its explanation.** `match_score` is a number; `match_details_json` alongside it holds the per-dimension breakdown and a snapshot of the settings that produced it. A score you cannot explain six months later is a score nobody trusts. Associations use **labels** rather than raw type ids, and object type ids are resolved from the schema list at runtime — they differ per portal, so hardcoding one is a bug that only appears on the second install. ## Integration seams | With | What crosses | |---|---| | **The customer's website** | The careers embed is a Vite bundle on the customer's own pages, calling the app's public routes. The portal is authenticated and licence-checked; the caller is not — see [ats-integration.md](ats-integration.md) | | **HubSpot workflows** | 4 custom actions, and the ordinary CRM surface. Everything ATS writes is a property or a record, so anything HubSpot can automate against a record it can automate against a hire | | **Marketing email and lists** | Candidates are Contacts with a property group. Talent-pool nurture, alert digests and re-engagement are ordinary HubSpot marketing, not an ATS feature | There is no outbound webhook to arbitrary URLs and no third-party job-board push. A system that wants ATS data reads it from the portal. ## Where to go next | You are | Read | |---|---| | Deciding how candidates should be ranked, or tuning it | [ats-matching.md](ats-matching.md) — every dimension's arithmetic, all 57 settings, and how to change them safely | | Calling it over HTTP, or building against the embed | [ats-integration.md](ats-integration.md) — the auth model, the generated endpoint index, the match API and the persisted score shape | | Installing or operating it on a portal | [ats-setup.md](ats-setup.md) — tiers, what install provisions, the properties matching needs, verification and the schema migration | | Evaluating whether it fits | This page, then [ask for a walkthrough](mailto:w@reus.ie) | # ATS — candidate matching > Load this file when: tuning how candidates are ranked, or working out why one scored what they did — the dimension weights, the must-have gate, `weight_*` and the other 47 `ats-matching` settings, `match_score`, `match_details_json`, the per-posting `match_weight_override_json`, or the dry-run calibrator. What the app is: [ats-reference.md](ats-reference.md). Calling the match API: [ats-integration.md](ats-integration.md). > 🔒 **Works by Design system — not a HubSpot platform feature.** The ATS matching engine is ours. Portable regardless, and the reason this page exists: a ranking model whose every rule is a stored, inspectable setting rather than a constant in code, that returns the breakdown it used, and that drops a dimension out of the denominator instead of scoring it zero when the data is absent. A match score answers one question: **how well does this person fit this posting, and which parts of the fit are weak?** It is a ranking aid for a human, not a decision. The engine is deliberately arithmetic rather than statistical — no model, no training data, no drift — so any score can be traced back to the rules that produced it, and a recruiter who disagrees with a ranking can change the rule instead of appealing to a black box. This page is written for two readers at once. Every dimension is stated first in plain terms — what it means and what makes it go up or down — and then as the exact arithmetic, for anyone who needs to reproduce or debug a number. A head of recruitment can stop after the plain sentence in each; a developer can skip straight to the formulas. ## How a score is produced Five steps, in this order. The order matters — the gate is applied *after* the weighted sum, not instead of it. 1. **Normalise both sides.** The posting's properties and the candidate's Contact properties are read into a neutral shape. Comma-separated, semicolon-separated, newline-separated and JSON-array text all parse into lists, so a skills field entered by hand and one written by a workflow behave the same. 2. **Score each dimension independently, 0–100.** 10 dimensions, each with its own rule. Nothing at this stage knows about weights or about any other dimension. 3. **Drop the dimensions that do not apply.** Three things exclude a dimension, and they are not interchangeable — § Two kinds of absence below. Excluded dimensions leave the denominator entirely. 4. **Combine by weight.** `overall = Σ(score × weight) ÷ Σ(weight)`, over the included dimensions only. 5. **Apply the must-have gate.** If the must-have skills **sub-score** — the 0–100 number from step 2, not a count of skills — is below the threshold, cap or flag the result depending on the configured behaviour. The result is rounded once, at the end. ## Two kinds of absence Data can be missing on either side, and the two are handled completely differently. This is the most common source of a score nobody can explain, so it is worth getting straight before anything else. | Missing on | What happens | |---|---| | **The posting** — you did not state a requirement | Four dimensions **exclude themselves**. The other six fall to missing-data behaviour, exactly as if the candidate were blank | | **The candidate** — they did not supply the data | Always missing-data behaviour. **Never** an automatic zero, unless you configure one | **Only four dimensions exclude themselves when the posting is silent**, and they are the four that are lists: must-have skills, nice-to-have skills, languages and keywords. An empty list has nothing to score against, so scoring it would be meaningless. The other six — seniority, location, salary, experience, education, industry — are comparisons between two values. A blank on *either* side means the comparison cannot be made, and both blanks take the same route: missing-data behaviour, which by default scores **50** and stays in the denominator. | Dimension | Posting silent | Candidate silent | |---|---|---| | Must-have skills · Nice-to-have skills · Languages · Keywords | **Excluded** | Missing-data behaviour | | Seniority · Location · Salary · Experience · Education · Industry | Missing-data behaviour | Missing-data behaviour | So a posting that says nothing about education does **not** remove education from the score — by default it gives every candidate 50 on it. If you want a silent posting to genuinely drop a dimension, either set its weight to 0, or set the relevant missing-data behaviour to `ignore`. Full treatment: § Missing data. > [!IMPORTANT] > **A blank field can score higher than an honest one.** Take a posting requiring a master's degree. A candidate who leaves education blank scores **50** on that dimension. A candidate who states a bachelor's — one level short, and truthful — scores **40**. Verified 2026-08-09. > > This is what `neutral` means, taken to its conclusion: not knowing is treated as better than knowing something disappointing. It is the right default for a small, well-curated pool and the wrong one for a large, patchy talent pool where incomplete profiles are common. Switch the missing-data behaviours to `conservative` if that describes yours. ## The ten dimensions and their weights 10 dimensions, 9 enabled by default. The defaults sum to 100, so out of the box a weight reads directly as a percentage — but only while every dimension participates. The share column is that best case; the real denominator is computed per candidate (see *Normalisation* below). | Dimension | Setting key | Default weight | Share of the score | |---|---|---|---| | Must-have skills weight | `weight_must_have_skills` | 30 | 30% | | Nice-to-have skills weight | `weight_nice_to_have_skills` | 10 | 10% | | Seniority weight | `weight_seniority` | 15 | 15% | | Location weight | `weight_location` | 15 | 15% | | Salary weight | `weight_salary` | 10 | 10% | | Experience years weight | `weight_experience_years` | 5 | 5% | | Education weight | `weight_education` | 5 | 5% | | Languages weight | `weight_languages` | 5 | 5% | | Industry weight | `weight_industry` | 5 | 5% | | Keywords weight (0 = disabled) | `weight_keywords` | 0 | **disabled** | Weights are ordinary numbers, not percentages, and nothing requires them to sum to anything. The defaults sum to 100 because that makes them readable, not because the engine needs it. Doubling every weight changes nothing at all; doubling one changes its influence relative to the rest. **A weight of 0 disables the dimension** — it is removed from the calculation rather than scored zero. That is how keywords ships: present, documented, and off. ## Normalisation — why a weight is not a percentage This is the part that surprises people, and it is the single most useful thing to understand before tuning anything. The denominator is **the sum of the weights that actually participated for this candidate**, not the sum of all weights. A dimension that was excluded — because the posting stated no requirement, or because its weight is 0 — is not in the numerator or the denominator. So the same weight is worth more when fewer dimensions participate: | Participating | Weights sum to | Location weight 15 is worth | |---|---|---| | All 9 enabled dimensions | 100 | 15% of the score | | Must-have skills and location only | 45 | 33% of the score | Neither is wrong — the second score genuinely rests on less, so what it does rest on counts for more. But it means **a thin comparison produces a confident-looking number from very little evidence**, and a recruiter comparing scores across postings is not comparing like with like. If that matters, require the same fields on every posting rather than trying to fix it with weights. > [!NOTE] > **Leaving fields off a posting does not, by itself, produce the second row.** Only the four list dimensions exclude themselves when the posting is silent; the rest score a neutral 50 and stay in the denominator (§ Two kinds of absence). Reaching a genuinely small denominator takes a deliberate act — a weight of 0, or a missing-data behaviour of `ignore`. A sparse posting on default settings does something different and worse: it scores everyone 50 on the fields you left out, which flattens the ranking rather than sharpening it. ## The must-have gate The gate exists because a weighted average is too forgiving on its own. A candidate missing every required skill can still reach a respectable overall score on seniority, location and salary alone — and no amount of weight tuning fixes that, because the problem is the averaging, not the weighting. So must-have skills get a second, non-averaged test. `must_have_match_behavior` chooses what happens when a candidate's must-have **sub-score** falls below `must_have_gate_threshold` (default 60). **The threshold is compared against the 0–100 sub-score, not against a count of skills** — and that has a consequence worth understanding before you set it, because the sub-score can only take as many values as the posting has skills. With 3 must-haves the only achievable scores are 0, 33, 67 and 100; a threshold anywhere from 34 to 67 means exactly the same thing, "at least 2 of 3". So **the same threshold is far stricter on a short list than on a long one**: | Must-haves listed | Achievable sub-scores | A threshold of 60 demands | |---|---|---| | 1 | 0, 100 | 1 of 1 — every skill | | 2 | 0, 50, 100 | 2 of 2 — every skill | | 3 | 0, 33, 67, 100 | 2 of 3 | | 4 | 0, 25, 50, 75, 100 | 3 of 4 | | 5 | 0, 20, 40, 60, 80, 100 | 3 of 5 | Verified 2026-08-09 and asserted by the engine's test suite. Two practical readings: a posting with **two** must-haves is running an all-or-nothing gate whether you intended one or not, and adding a fifth must-have to a posting quietly *loosens* the gate from "3 of 4" to "3 of 5". If you want "every listed skill, always", set the threshold to 100 and stop thinking about list length. | Behaviour | What happens below the threshold | Use it when | |---|---|---| | `gate` (**default**) | The overall score is **capped** at `must_have_gate_score_ceiling` (default 30). The candidate still appears, visibly sunk | You want weak-on-requirements candidates ranked last but still visible | | `weighted` | Nothing. Must-have skills are just another weighted dimension | The "requirements" are aspirational and you do not want them enforced | | `hard_exclude` | The candidate is **removed from the ranking**, and flagged `below_threshold: true` | You do not want to see them at all | Removed candidates are still counted: a ranking reports `totalScored` alongside `belowThreshold`, so a short list reads as *three were dropped* rather than as *only two people applied*. You can always see that somebody was excluded, just not who. If you want them ranked last but still visible, that is what `gate` with a ceiling is for. `salary_hard_filter` removes a candidate the same way, on the salary dimension. The gate is applied after the weighted sum, so a capped score of 30 means "this candidate scored well on paper but does not have what the role requires" — which is exactly what you want a recruiter to see, rather than a fabricated low average. If the posting lists **no** must-have skills, the dimension is excluded and the gate never applies (`gate_applied: false`). A posting with no requirements cannot fail anyone on requirements. ## How each dimension scores Each returns 0–100. `score` is the sub-score; `excluded` means the dimension left the calculation. ### Skills — must-have and nice-to-have **In plain terms:** the proportion of the listed skills the candidate has. Two skills out of three is 67. **Arithmetic:** `round(matched ÷ required × 100)`. If the posting lists no skills for that field, the dimension is excluded. Both fields use the same rule and the same matching method; they differ only in weight and in the fact that the gate watches must-have alone. **Matching method** (`skill_matching_method`) decides what counts as the same skill: | Method | Behaviour | |---|---| | `exact` | Case-insensitive, whitespace-trimmed string equality. `Node` does not match `Node.js` | | `synonym` (**default**) | Both sides are canonicalised through `skill_synonym_map` first, so `Node`, `nodejs` and `node.js` are one skill | | `semantic` | **Currently identical to `synonym`.** The option exists; there are no embeddings behind it. It is not a wrong answer, just not the one the name implies | > [!WARNING] > **Selecting `semantic` changes nothing.** The engine falls through to the same synonym-map lookup. Nothing infers that React implies JavaScript. (Résumé reading fills the skills *property* from a CV — see § Known limits — but it does not change how two skill strings are compared once they are there.) If you want wider matching today, the answer is the synonym map below, not this dropdown. The settings page says the same thing under the dropdown itself. The synonym map is `{ "canonical": ["alias", …] }`, edited as JSON on the settings page. Editing it is the highest-leverage single change available: adding your industry's vocabulary once fixes every posting at the same time, and costs nothing at scoring time. Matching is per-term and literal. It does not infer that React implies JavaScript, and it compares only what is on the record — résumé reading is the step that puts a CV's skills there, and it is a separate stage with its own switch. See § Known limits. ### Seniority **In plain terms:** how far the candidate's level is from the level the posting asks for. Exactly right scores 100; a level under scores 60; two under scores 20. Over-qualified is penalised gently by default, because a level too senior is usually a conversation, not a rejection. **Arithmetic:** both sides map to a numeric rank (`intern`/`entry` 1 → `c_level` 9), then `gap = candidate − job`: | Gap | Score | |---|---| | 0 | 100 | | +1, +2, +3 or more (over-qualified) | From the `seniority_over_qualified_penalty_mode` table below | | −1 | `seniority_gap_1_score` (60), softened by the mode below | | −2 | `seniority_gap_2_score` (20), softened by the mode below | | −3 or more | 0 | Under-qualification table, by `seniority_under_qualified_penalty_mode`: | Mode | −1 level | −2 levels | −3 or more | |---|---|---|---| | `strict` (**default**) | The gap score as set — 60 | The gap score as set — 20 | 0 | | `mild` | Halves the shortfall — 80 | Halves the shortfall — 60 | 0 | `mild` is computed from the two gap settings rather than replacing them, so changing a gap score still moves both modes. Three or more levels short is a mismatch either way. Over-qualification table, by mode: | Mode | +1 level | +2 levels | +3 or more | |---|---|---|---| | `none` | 100 | 100 | 80 | | `mild` (**default**) | 80 | 50 | 30 | | `strict` | 60 | 20 | 0 | If either side's value is not in the rank map, the dimension falls to the global missing-data behaviour. ### Location **In plain terms:** whether the candidate can realistically work where and how the job requires. A remote job and a remote candidate is a perfect fit; an on-site job in a city the candidate does not live in is not, unless they will move. **Arithmetic**, branching on the posting's `remote_model`: | Posting | Candidate preference | Score | |---|---|---| | `remote` | `remote` | 100 | | `remote` | `hybrid` or `flexible` | 80 | | `remote` | anything else | `location_remote_to_onsite_score` (10) | | `hybrid` | `hybrid`, `remote` or `flexible` | 100 | | `hybrid` | anything else, including blank | 70 | | `onsite` | within `location_radius_km` of the posting | 100 | | `onsite` | same city as the posting | 100 | | `onsite` | same country | `location_same_country_score` (40) | | `onsite` | willing to relocate | `location_relocation_score` (50) | | `onsite` | nothing known about where or how they work | Global missing-data behaviour | | `onsite` | none of the above | 0 | A posting with no `remote_model` is treated as `onsite`. **The on-site rules are independent, and the candidate gets the highest one that applies.** Someone in the same country who has also ticked "willing to relocate" scores 50, not 40 — each rule is a separate reason they can work on site, so being available in two ways is never worse than being available in one. #### Radius, and what it replaces Switch on **Enable radius matching** and the first rule becomes a measured great-circle distance instead of a city-name comparison. It needs coordinates on both records — see *Geocoding* in the settings, and `POST /crm/geocode/backfill` in [ats-integration.md](ats-integration.md). Four things worth knowing before you turn it on: - **The distance is shown whether or not it scores.** Every location explanation carries `distance_km` as soon as both records are geocoded, and a `basis` saying which question was actually answered: `distance` (the radius decided it), `city` (a name comparison did), or `workplace` (a remote or hybrid role, scored on how the two sides want to work — the distance is information only). A measurement and an estimate must never render as the same number, and the recruiter cards print the basis beside the kilometres for exactly this reason. - **Inside the radius there is no gradient.** 5 km does not beat 45 km when you said 50 is acceptable. - **Beyond it, nobody is excluded by distance alone.** They fall through to the same country and relocation rules as anyone else. There is no decay curve, because there is no setting behind one and an invented curve cannot be explained to a recruiter afterwards. - **A candidate with no coordinates is scored by city, exactly as before** — never dropped. Switching radius on must not silently empty your shortlist. Once radius is on and a posting is geocoded, the candidate pool is fetched by a bounding-box query rather than the 2,000-record cap described under *Known limits* — the cap disappears for that posting. ### Salary **In plain terms:** can you afford this candidate. If their expectation overlaps the posted range at all, it is a full match. Above the range, the score decays across a tolerance band and then hits zero. **Arithmetic:** requires the posting's maximum and the candidate's minimum; without either it falls to `salary_missing_data_behavior`. 1. If the two sides name **different currencies**, they are not compared at all — it falls to `salary_missing_data_behavior` and the explanation names both. See below. 2. If the candidate's minimum is at or below the posting's maximum — they are affordable → **100**. 3. Otherwise `excess = candidateMin − postingMax` and `tolerance = postingMax × salary_tolerance_pct ÷ 100`. If the excess is within tolerance → `round(100 × (1 − excess ÷ tolerance))`. 4. Otherwise **0**, and the candidate is removed from the ranking when `salary_hard_filter` is on. **The dimension measures affordability, not similarity.** A candidate asking less than the range — even far less — is a full match, not a suspicious one. If you want to treat under-asking as a signal, that is a screening judgement for a person, not something this dimension will do for you. **Currency, and why nothing is converted.** Until 2026-08-11 this compared two bare numbers whatever currency each was in: a €60,000 expectation and a $60,000 posting were the same figure to it. Nobody had noticed, because nothing filled the candidate side until résumé reading started writing it. The fix is to **refuse rather than convert** — a conversion needs a live exchange rate, and a stale rate would misscore silently, which is the same class of failure being fixed. So: - Both sides name the same currency → compared normally. - Both name a currency and they differ → not compared; the dimension reports missing data and says which two currencies it saw. - Either side is silent, or names something that is not a three-letter code → compared as before. "The candidate did not say" is not evidence that they meant a different currency, and every score recorded before this change is in that position. A posting's currency is `public_salary_currency`; a candidate's is `ats_salary_expectation_currency`, which résumé reading fills when the document states one. ### Experience years **In plain terms:** the candidate's total years against the posting's minimum. At or above the minimum is a full match; below it decays to zero across a tolerance band. **Arithmetic:** `gap = postingMin − candidateYears`, and `floor` is `experience_years_under_1yr_score`. - `gap ≤ 0` → **100** - `0 < gap ≤ experience_years_tolerance` → `round(floor + (100 − floor) × (1 − gap ÷ tolerance))` - otherwise → **0** The two settings mean what their names say: **tolerance** is how far short you will tolerate, and **under-by-1yr score** is what a candidate scores *at* that limit. On the defaults, one year short scores exactly 50 and half a year short scores 75. ### Education **In plain terms:** does the candidate hold the level of qualification the posting requires. Meeting or exceeding it is a full match; one level short scores 40; two or more short scores zero. **Arithmetic:** both sides map to a rank (`high_school` 1, `associate` 2, `bachelor` 3, `master`/`mba` 4, `phd`/`doctorate` 5), then `gap = required − candidate`. - `gap ≤ 0` → **100**, or **80** when the candidate is above the requirement and `education_over_qualified_penalty` is on - `gap = 1` → `education_gap_1_score` (40) - `gap ≥ 2` → **0** Without a mapped value on either side, `education_missing_data_behavior` applies. ### Languages **In plain terms:** the proportion of the required languages the candidate has, or all-or-nothing if you turn partial credit off. **Arithmetic:** `ratio = matched ÷ required`. - `language_partial_match_allowed` on (**default**) → `round(ratio × 100)` - off → **100** if every required language is present, otherwise **0** The posting listing no languages excludes the dimension. The candidate listing none falls to the global missing-data behaviour. Comparison is on the literal value, so `English` and `en` are different languages unless you standardise the data. ### Industry **In plain terms:** the same industry scores 100, a related one scores **40**, anything else scores 0. There is no sliding scale between them — adjacency is a yes or no, and 40 is the whole answer for every adjacent pair. **Arithmetic:** equal (case-insensitively) → **100**. Otherwise, if either side appears in the other's list in `industry_adjacency_map` → `industry_adjacent_score` (**40** by default). Otherwise **0**. Either side blank → global missing-data behaviour. So a fintech role and a banking candidate score 40 on this dimension, not 100 and not 50. Raise `industry_adjacent_score` if your sector genuinely treats adjacent experience as near-equivalent — it is one number and it applies to every adjacent pair at once. Adjacency is checked in **both** directions, so declaring `fintech: ["banking"]` also makes banking adjacent to fintech. You do not need to list a pair twice. ### Keywords **In plain terms:** free-text overlap between the posting's keywords and the candidate's. Off by default. **Arithmetic:** `min(100, round(overlap ÷ postingKeywords × 100))`. No keywords on the posting excludes the dimension, and the default weight of 0 excludes it anyway. Turn it on when you have a vocabulary that is genuinely not a skill — a clearance, a certification body, a domain. Turning it on without curating both sides adds noise, not signal. ## Missing data § Two kinds of absence covers *which* dimensions take this route and when. This is what happens once they do — a policy decision rather than an arithmetic one, and the one setting most worth changing deliberately. Three behaviours: | Behaviour | Effect | What it says | |---|---|---| | `neutral` (**default**) | Scores **50** and participates | "We do not know" — neither rewarded nor punished | | `conservative` | Scores **0** and participates | "Unstated is a miss" — pushes incomplete profiles down | | `ignore` | **Excluded** from the calculation entirely | "Score only what we know" — the remaining dimensions carry the whole weight | Salary, experience and education each have their own setting; every other dimension uses `missing_data_global_default`. The choice interacts with the normalisation rule above: `ignore` shrinks the denominator, so a candidate with almost no data can score highly on the one or two dimensions they do have. `conservative` is the safer default for a large, patchy talent pool; `neutral` is the safer one when you are matching a handful of well-filled records. Each behaviour has a failure mode, and picking one means choosing which you would rather have: | Behaviour | Its failure mode | |---|---| | `neutral` | Blank beats honest — an unstated field outscores a stated-but-short one, and a profile of nothing but blanks lands near 50 rather than at the bottom | | `conservative` | A genuinely strong candidate whose record is thin ranks below a mediocre one whose record is complete. You are ranking data quality as much as fit | | `ignore` | The denominator shrinks to whatever the candidate happened to fill in, so scores stop being comparable between candidates — an empty profile can reach 100 on one dimension | There is no safe universal answer. The useful question is which of those three sentences you would least mind explaining to a hiring manager. ## Every setting All 47, as the engine reads them. Their values live as `app_setting` records on the portal under `app_name = "ats-matching"`; anything unset falls back to the default shown here. The 47 rule settings, in 14 groups — the 10 weights above complete the 57. A blank range means the engine imposes no bound. **Must-Have Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Must-have behaviour | `must_have_match_behavior` | select | `gate` | `gate` · `weighted` · `hard_exclude` | Always | | Gate threshold | `must_have_gate_threshold` | number | `60` | — | `must_have_match_behavior` is `gate` or `hard_exclude` | | Gate score ceiling | `must_have_gate_score_ceiling` | number | `30` | — | `must_have_match_behavior` is `gate` | **Skill Matching** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Skill matching method | `skill_matching_method` | select | `synonym` | `exact` · `synonym` · `semantic` | Always | | Skill synonym map | `skill_synonym_map` | json | 9 entries — `javascript`, `typescript`, `react`, … | JSON object | Always | | Industry adjacency map | `industry_adjacency_map` | json | 5 entries — `fintech`, `saas`, `ecommerce`, … | JSON object | Always | **Seniority Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | One-level gap score | `seniority_gap_1_score` | number | `60` | — | Always | | Two-level gap score | `seniority_gap_2_score` | number | `20` | — | Always | | Over-qualification penalty | `seniority_over_qualified_penalty_mode` | select | `mild` | `none` · `mild` · `strict` | Always | | Under-qualification penalty | `seniority_under_qualified_penalty_mode` | select | `strict` | `mild` · `strict` | Always | **Location Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Remote candidate on on-site role | `location_remote_to_onsite_score` | number | `10` | — | Always | | Relocation willing score | `location_relocation_score` | number | `50` | — | Always | | Same country score | `location_same_country_score` | number | `40` | — | Always | | Enable radius matching | `location_use_radius` | boolean | `false` | `true` · `false` | Always | | Radius (km) | `location_radius_km` | number | `50` | — | `location_use_radius` is `true` | **Salary Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Salary tolerance (%) | `salary_tolerance_pct` | number | `15` | — | Always | | Hard salary filter | `salary_hard_filter` | boolean | `false` | `true` · `false` | Always | | Missing salary data | `salary_missing_data_behavior` | select | `neutral` | `neutral` · `conservative` · `ignore` | Always | **Experience Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Experience tolerance (years) | `experience_years_tolerance` | number | `1` | — | Always | | Under-by-1yr score | `experience_years_under_1yr_score` | number | `50` | — | Always | | Missing experience data | `experience_missing_data_behavior` | select | `neutral` | `neutral` · `conservative` · `ignore` | Always | **Education Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | One-level gap score | `education_gap_1_score` | number | `40` | — | Always | | Penalise over-qualification | `education_over_qualified_penalty` | boolean | `false` | `true` · `false` | Always | | Missing education data | `education_missing_data_behavior` | select | `neutral` | `neutral` · `conservative` · `ignore` | Always | **Language Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Allow partial language match | `language_partial_match_allowed` | boolean | `true` | `true` · `false` | Always | **Industry Rules** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Adjacent industry score | `industry_adjacent_score` | number | `40` | — | Always | **Score Display** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Minimum score to display | `minimum_display_score` | number | `0` | — | Always | | Minimum must-have score to display | `minimum_must_have_score_to_display` | number | `0` | — | Always | **Global Settings** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Global missing data default | `missing_data_global_default` | select | `neutral` | `neutral` · `conservative` · `ignore` | Always | **CV Parsing** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Read uploaded resumes | `cv_parse_enabled` | boolean | `false` | `true` · `false` | Always | | AI provider | `cv_ai_provider` | select | `claude` | `claude` · `openai` · `gemini` | `cv_parse_enabled` is `true` | | AI API key | `cv_ai_api_key` | password | — | — | `cv_parse_enabled` is `true` | | Model (optional) | `cv_ai_model` | text | — | — | `cv_parse_enabled` is `true` | | Extraction prompt | `cv_ai_prompt` | textarea | `You are reading a job applicant's résumé for a recruiting system. What you return is written onto their contact record and used to score them against open roles, so two different mistakes both cost the candidate: missing something they did say, and inventing something they did not. READ THE WHOLE DOCUMENT. Most of what matters is not under the heading you would expect — skills usually appear inside the job descriptions rather than in a "Skills" list, and salary, relocation and availability are often one line at the very end. Do not stop at the first page, and do not skip a section because it looks like formatting. The résumé may be in any language. Return free text in the language it is written in, but map every listed option — seniority, education level, remote preference — to the exact English values given for that field. Extract only what the document states. If something is not stated, leave the field empty. An empty field is treated as "unknown" and stops counting against the candidate; a wrong one is scored as if it were true. When you are genuinely unsure between two values, leave it empty. Specifically: - An employer's industry is not the candidate's skill. - Do not add up date ranges into a total of years unless the document gives that total. - Applying for a job somewhere else says nothing about willingness to relocate. - A city of residence says nothing about wanting to work remotely. Seniority is the one place people are too cautious. A job title that NAMES a level — "Senior Engineer", "Lead Developer", "Head of Finance" — is the document saying so, not you inferring it, so use it. What you must not do is derive a level from years of experience, from the size of a team, or from a title that names no level at all. Skills are named technologies, tools, methods, certifications and qualifications — never personality traits. List each one once, in the form the industry uses ("PostgreSQL", not "postgres database experience"). Include skills you find in the experience section. Return dates as YYYY-MM-DD. Return salaries as plain annual gross numbers, with the currency in its own field.` | — | `cv_parse_enabled` is `true` | | Read a résumé as soon as it is uploaded | `cv_parse_on_upload` | boolean | `true` | `true` · `false` | `cv_parse_enabled` is `true` | | Largest résumé to read (MB) | `cv_max_file_mb` | number | `10` | 1–10 | `cv_parse_enabled` is `true` | | Most résumés to read per day (0 = no limit) | `cv_max_parses_per_day` | number | `200` | ≥ 0 | `cv_parse_enabled` is `true` | | Write what was found onto the contact | `cv_write_contact_fields` | boolean | `true` | `true` · `false` | `cv_parse_enabled` is `true` | | Overwrite values a recruiter already entered | `cv_overwrite_existing` | boolean | `false` | `true` · `false` | `cv_parse_enabled` is `true` | **Geocoding** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Turn addresses into coordinates | `geo_enabled` | boolean | `false` | `true` · `false` | Always | | Geocoding provider | `geo_provider` | select | `opencage` | `opencage` | `geo_enabled` is `true` | | OpenCage API key | `geo_api_key` | password | — | — | `geo_enabled` is `true` | | Lookups per second | `geo_requests_per_second` | number | `1` | 1–40 | `geo_enabled` is `true` | | Look up a candidate's coordinates after reading their résumé | `geo_on_apply` | boolean | `true` | `true` · `false` | `geo_enabled` is `true` | **Travel Time** | Setting | Key | Type | Default | Range / options | Shown when | |---|---|---|---|---|---| | Show how long the commute takes | `travel_enabled` | boolean | `false` | `true` · `false` | Always | | Google Maps API key | `travel_api_key` | password | — | — | `travel_enabled` is `true` | | How they travel | `travel_mode` | select | `driving` | `driving` · `transit` | `travel_enabled` is `true` | ## Where weights can be changed Three places, in increasing order of precedence. ### 1. The settings page — portal-wide The app's **Settings → Matching** tab renders every setting above, grouped, with the conditional ones appearing only when they apply. Changes are staged and saved as a group: **nothing takes effect until you press Save**, and Discard restores what is stored. This is the right place for a policy that is true of your hiring generally — your synonym vocabulary, your missing-data stance, your gate threshold. ### 2. Per posting — `match_weight_override_json` A property on `ats_job_posting`. Set it to a JSON object of weight keys and the posting scores with those weights instead of the portal's: ```json { "weight_location": 40, "weight_salary": 0 } ``` Only the keys you name are overridden; everything else falls through to the portal settings. Only `weight_*` keys are read — the rules (thresholds, tolerances, maps) are portal-wide and cannot be overridden per posting. This is for the posting that genuinely differs: an on-site role where location is the whole problem, or a role where budget is fixed and salary fit should not be scored at all. ### 3. The dry run — change nothing, see everything Under the settings, **Dry-run calibration** takes a job posting's record ID and ranks real candidates against it using the settings **currently on screen, saved or not**. It writes nothing: no score is persisted, no record is touched. Use it as the loop: change a weight, preview, compare the order against what you would have done by hand, then save or discard. It is the difference between tuning and guessing, and it costs nothing to run repeatedly. > [!TIP] > Calibrate against a posting you have already hired for, where you know who the right answer was. A ranking that puts your actual hire in the top three is calibrated; one that does not is telling you which dimension is mis-weighted. ## A worked example A senior React role, and a candidate who fits it well. Portal defaults throughout. These numbers are asserted by the engine's own unit suite, so they cannot drift away from the code. **The posting:** Senior React Engineer · Amsterdam, Netherlands · hybrid · €70,000–90,000 · must-have React, TypeScript, Node.js · nice-to-have GraphQL, Docker · English · bachelor's · 5 years minimum · technology. **The candidate:** senior · Amsterdam, Netherlands · prefers hybrid · asking €75,000–85,000 · React, JS, TypeScript, Node, Docker, Python · English and Dutch · bachelor's · 6 years · technology. | Dimension | Score | Why | Weight | Contribution | |---|---|---|---|---| | Must-have skills | 100 | 3 of 3 — `Node` canonicalised to `node.js` | 30 | 3,000 | | Nice-to-have skills | 50 | Docker yes, GraphQL no | 10 | 500 | | Seniority | 100 | Senior against senior, gap 0 | 15 | 1,500 | | Location | 100 | Hybrid posting, hybrid candidate | 15 | 1,500 | | Salary | 100 | €75–85k overlaps €70–90k | 10 | 1,000 | | Experience years | 100 | 6 against a minimum of 5 | 5 | 500 | | Education | 100 | Bachelor's against bachelor's | 5 | 500 | | Languages | 100 | English required and held | 5 | 500 | | Industry | 100 | Technology both sides | 5 | 500 | | Keywords | — | Excluded: weight 0, and the posting lists none | 0 | — | `9,500 ÷ 100 = 95`. Must-have is 100, comfortably above the gate's threshold of 60, so the gate is applied but not triggered. **Overall: 95.** Two variations worth seeing, both also asserted by the suite: - **Switch `skill_matching_method` to `exact`** and `Node` no longer matches `Node.js`. Must-have drops to 67 — still above the threshold, so the gate stays open — and overall drops to **85**. - **Give the candidate only React and Python.** Must-have is 33, below the threshold of 60, so the gate triggers and caps the overall at the ceiling: **30**, down from what the weighted average alone would have produced. ## Tuning recipes Concrete starting points. Each is a small change with a predictable effect — make one at a time and dry-run it. | You want | Change | |---|---| | Requirements to be genuinely non-negotiable | Raise `must_have_gate_threshold` to 100 and leave the behaviour on `gate`. Anything short of every listed skill is capped, whatever the list length | | The gate to mean the same thing on every posting | Either set the threshold to 100, or standardise how many must-haves a posting may list. At 60 the gate silently loosens as the list grows — § The must-have gate | | A blank profile to stop outscoring an honest one | Set the missing-data behaviours to `conservative`. Under `neutral`, unstated beats stated-but-short | | Weak-on-requirements candidates off the list entirely | Either `must_have_match_behavior: hard_exclude`, or `minimum_must_have_score_to_display` at your threshold. The first drops anyone under the gate threshold; the second is an independent floor and works whatever the behaviour | | To stop rewarding profiles that are simply empty | Set `missing_data_global_default` to `conservative`, and the three per-dimension ones with it | | Fewer false misses on skills | Extend `skill_synonym_map` with your own vocabulary before touching any weight. It is the cheapest accuracy you will get | | To hire for potential rather than credentials | Drop `weight_education` and `weight_experience_years` to 0; raise `weight_must_have_skills` | | Location to decide an on-site role | Override `weight_location` on that posting rather than changing the portal default | | To see fewer, better candidates | Raise `minimum_display_score`. It filters the ranking without changing any score | | To understand one specific ranking | Read `match_details_json` on the application — every sub-score, every matched and missing skill, and the settings used | Two habits worth more than any single setting. **Tune the data before the weights** — most bad rankings are a posting with three fields filled, not a weighting error. And **change one thing at a time**, because the normalisation rule means a change to one dimension moves every other dimension's share of the result. ## Known limits - **A candidate is only as scoreable as their record.** Matching reads structured properties. Résumé reading (see *CV Parsing*) fills most of them, but it is off by default and needs your own provider key — until it is on, a portal that collects CV files and no property data has nothing to score and every candidate looks identical. *(This entry read "Nothing reads CVs" until 2026-08-11, which had been untrue since résumé parsing shipped earlier that day.)* - **`semantic` is `synonym`.** The option is in the settings and behaves as synonym matching. - **Every setting is read by something.** That was not true until 2026-08-11 — the two radius settings saved and changed nothing. It is checked by scanning the modules that read settings, so the warning that used to sit here reappears by itself the day a setting stops being read. - **The candidate pool is bounded, unless radius matching is on.** A posting-to-candidates run scores up to 2,000 Contacts, filtered to those marked open to work where the portal has that property. With radius matching on and the posting geocoded, the pool is instead everyone inside the circle, with no cap. Either way the response says whether a bound was hit; see [ats-integration.md](ats-integration.md). - **Travel time is not a thing the engine knows, deliberately.** Distance is straight-line. The app *can* show a driving or transit commute next to a match (Settings → Travel Time), but it is display-only and never reaches a score, a filter or a sort: it is a live third-party value that differs between two runs an hour apart, so a ranking built on it could not be reproduced or explained after the fact. It is also stored nowhere, which means it is unavailable to workflows, lists, reports and exports — and that its cost follows how often a card is opened rather than how many candidates you have. It is fetched only when a recruiter presses the button, for the candidates on screen, and identical addresses are charged once. - **No score is automatic until you build the workflow.** Scores are produced on request — a card, a dry run, or the recalculate workflow action. To have every application arrive scored, add a workflow enrolling on application creation with the *Recalculate match score* action as its only step: one step, about a minute in the workflow editor. It is not provisioned for you, and that is a platform constraint rather than an omission — a workflow step using an app's own action has to be created in the editor, because HubSpot binds it to an app connection the API cannot set. Nothing recalculates when a Contact or a posting is later edited either; that is a second workflow, enrolling on the property changes you care about. ## What the engine returns Every score carries its own explanation. The same object is what the API returns per candidate and what is persisted to `match_details_json` on the application. ```json { "overall": 95, "engine_version": "v3", "must_have_score": 100, "gate_applied": true, "gate_triggered": false, "below_threshold": false, "dimensions": { "must_have_skills": { "weight": 30, "score": 100, "included": true, "matched": ["React", "TypeScript", "Node.js"], "missing": [] }, "seniority": { "weight": 15, "score": 100, "included": true, "gap": 0, "candidate": "senior", "job": "senior" }, "keywords": { "weight": 0, "included": false, "excluded": true } }, "settings_snapshot": { "must_have_match_behavior": "gate", "skill_matching_method": "synonym" } } ``` Reading it: - **`included: false`** is the dimension that left the denominator. Check this before concluding a weight did nothing. - **`excluded: true`** distinguishes "the posting stated no requirement" from "the weight is 0" — both exclude, for different reasons. - **`engine_version`** is the ruleset that produced the score, and it is also written to the application's `match_score_version`. It moves only when a change would make the same inputs score differently — so **two scores carrying the same version are comparable, and two carrying different versions are not.** Check it before comparing scores written at different times, and recalculate rather than compare across a change. - **`settings_snapshot`** records the two settings that most change the meaning of a score, so a stored score from six months ago can still be read correctly. - Each dimension carries its own diagnostic fields — `matched`/`missing` for skills, `gap` for seniority and education, `candidate_min`/`job_max` for salary. Calling the API, persisting a score, and the workflow action are in [ats-integration.md](ats-integration.md). ## Change policy - **The generated tables** — the weights and all 57 settings — are regenerated from the engine's own registry and checked on every commit. They cannot drift from the code. - **The worked example and the dimension arithmetic** are asserted by the engine's unit suite, which runs on every change to it. A rule that changes fails a test before it reaches this page. - **Defaults may change between versions.** Anything you have explicitly saved is stored on your own portal and survives; anything left unset follows the default. - **The dimension set and the shape of `match_details_json`** are the stable parts. New dimensions would be additive, with a default weight of 0. - **A rule change bumps `engine_version`.** That is the contract: if the same inputs would score differently, the version moves, so you can always tell whether two stored scores are comparable. A refactor or a new diagnostic field does not move it. - Everything else carries the `verified:` date at the top of this page and no stronger guarantee. # ATS — integration guide > Load this file when: calling the ATS over HTTP, embedding the careers site, or building against the match API — `api.worksby.design/apps/ats`, the `/crm/match/run` ranking call, `match_details_json`, the `recalculate-match` workflow action, or the status-link token the candidate portal uses. How scoring works: [ats-matching.md](ats-matching.md). What the app is: [ats-reference.md](ats-reference.md). > 🔒 **Works by Design system — not a HubSpot platform feature.** These endpoints are ours; `api.worksby.design` is our service and pointing a build at it will fail unless we installed the app on your portal. Portable regardless: the three-surface split below — a portal-authenticated public surface, a capability token that is the whole credential, and a signature-verified automation backend — is a shape worth copying for any app with a public embed and a CRM back office. The ATS has three HTTP surfaces with three different trust models, and nearly every integration mistake is a call made against the wrong one. Read this section before the endpoint table. ## The three surfaces | Surface | Path | What is authenticated | Who calls it | |---|---|---|---| | **Public embed** | `/apps/ats/*` | The **portal**, and its licence — not the caller | The careers site, from a visitor's browser | | **Candidate self-service** | `/apps/ats/fetch-my-applications`, `/withdraw-application` | A signed status-link token, which **is** the identity | A candidate holding an emailed link | | **Recruiter and automation** | `/apps/ats/crm/*`, `/apps/ats/wf/*` | A HubSpot-signed request carrying a proven portal **and user** (CRM) · a HubSpot v3 signature (workflow) | The CRM cards and app page · HubSpot's workflow engine | **The public surface authenticates the portal, not the person, and this is correct for what it is.** A careers page is public: anyone may read live postings and anyone may apply. There is no credential a browser on a public page could hold that a determined caller could not also hold. So the gate proves the request is for a portal that has installed and licensed the app, and the *data* is what is protected — the public routes return only the posting fields marked public, and never another candidate's application. **The candidate surface is different.** A status link is a signed token bound to one contact on one portal. It is the whole credential — there is no password beside it — so treat it exactly as you would a session token: never log it, never put it in a URL you did not construct, and expect it to expire. `request-status-link` deliberately returns the same acknowledgement whether or not the email exists, so it cannot be used to test whether somebody has applied. > [!NOTE] > **The CRM surface authenticates the individual user, cryptographically.** Cards call it through the UI-extension SDK's `hubspot.fetch()`, and HubSpot signs every production call of that kind with the app's OAuth client secret, writing the caller's portal and user id into the **signed** request. The backend validates that signature and takes the identity from it — never from anything the card's own code sent. A request that arrives without a valid signature is refused, so a portal id copied out of a page source buys an attacker nothing. This also means recruiter actions carry real attribution: the "updated by" on a record is the user HubSpot vouched for, not a self-declared value. > > Two honest limits an IT reviewer should have in writing. A captured request can be replayed for up to five minutes, the skew window HubSpot's signature scheme allows. And the gate has a documented emergency setting that drops the surface back to portal-and-licence checking; it is off, and turning it on is a deliberate operator action that gets recorded. > > The workflow surface is separately signed: those are genuine HubSpot-signed webhooks, verified per request. > > *This paragraph said the opposite until 2026-08-15 — that HubSpot did not sign card-initiated calls and portal-level binding was the strongest available. That was wrong, and had never been checked against a real request. The signature gate has been enforcing since 2026-08-12.* ## Endpoint index Base URL: `https://api.worksby.design/apps/ats`. The table is generated from the service's own policy manifest, which a route-coverage test asserts against the real routers — a route cannot ship without declaring its policy, and this table cannot drift from that declaration. 45 endpoints — 8 public, 2 customer, 30 admin, 5 workflow. | Endpoint | Surface | Auth required | |---|---|---| | `GET /apps/ats/jobs.xml` | Public | Portal + licence | | `GET /apps/ats/license-status` | Public | None | | `POST /apps/ats/create-application` | Public | Portal + licence | | `POST /apps/ats/fetch-posting-details` | Public | Portal + licence | | `POST /apps/ats/fetch-postings` | Public | Portal + licence | | `POST /apps/ats/request-status-link` | Public | Portal + licence | | `POST /apps/ats/subscribe-alerts` | Public | Portal + licence | | `POST /apps/ats/upload-document` | Public | Portal + licence | | `POST /apps/ats/fetch-my-applications` | Customer | A signed token, which is itself the identity | | `POST /apps/ats/withdraw-application` | Customer | A signed token, which is itself the identity | | `GET /apps/ats/crm/app-setting` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/application/:id/bundle` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/applications/board` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/candidate/:contactId/overview` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/dashboard/summary` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/document/:id` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/interview/:id` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/interviews/agenda` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/portal-state` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/posting/:id/summary` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/postings/list` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/reports/:report` | Admin | Authenticated portal user, from a CRM card | | `GET /apps/ats/crm/settings` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/admin/migrate-schema` | Admin | Portal + shared secret | | `POST /apps/ats/crm/admin/upgrade-tier` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/app-setting` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/application/:id/stage` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/cv-parse/test` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/document/:id/reparse` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/geocode/backfill` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/interview` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/interview/:id/outcome` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/match/persist` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/match/run` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/posting/:id/close` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/posting/:id/geocode` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/posting/:id/publish` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/posting/create` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/settings` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/crm/travel/lookup` | Admin | Authenticated portal user, from a CRM card | | `POST /apps/ats/wf/close-posting` | Workflow | HubSpot signature (v3) | | `POST /apps/ats/wf/create-interview` | Workflow | HubSpot signature (v3) | | `POST /apps/ats/wf/parse-resume` | Workflow | HubSpot signature (v3) | | `POST /apps/ats/wf/recalculate-match` | Workflow | HubSpot signature (v3) | | `POST /apps/ats/wf/set-stage` | Workflow | HubSpot signature (v3) | **What that table includes, and what it means.** The `/crm/*` and `/wf/*` sub-routers are separate Express routers mounted ahead of the portal gate, each carrying its own check — `/crm/*` a licensed-install check, `/wf/*` a HubSpot v3 signature. They **are** asserted by the coverage test as of 2026-08-09; before that the extractor could not see into a mounted router, so they were live and undeclared. Being listed is not an invitation to call them. They are not an outsider-callable contract: `/crm/*` is the recruiter's own cards talking to their own portal, and `/wf/*` answers HubSpot's workflow engine. Read the `Surface` column as *what proves the caller*, not *who may call* — a route marked Public is one whose gate is the portal install plus licence, which is why an in-product recruiter surface can carry that label. ## The careers embed contract Four calls carry the whole public flow. All are `POST`, all take JSON, and all resolve the portal from the embed's own configuration. | Step | Call | Notes | |---|---|---| | List | `POST /fetch-postings` | Live and active postings only, newest first, bounded at 1,000. Returns the public field set — never internal notes, never salary bands you have not marked public | | Detail | `POST /fetch-posting-details` | `{ id, isSlug }`. Resolves by slug or by `public_posting_id`, trying the hinted one first | | Apply | `POST /create-application` | `{ publicPostingId, applicant: { email, … }, contactId?, source? }` | | Attach a CV | `POST /upload-document` | Multipart. Stores the file and creates the document record | Three behaviours of `create-application` that decide how you build the form: - **Duplicates are answered, not refused.** One active application per posting and email. A repeat returns `200` with `{ duplicate: true, applicationId }` — so a double submit is idempotent, and your UI should treat that response as success rather than as an error. - **A closed posting refuses with `409`.** Applications are accepted while a posting is live; `closed`, `archived` and `hidden` are declined with a message meant for the candidate. - **`contactId` is a hint, not an identity.** It comes from the page's tracked-visitor cookie and is honoured **only** when that contact's own email already matches what the applicant typed. A visitor browsing as a known contact who then applies under a different address creates or matches the record for the address they typed. Never rely on `contactId` to mean "this is who is applying". Two further public calls exist for the candidate-facing extras: `POST /subscribe-alerts` opts a contact into the job-alert digest, and `POST /request-status-link` starts the status flow below. ## The job-board feed ```text GET https://api.worksby.design/apps/ats/jobs.xml?portalId= ``` One URL, handed to a job board once. Aggregators poll it on their own schedule; nothing is pushed and there is nothing to maintain after the first setup. **This is not the same mechanism as the structured data on each job page**, and the difference decides whether you need it. JSON-LD serves crawlers that *visit* a page — Google Jobs finds roles that way. A feed serves aggregators that ingest a URL and never crawl the site at all. An app with only the first is invisible to the second, so most portals want both. The format is Indeed's `` schema, which most boards accept. Content type is `application/xml`, cached for 15 minutes. | Field | From | |---|---| | `title` · `city` · `category` | Posting title, public location label, department | | `url` | The careers base URL joined to the posting's slug | | `referencenumber` | `public_posting_id` — stable across edits, so a board updates rather than duplicates | | `date` | `first_published_at` | | `company` · `publisher` | The **Employer Name** setting, falling back to the careers hostname | | `description` | The four public content fields, flattened to text | | `salary` | Only what `salary_visibility` permits — see below | | `jobtype` · `remotetype` | Employment type and remote model, mapped to the feed vocabulary | ### What it will not publish Three exclusions, each deliberate: - **A posting whose salary is hidden has no `` element.** `salary_visibility` is an editorial decision per posting, and a feed is a republication into a channel nobody reviews. `hidden` means hidden here too; `minimum`, `maximum` and `text_only` each render exactly what the public job page would. - **A posting marked `noindex` is omitted entirely.** You said do not index it. - **A posting that cannot produce a working link is omitted** — no slug, or no careers base URL configured. This one matters more than it sounds: an aggregator given a job with a dead URL does not skip it, it publishes it, and the candidate lands on a 404 with your name on it. Absent beats broken. Omissions are counted rather than hidden. The response carries `X-Jobs-Included` and `X-Jobs-Skipped`, and anything skipped is logged. > [!IMPORTANT] > **Set the careers base URL before you give this URL to a board.** Without it the feed answers `409` with a message rather than an empty document — deliberately, because a valid-but-empty feed tells an aggregator you have no open roles, and some stop polling after enough of those. Set **Employer Name** too: the fallback publishes your careers hostname as the employer, which reads as a broken listing to a candidate. ## The candidate status flow 1. The candidate submits an email to `POST /request-status-link` with the URL of your status page. The response is always the same acknowledgement. 2. If they have applications on file, they receive an email with `?token=…` appended to that page. 3. Your page calls `POST /fetch-my-applications` with the token. An invalid or expired token gets `401` and the message to request a new link — treat it as a prompt, not a failure. 4. `POST /withdraw-application` withdraws one application, with the same token. What comes back is deliberately thin: the application's own identifier, the posting title, the date applied, and a **simplified** step. The internal pipeline stage and any rejection detail are never in this response. If you are building the status page, do not attempt to enrich it from elsewhere — the reduction is the feature. ## The match API Ranking lives on the CRM surface (`/apps/ats/crm/*`), because it reads across the whole candidate pool rather than one public record. The arithmetic behind every number here is [ats-matching.md](ats-matching.md); this section is the call. ### `POST /crm/match/run` — rank, without writing anything Two modes. Neither persists: this is the same call the settings page's dry-run calibrator makes, which is why calibration is free of consequences. ```json { "mode": "posting_to_candidates", "sourceId": "", "limit": 10 } ``` | Mode | `sourceId` | What it does | |---|---|---| | `posting_to_candidates` (**default**) | A job posting record id | Scores the candidate pool against that posting and returns the top `limit` | | `candidate_to_postings` | A contact record id | Scores that contact against every **live** posting and returns the best `limit` | The response for `posting_to_candidates`: ```json { "success": true, "mode": "posting_to_candidates", "job": { "id": "1234", "title": "Senior React Engineer" }, "candidatePoolSize": 312, "candidatePoolTruncated": false, "topMatches": [ { "rank": 1, "contactId": "5678", "name": "…", "email": "…", "overall": 95, "mustHaveScore": 100, "gateTriggered": false, "matched": ["React", "TypeScript", "Node.js"], "missing": [], "details": { "…": "the full match object — see ats-matching.md" } } ], "totalScored": 312, "belowThreshold": 47, "executionMs": 1840 } ``` Four fields decide whether you can trust the ranking, and all four are easy to skip: - **`candidatePoolSize`** is how many contacts were actually scored, after filtering. - **`candidatePoolTruncated`** warns that the pool hit its bound. **Check it.** A truncated pool means a genuinely good candidate may simply not have been read — a ranking that is silently incomplete is worse than one that says so. - **`totalScored` versus `belowThreshold`** is how many were removed by the display minimums rather than by their score. A `topMatches` shorter than `limit` is usually this, not a shortage of candidates. - **`executionMs`** is the honest cost. A full-tier pool costs several seconds and several HubSpot search calls; do not put this call in a loop or on a page load. **Per-posting weight overrides are applied automatically.** If the posting carries `match_weight_override_json`, `/match/run` reads it and scores with it — you do not pass overrides in the request. ### `POST /crm/match/persist` — store a score on an application ```json { "applicationId": "", "match": { "overall": 95, "rank": 1, "…": "…" }, "version": "v2" } ``` Writes `match_score`, `match_score_version`, `candidate_rank` and `match_details_json` onto the application. `match_details_json` is truncated at 65,000 characters to stay inside HubSpot's property limit — a concern only for a posting with a very large number of listed skills. `match_score_version` is taken from the engine that produced the score, not from your request. You may override it, but do not — it is what tells a later reader whether two stored scores are comparable. See [ats-matching.md](ats-matching.md) § What the engine returns. Ranking and persisting are separate calls on purpose: scoring is cheap and repeatable, writing to the CRM is neither. ### The `recalculate-match` workflow action The supported way to keep a score current. Add it to any HubSpot workflow enrolling `ats_application` records; it reads the application's associated contact and posting, scores the pair with the same engine, and by default writes the result back. | | | |---|---| | **Input** | `persist` — set it to `false` for a scored-but-not-stored run | | **Outputs** | `match_score`, `gate_triggered` — both usable in later workflow branches | | **On failure** | `FAIL_CONTINUE` with the reason, so a broken record does not halt the workflow | It fails cleanly and reports why when the application has no linked candidate or posting — an application that was created outside the normal flow is the usual cause. > [!IMPORTANT] > **Nothing recalculates a score by itself.** No score updates when a Contact's skills change, when a posting's requirements are edited, or when settings are saved. A stored `match_score` is a snapshot of the moment it was written, and `match_details_json` carries `settings_snapshot` precisely so an old score can still be read correctly. If you want scores to track reality, build the workflow — enrol on the property changes you care about and call the action. ## Errors Every route answers `{ success: false, error: "…" }` with a meaningful status. Prescribed behaviour rather than a list of codes: | Status | Means | What your integration should do | |---|---|---| | `400` | A required field is missing or unusable | Fix the call. Never retry unchanged | | `401` | The status-link token is invalid or expired | Prompt for a new link. This is an ordinary state, not an error to report | | `403` | The portal is not installed, or its licence is inactive | Stop. Retrying will not help; this needs an operator | | `404` | The posting does not exist, or is not public | Treat as gone. Do not fall back to listing everything | | `409` | The posting no longer accepts applications, or a required object is not installed on the portal | Show the candidate the message; escalate the install case to an operator | | `429` | Rate limited, upstream | Back off and retry. The service already retries HubSpot's own limits internally | | `5xx` | Upstream or service failure | Retry once with backoff, then surface it. Do not resubmit an application blindly — check for the duplicate response first | `200` with `{ duplicate: true }` is a success, not an error. Handle it before your generic error path. ## Limits and bounds Every bound is stated so a silently-truncated result cannot read as a complete one. | Bound | Value | What happens at the edge | |---|---|---| | Posting listing | 1,000 postings | Pagination stops. Not a realistic ceiling for a careers site | | Candidate pool for a match run | 2,000 contacts | `candidatePoolTruncated: true` in the response | | Candidate pool filter | Contacts marked open to work, where the portal has that property | Portals that have not migrated their schema score the whole contact base up to the bound — see [ats-setup.md](ats-setup.md) | | Postings scored per candidate run | 500 live postings | The default search bound | | `match_details_json` | 65,000 characters | Truncated on write | | HubSpot rate limits | Upstream | Retried internally on `429` and `5xx`, three attempts, honouring `Retry-After` | ## What does not exist yet Stated plainly, because each one is something an integrator would otherwise plan around and discover late. - **No per-integrator credential.** There is no API key, client id or service account for a third-party system. The public surface is portal-gated and the CRM surface is not designed for outside callers. A system that needs ATS data reads it from the portal through HubSpot's own APIs. - **No outbound webhooks.** ATS does not push events anywhere. HubSpot workflows are the event mechanism — every state change ATS makes is a property write, which a workflow can enrol on. - **No resume parsing.** `POST /crm/document/:id/reparse` returns **409** with the reason, and changes nothing. Uploaded documents are stored with `parse_status: 'skipped'`; `not_started` is reserved for a parse that is genuinely coming, so a status read tells an integrator which of the two it is looking at. Until 2026-08-11 the endpoint returned 200 and set `parse_status: 'queued'` — a state nothing consumed — so anything built to poll for `parsed` would have waited forever. - **No public read of a single application.** By design. The status-link flow is the only candidate-facing read, and it returns the reduced shape. - **No batch match endpoint.** `/crm/match/run` ranks one posting against many candidates or one candidate against many postings. Scoring many pairs means many calls, and at that volume the workflow action is the better instrument. ## Change policy - **The endpoint index** is generated from the service's policy manifest and checked on every commit, in both repositories. It cannot drift. - **The public embed contract** — the four careers calls and their request shapes — is the stable part. Additive fields may appear; existing ones will not change meaning. - **`match_details_json`'s shape** is stable in its top-level keys and its per-dimension `score`/`weight`/`included`. Per-dimension diagnostic fields may gain entries. - **The CRM surface is internal** and may change without notice. Build against it only if we have agreed to that with you. - Everything else carries the `verified:` date at the top of this page and no stronger guarantee. # ATS — install & operate > Load this file when: installing, upgrading or operating the ATS on a portal — choosing a schema tier, provisioning the careers pages, filling the properties matching depends on, running `admin/migrate-schema`, or working out why the job board is empty, why every candidate scores the same, or what an uninstall removes. How scoring works: [ats-matching.md](ats-matching.md). What the app is: [ats-reference.md](ats-reference.md). > 🔒 **Works by Design system — not a HubSpot platform feature.** The ATS is our HubSpot app; these steps apply to a portal we have installed it on. Portable regardless: the tier decision below is the general answer to HubSpot's custom-object limit, and the "matching only sees what somebody typed" section is true of any scoring system laid over a CRM. This page is written for the person who owns the portal. It is ordered as the work actually happens: decide the tier, install, provision the pages, fill the data, tune, verify. The section that most often gets skipped is § What matching needs, and skipping it is the single most common reason a working install produces useless rankings. ## Before you start | Requirement | Why | |---|---| | A HubSpot plan that includes **custom objects** | Postings, applications, interviews and documents are custom objects. Without them the app installs but the job board cannot exist — the dynamic job page is skipped rather than left as an unpublishable draft | | Custom-object **allowance** for your chosen tier | 4 objects at the default. Most Enterprise portals cap around 10; the full 19-object tier needs a raised allowance agreed with HubSpot first | | A **CMS-hosted site** on the portal, if you want the careers pages provisioned | The seven standard pages are created on your own domain, in your own theme | | An active **licence** for the app | Every route checks it. An inactive licence answers `403` and no amount of retrying helps | ## The tier decision — make it before installing The schema installs in tiers because 19 objects does not fit on a normal portal. Choose the smallest one that covers the process you actually run; upgrading later is additive and never deletes. | Tier | Objects | Choose it when | |---|---|---| | `minimal` | 2 | You want the public job board and nothing else. This is what the OAuth callback provisions on its own | | `lean` (**default**) | 4 | Normal. Postings, applications, interviews and documents — a working ATS | | `standard` | 6 | You need a separate candidate profile object or structured offers | | `full` | 19 | Requisitions, compliance records, scorecards, skill junctions. Requires a raised object allowance and a deliberate decision | > [!IMPORTANT] > **The OAuth install deliberately provisions only the `minimal` tier.** A public app must not create a dozen custom objects on a portal simply because somebody clicked install. Everything above `minimal` is a separate, deliberate step — the Settings page's Status tab, or the tier-upgrade action. If your postings and applications are missing after install, this is why, and the fix is to upgrade the tier rather than to reinstall. Upgrades only ever add. Going from `lean` to `standard` creates the two new objects and leaves everything else untouched; there is no downgrade, because a downgrade would mean deleting records. ## What install provisions | Provisioned | Detail | |---|---| | Custom objects | Per the tier above, with their properties and association labels | | Contact properties | An **ATS – Candidate Matching** property group on the standard Contact object, holding every candidate-side field matching reads | | `app_setting` records | All 57 settings — matching, résumé reading, geocoding and travel time — seeded at their defaults, plus app-level settings such as the public base URL | | Careers pages | 7 standard pages, on request — `/careers`, `/careers/jobs`, the dynamic job detail page, `/careers/apply`, `/careers/thanks`, `/careers/application-status` and `/careers/alerts`. All seven embed the same module with a different view | | Workflow actions | 4 actions, available in the workflow builder immediately | | Sample workflows | Two, created **disabled**, so nothing runs until you enable it. A third — *Score new applications* — is **not** provisioned and is described in the install result instead: any workflow using one of the app's own actions must be created in the editor, because HubSpot binds it to an app connection no API can set. It is one step and worth the minute — it is what makes every new application arrive already scored | The dynamic job-detail page is bound to the portal's own `ats_job_posting` object. That binding is per-portal, which is why object type ids are resolved at runtime everywhere and never hardcoded. ## The settings page Seven tabs, in the app's listing in HubSpot: | Tab | What it is for | |---|---| | **Status** | Installed tier, which objects are present, licence state, and the tier upgrade | | **General** | App-level settings — the careers site base URL used to build candidate links and the job-board feed, and the employer name that feed publishes | | **Matching** | The scoring rules — weights, the must-have gate, and each dimension's own group — plus the dry-run calibrator. [ats-matching.md](ats-matching.md) | | **CV Parsing** | Reading a résumé into the properties the engine scores on: your AI provider and model, the daily cap, and what a parse is allowed to overwrite | | **Geolocation** | How an address becomes coordinates, and how a commute is measured. Two different providers, two different bills | | **Sample data** | Load or remove a working demo dataset in one action | | **Danger zone** | Destructive operations. Read § What an uninstall destroys first | Settings are staged and saved as a group — **nothing takes effect until Save**, and Discard restores what is stored. **A provider key is the exception**: it saves on its own, encrypted, and is never sent back to the page afterwards. ### The three keys, and what each one costs Everything that leaves the portal needs a key you own, and none of it runs until you set one. Nothing here is billed by us. | Setting | Provider | Charged per | Off means | |---|---|---|---| | CV Parsing → provider + key | Your AI provider (Claude by default) | Résumé parsed, bounded by the daily cap you set | Uploads still attach; nothing reads them, and every surface says so | | Geolocation → geocoding key | OpenCage | Address turned into coordinates, once — coordinates are stored | No distance anywhere, and radius matching cannot score | | Geolocation → travel key | Google | Commute looked up, every time a recruiter presses the button | The distance line still shows; the commute button does not | ## What matching needs Read this section even if you are not tuning anything. **The engine scores properties, and only properties.** It does not infer, it does not open a CV at scoring time, and a field nobody filled is not a low score — it is a dimension that either scores a neutral 50 or drops out of the calculation entirely. Résumé reading matters here precisely because it is the step that *turns a CV into properties*; without it, an attached CV is invisible to every rule below. The failure this produces is quiet and specific: **every candidate scores roughly the same**, in a band around 50, and the ranking looks arbitrary. That is not a broken engine. That is an engine with nothing to compare. ### On the job posting | Field | Feeds | |---|---| | Must-have skills · Nice-to-have skills | The two skill dimensions, and the must-have gate | | Seniority level | Seniority | | Remote model · City · Country | Location | | Salary minimum and maximum (the public ones) | Salary | | Minimum years of experience | Experience | | Required education level | Education | | Required languages | Languages | | Industry | Industry | | Match keywords | Keywords, if you have enabled that dimension | | Match weight override (JSON) | Per-posting weights — see [ats-matching.md](ats-matching.md) | A posting that states no requirement for a dimension **excludes** it. That is deliberate and usually right, but it means a thinly filled posting scores everyone on very little. If rankings need to be comparable across postings, make these fields required in your own process — the app does not enforce it. ### On the candidate — the Contact Every field lives in the **ATS – Candidate Matching** property group: seniority level, remote preference, willing to relocate, total years of experience, primary skills, languages, education level, industry, match keywords, salary expectation minimum and maximum, and open to work. Four ways they get filled, in descending order of how much you should rely on them: 1. **A workflow or an import.** The reliable answer. Map whatever you already hold onto these properties. 2. **Résumé reading.** With an AI provider key set, an uploaded CV is read and these fields are written from it. This is the only route that fills them for a candidate who applied through the form and exists nowhere else in your data — which is most of them. It never blanks a field the CV does not mention, and never overwrites what a recruiter typed unless you have told it to. 3. **A recruiter, on the Contact record.** Fine at low volume, and it outranks a parse by design. 4. **The application form itself.** It captures the applicant, not their full profile. On its own it does not populate the matching fields. Leave résumé reading off and route 2 disappears: an uploaded CV attaches to the candidate and nothing reads it, which is the state that produces the quiet failure above. > [!IMPORTANT] > **`ats_open_to_work` is what scopes the candidate pool.** A posting-to-candidates match run filters to contacts marked open to work, and scores up to 2,000 of them. If nobody is marked, the run falls back to scanning your contact base — which on a large portal means genuine candidates can fall outside the bound and never be scored at all, with only `candidatePoolTruncated` in the response to say so. Set this property, from the application form's own workflow if nothing else. ## Migrating an already-installed portal Properties added after a portal was installed do not appear on it by themselves. Run the schema migration: ```text POST https://api.worksby.design/apps/ats/admin/migrate-schema ``` It is idempotent and additive — it creates what is missing and touches nothing that exists. Two properties are worth checking for specifically, because their absence is silent: - **`ats_open_to_work`** — without it the candidate pool is unfiltered, as above. - **`stage_entered_at`** — without it, time-in-stage reports nothing. There is no error; the number is simply always empty. Run this after any upgrade. A portal that has been installed for a while and has never been migrated is the usual explanation for "that feature is documented but I do not have it". > [!IMPORTANT] > **Read the migration's own result, do not just check it returned 200.** It reports `contactProperties: { created, failed }`, and a property that could not be created lands in `failed` while the call still reports success. Each one silently degrades a matching dimension to "missing data" for every candidate — a wrong ranking rather than an error, which is the hardest kind to notice. > > The failure mode worth knowing: **HubSpot requires property labels to be unique across the whole object**, not just across one app's properties. A label that collides with a standard HubSpot property is rejected, and the app carries on. If `failed` is non-empty, the fix is on our side — send us the list. **Sample data does not update itself.** The seeder reuses records that already exist rather than overwriting them, which is what stops a re-seed duplicating your demo. The consequence: if the shipped sample set gains fields, an already-seeded portal will not pick them up from a re-seed. Remove the sample data and add it again, or ask us to backfill. ## Verification In order. Each step proves the one before it actually worked, and the last one is observable by a person rather than by a log. 1. **Licence** — `GET /apps/ats/license-status` for the portal answers active. 2. **Objects** — the Settings page's Status tab lists the tier and shows every expected object present. 3. **Pages** — `/careers/jobs` loads on the portal's own domain and renders the module rather than an empty page. 4. **A posting appears** — create a posting, publish it, and confirm it is on `/careers/jobs`. If the list is empty, the posting is not both `live` **and** active; nothing else filters it. 5. **The job page resolves** — open the posting from the list. The detail page is the dynamic page; if it 404s, the dynamic page was skipped at install because the custom object did not exist yet. 6. **An application lands** — apply through the form, then find the `ats_application` record associated to both the Contact and the posting. Submitting the same form twice must produce one record, not two. 7. **The status link works** — request a status link for that email, follow it, and see the application with a simplified status. 8. **Matching produces a real ranking** — on the Settings page's Matching tab, run the dry-run calibrator against that posting's record id. **This is the step that proves the data, not just the plumbing.** A spread of scores means the candidate properties are populated; every candidate clustered near 50 means they are not, and § What matching needs is where to go. 9. **The job-board feed resolves** — open `https://api.worksby.design/apps/ats/jobs.xml?portalId=` in a browser. A `409` means the careers base URL is not set. Otherwise check the `X-Jobs-Included` header against the number of live postings, and **click one `` from the document** — that link is what a candidate will follow from a job board, and it is the only part no test can prove for you. ## When something is wrong | Symptom | Usual cause | Fix | |---|---|---| | Careers page is empty | Postings are not `live`, or not active | Publish the posting; check `record_status` | | Job detail page 404s | The dynamic page was skipped at install because the custom object did not exist | Provision the object, then re-provision the pages | | Every route answers `403` | Licence inactive, or the portal's install record is missing | Reactivate the licence; reinstall the app if the install record is gone | | A route answers `409` naming an object | That object is not installed at the portal's tier | Upgrade the tier from the Status tab | | **Every candidate scores about the same** | Candidate properties are empty | § What matching needs. This is not a matching bug | | A candidate you expected is absent from a ranking | They are not marked open to work, or the pool bound was hit | Check `candidatePoolTruncated` in the response; set `ats_open_to_work` | | A setting saves but changes nothing | It may be one of the 3 that are not wired to the engine | The generated table in [ats-matching.md](ats-matching.md) names them | | Scores never update | Nothing recalculates automatically | Build a workflow using the `recalculate-match` action | | Time-in-stage is always empty | `stage_entered_at` was never provisioned | Run the schema migration | | The job-board feed answers `409` | The careers site base URL is not set | Settings page → General | | A board shows your hostname as the employer | Employer Name is unset, so the feed falls back to it | Settings page → General | | A live posting is missing from the feed | It has no slug, or it is marked `noindex` | Compare `X-Jobs-Skipped` against the number of live postings | | A board shows no salary on a role that has one | `salary_visibility` is `hidden` for that posting — the feed honours it | Change it on the posting if you intended to publish it | | New applications arrive unscored | No scoring workflow exists yet — it is a manual one-step build, not provisioned | Workflow editor: enrol on application create, add *Recalculate match score* | | A workflow using an ATS action does nothing, with no error | It was created over the API rather than in the editor. It will enable, report enabled, and never fire | Rebuild that step in the workflow editor | ## Security posture The shareable summary. A deeper analysis exists and is not published — ask, and we will walk through it. - **Candidate data stays on your portal.** The app stores postings, applications, candidates and documents as CRM records on your own HubSpot. No external database holds them. - **The public careers routes authenticate the portal, not the caller.** That is appropriate for a public careers page: anyone may read live postings and anyone may apply. What is protected is the data — public routes return only the public field set, never another candidate's application. - **The candidate status link is a signed, expiring token bound to one contact.** Requesting one always returns the same acknowledgement, so it cannot be used to discover whether somebody applied. - **The candidate never sees your internal pipeline.** The status portal returns a reduced shape by design — no internal stage name, no rejection reason. - **The recruiter surface is bound to the individual user, cryptographically.** HubSpot signs every call a CRM card makes and puts the caller's portal and user id inside that signature; the backend refuses anything that does not validate, and takes the identity only from the signed values. Recruiter actions are therefore attributable to a real person. Stated in full, with its two limits, in [ats-integration.md](ats-integration.md). - **Workflow actions are genuine signed HubSpot webhooks**, verified per request. ## What an uninstall destroys > [!CAUTION] > **Removing the app archives every ATS record on the portal and deletes the schemas** — postings, applications, interviews, documents, and their custom properties and association labels. It discovers what to remove dynamically, so a portal that was upgraded to a higher tier has that tier's objects removed too. > > **It also archives `app_setting`, which is where all 57 of your tuned settings live — including your provider keys.** They are not backed up anywhere. If you have calibrated the engine, record the values you changed before uninstalling — a fresh install starts from the defaults. Two things survive, and both matter: - **Contacts survive.** Candidates are Contacts, and Contacts are yours. The ATS property group's values remain on them, so a reinstall finds the candidate data intact. - **Schema deletion can fail without the uninstall failing.** HubSpot refuses to drop a schema while soft-deleted records or workflow references remain. That is reported in the result rather than treated as fatal, so check the result rather than assuming a clean removal. Retrying later usually clears it. Reinstalling is not a repair tool. For a portal that is behaving oddly, the migration above fixes missing properties, and the tier upgrade fixes missing objects — both without touching data. # Keyring — reference > Load this file when: evaluating, demoing, or getting oriented on **Keyring** — what it does, its surfaces, its CRM data model, and how it connects to a point-of-sale system, to Events and to Commerce. Integrating a till against it is [keyring-integration.md](keyring-integration.md); installing and running it is [keyring-setup.md](keyring-setup.md). > 🔒 **Works by Design system — not a HubSpot platform feature.** Keyring is our HubSpot app; nothing here ships with a HubSpot subscription and it is not on your portal unless we installed it. Portable regardless: the ledger-not-balance data model, per-location authority checked server-side, and stateless HMAC tokens as an identity layer. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md) ## What Keyring is Keyring turns a HubSpot portal into the system of record for **value a customer holds**: points, gift-card balances, vouchers, and the membership that ties them to a person. It covers the whole loop — issue the value, deliver it, let the customer see it, spend it at a counter, and record what happened — without a separate loyalty database. The point is not that it stores loyalty data. It is *where* it stores it: every balance, every voucher and every transaction is an ordinary CRM record on your own portal. So lists, workflows, reports and marketing email work on loyalty the same way they work on deals — and a marketer can build a campaign on "customers holding an unspent gift card" without asking anyone to export anything. | | | |---|---| | **Kind** | Private HubSpot app (platform 2026.03), installed per portal | | **Built for** | Businesses with physical counters — hospitality, retail, venues, multi-site groups | | **Stores data in** | Custom CRM objects on your own portal. No external database holds your customer data | | **Customer surfaces** | A web wallet, a durable loyalty-card QR, offer pages, and email | | **Staff surfaces** | A browser-based scanner for any phone or tablet, a staff home screen, and three CRM cards | | **How to get it** | Not distributed. Ask for a walkthrough: [w@reus.ie](mailto:w@reus.ie) | > [!IMPORTANT] > **Maturity, stated plainly.** Keyring is a working system that runs on demo portals and is exercised by an automated regression on every change. It has **not** yet been deployed against a real customer's live data. It carries deliberate demo-grade trade-offs — the most consequential being that installation is deliberately open so the app can bootstrap itself, and that revoking one till's access means rotating the secret for every till. Those are documented, and closing them is a bounded piece of work, but it is **a prerequisite, not a formality**, before real value moves through it. Ask for the hardening pass as part of any real deployment. ## What it actually does Five capabilities, each of which is a thing a business already does badly on spreadsheets. ### Gift cards with real balances A gift card is a record with a balance, a currency and a code. A customer can spend part of it — the till sends the amount, Keyring does the arithmetic and returns what is left. Balances can be topped up at the counter. Nothing about this needs the original purchase to be found again. ### Vouchers and offers Four kinds of instrument, and the difference matters at the till: a **balance** (spend part), a **product** voucher (this specific item), a **percentage** discount, or an **entry** into a raffle or lottery draw. A voucher can be valid everywhere or only at named venues, single-use or good until it expires, and either generic (anyone holding the code) or bound to one named person. ![A customer holding their phone, showing an offer in the Keyring wallet: a greeting by name, a percentage-discount card with its code and expiry date, buttons to accept or decline it, and a list of recent points activity beneath.](../media/keyring/wallet.webp) **The customer surface adapts to what the programme is for.** The same wallet renders as a single offer to accept or decline, as a full wallet of cards and vouchers, as a membership card with a points balance, or as a plain gift card — chosen once per install rather than built four times. The screen above is the single-offer shape; the same link on a loyalty install shows the membership and everything held. ### Loyalty points and programmes A programme defines what a point is called, what it is worth per unit spent, and whether it applies brand-wide or at particular venues. Points are awarded from a spend amount or as a literal adjustment, and the balance lives on the membership record — never on an association, never derived by summing history at read time. ### Membership and identity A customer enrols at the counter or through a workflow, and gets a durable QR loyalty card delivered by email. Scanning that card at any venue identifies them — and the response deliberately carries **name only**, never email or phone, so a shared till device is not a customer-data leak. ### Multi-location control Every venue is a record with **capabilities** — whether staff there may identify, redeem, earn or issue. A till at a redeem-only bar cannot issue vouchers no matter what it sends, because that check happens on the server against the venue record, not in the app the staff member is holding. This is the mechanism that makes a franchise or a multi-site group safe to run on one portal. ### And the reason a marketer cares: the journey is recorded Every incentive carries a **journey state** alongside its value state — `issued → sent → viewed → claimed → redeemed`, plus `declined`, `expired` and `cancelled` — each with its own timestamp, and each stamped only when the real event happens. Nothing is inferred to make a funnel look complete. That gives you the questions a loyalty programme normally cannot answer: how many issued vouchers were never opened, how long between claiming and spending, which venue converts a claimed offer best, what proportion of a campaign is still outstanding as a liability. Because they are CRM records, those are HubSpot lists and reports — not a data request. ![The Keyring overview page inside HubSpot: headline figures for outstanding voucher value, redemption rate, breakage and active voucher count, above an offer funnel charting vouchers from issued through viewed and claimed to redeemed, and a bar chart of redemptions broken down by venue.](../media/keyring/app-page.webp) The operator's own view of that, without building a report: outstanding value as a liability figure, breakage, and the funnel from issued to redeemed with the drop-off visible at each stage. ## What it does not do Stated up front, because these are the ones that reshape a plan if you find them late. - **It is not a payment system.** Keyring moves stored value, not money. Card payments are a separate concern — on our stack that is [Commerce](commerce-reference.md). - **It does not push events to you.** There are no outbound webhooks to arbitrary URLs. An integrator that wants activity polls for it. - **It is not a till.** There is no cash drawer, no receipt printing (beyond a loyalty-card label), no product catalogue and no tax handling. It sits beside your point-of-sale system and tells it what discount to apply. - **It does not do per-integrator credentials yet.** Access is per venue and per staff identity, which is real authority — but one integrator cannot be revoked without rotating the secret for everyone. See [keyring-integration.md](keyring-integration.md) § What does not exist yet. - **It does not reconcile your accounting.** Outstanding gift-card value is visible as CRM data; turning that into a liability figure in your ledger is your finance system's job. ## What people see | Who | Surface | What it is | |---|---|---| | **Customer** | Wallet | A web page reached from a link or their loyalty-card QR. What it shows depends on the experience profile the install chose — a single offer to accept or decline, a full wallet of cards and vouchers, a membership with its points balance, or a plain gift card. No login, no password — the link itself is the credential | | **Customer** | Loyalty card | A durable QR delivered by email and re-mintable at any time. Also printable as a physical card on a label printer | | **Customer** | Offer page | Accept or decline a specific offer; the decision is recorded against the record, and only the actual owner can make it | | **Staff** | Scanner | A browser page on any phone or tablet. Scan a card or a code, see what it is, and act — redeem, top up, award, issue, enrol or void | | **Staff** | Staff home | Today's activity at this venue, so a supervisor can see the counter's own ledger without CRM access | | **Operator** | CRM cards | Keyring context on the contact record inside HubSpot, including manual point adjustment | | **Operator** | App pages | An in-HubSpot cockpit — programme and venue health, plus a guided tour of how the parts fit | | **Operator** | Settings | Eight tabs covering programmes, venues, tokens, email, integrations and security | ## Data model Everything is a CRM record. Five objects plus two extensions: | Object | What it holds | |---|---| | `location` | A venue, and its `capabilities` — the gate on what staff may do there | | `loyalty_program` | A programme: what a point is called, its earn rate, and its scope | | `loyalty_membership` | One record per person per programme. **The points balance lives here** | | `loyalty_transaction` | The ledger — one append-only row per movement of points or value, with the acting staff identity and the venue | | `incentive` | Vouchers, gift cards and entries: the value layer (`redemption_type`, balances, validity) and the journey layer (`engagement_status` and its eight timestamps) | | `contact` (extended) | Three added properties: the magic link, the QR image, and a locale hint | | `app_setting` | Key-value configuration, using our shared app-settings pattern | ![The Keyring tab on a HubSpot contact record: total points earned across programmes, a Rewards Club balance, and a wallet of ten active items including gift cards with their codes and a reload button on each. The record's own sidebar carries loyalty enrolment date, points balance and tier as ordinary contact properties.](../media/keyring/crm-card.webp) That is the whole argument for the data model in one screen: the balance, the vouchers and the tier are contact and CRM records, sitting in the record layout beside everything else your team already works from — not in a system someone has to be given a second login for. Three things about this model are deliberate and worth copying whatever you build on: - **The ledger is append-only.** There is no hard delete anywhere on the public surface. Reversing something writes a compensating row and marks the original — the audit trail survives the correction. - **Balances are stored, not derived.** A balance is a property on the membership or the incentive, updated transactionally. Summing a ledger at read time is where loyalty systems go wrong at scale. - **The value state and the journey state are separate properties.** Whether a voucher has been spent and whether the customer ever opened the email are different questions, and overloading one property to answer both loses the ability to ask either. Associations use **labels**, not raw type ids — an incentive relates to a venue in two distinct ways (valid at, issued by), which a single unlabelled association cannot express. Resolve label names at runtime; never hardcode a numeric type id. The same goes for object type ids: resolve them from the schema list at runtime, because they differ per portal. ## Integration seams | With | What crosses | |---|---| | **A point-of-sale or property-management system** | Two independent directions. **Inbound:** the POS calls Keyring to scan, read, redeem, top up, award and enrol — the full contract is [keyring-integration.md](keyring-integration.md). **Outbound:** after Keyring has committed a redemption, an adapter reports it to the provider's own system so the discount lands on the real bill. Adapters exist for Mews, Zettle and Shopify; all three are built and currently dormant, and none has been through a provider sandbox yet | | **[Commerce](commerce-reference.md)** | Vouchers at online checkout. A shopper enters a Keyring code in the cart; Commerce validates it, recomputes the discount from the live cart server-side, and spends it against Keyring only when the order genuinely reaches paid. Commerce integrates as an external POS would — no shared code, no privileged lane | | **[Events](events-app-api.md)** | One scanner at the door. A venue running the Keyring scanner can hand a scanned `EVT-…` event ticket straight to the Events scanner rather than rejecting it, so door staff carry one device for loyalty cards and tickets. One-directional, and off unless configured | The direction of authority never changes: **Keyring is authoritative for the redemption.** External systems are informed after the fact, never asked for permission. That is what makes a counter keep working when a provider's API is down, and it is the single most important thing to understand before designing against it. ## Where to go next | You are | Read | |---|---| | Integrating a till, kiosk or third-party system | [keyring-integration.md](keyring-integration.md) — auth, the QR contract, every endpoint, idempotency, error handling | | Installing or operating it on a portal | [keyring-setup.md](keyring-setup.md) — prerequisites, the manual steps, verification, what a reinstall destroys | | Evaluating whether it fits | This page, then [ask for a walkthrough](mailto:w@reus.ie). It demos live in about ten minutes, ending with a real scan | # Keyring — integration guide > Load this file when: an external system — a point-of-sale, a kiosk, a property-management module, any third-party client — needs to call **Keyring** over HTTP: scan its QR codes, read voucher and member state, redeem, top up a balance, award points, issue or void. This is the **inbound** direction. What Keyring is and what it stores: [keyring-reference.md](keyring-reference.md). Installing and operating it: [keyring-setup.md](keyring-setup.md). > 🔒 **Works by Design system — not a HubSpot platform feature.** These endpoints exist only on portals where Keyring is installed. Portable regardless: the till-as-service-identity model, per-location authority checked server-side, and the idempotency-key contract. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md) ## Read this first Three facts decide most of your design, and all three are easier to accept now than to discover in week three. 1. **Keyring is authoritative for the redemption.** Your till asks Keyring to spend value and Keyring decides. It does not ask your system for permission, and it does not wait for your system to confirm. If you need the discount to land on the customer's real bill, that is a second step your till performs *after* Keyring has said yes. 2. **A till is a staff identity, not an API client.** There is deliberately no separate API-key lane. The operator creates a staff contact representing the till, and your integration signs in as that contact at one specific venue. Everything you may do follows from that venue's capabilities, enforced on the server. 3. **Every mutation needs an idempotency key that you generate and persist.** Not per HTTP attempt — per business operation. This is a hard requirement, not a recommendation; a call without one is refused. **Base URL:** `https:///hs/serverless/` JSON in, JSON out. Every response carries `success`, and every failure carries a structured `code` you can switch on. The portal's domain is the customer's own HubSpot site domain — ask the operator for it, and do not assume it matches any other environment. ## Authenticating a till Sign in once per till, then reuse the token until it is refused. ```http POST /hs/serverless/keyring-staff-session Content-Type: application/json { "email": "pos-frontdesk@venue.example", "locationId": "", "pin": "" } ``` ```json { "success": true, "token": "", "expiresAt": 1786000000000 } ``` Send the returned value as `staffToken` on every authenticated call. - **The PIN is required whenever one is configured**, which it should be on any install that matters. Provision it to the till like any other credential. - **Tokens expire** — eight hours by default. On `401` with `STAFF_TOKEN_INVALID` or `STAFF_TOKEN_REQUIRED`, re-mint and retry the call **once**. Do not treat it as fatal, and do not re-mint on every request. - **One token binds one venue.** A multi-till site mints one per till, which also gives you per-till attribution — every ledger row records the acting staff identity. - **Revocation is secret rotation**, which invalidates every outstanding token at once. Your till should survive that by simply re-minting; build the 401 path first and you get this for free. - **The token is a credential.** HTTPS only, never logged, never in a receipt or a URL, stored the way you would store a password. ## What a scan contains Your scanner will meet exactly two payload shapes: | Scanned thing | Payload | What it means | |---|---|---| | **Member loyalty card** | A wallet URL — `https:///wallet?t=` | Identity. Take the `t` parameter; that is the input to `keyring-resolve-token` | | **Voucher or gift card** | The code itself, e.g. `WELCOME-A1B2C3` | Value. The code *is* the record's identifier — input to `keyring-fetch-incentive` and to a redeem | The parsing rule, which mirrors the scanner's own: if the payload is a URL, take `?t=` if present, otherwise the last path segment that looks like a code. A bare `xxxxx.yyyyy` base64url pair is a token. Anything else is a code. Codes are case-insensitive — the server uppercases them. > [!WARNING] > If you decode token payloads in a browser, **re-pad the base64url before decoding**. Browsers reject unpadded base64 where server-side decoders accept it, so this works in your Node prototype and fails on the tablet. ## Endpoint index Generated by cross-checking two independent sources inside the app — the deployment manifests that tell HubSpot what to publish, and the policy manifest whose own test asserts full coverage against them. Neither the list nor the auth column is typed by hand, and a disagreement between them fails the build rather than producing a plausible table. **Public**, **Staff** and **Customer** are surfaces you may call. **Workflow** endpoints are HubSpot automation backends and **Install** endpoints belong to provisioning; both are listed for completeness and are not part of your contract. **Retired** endpoints still answer, with `410` — they are listed precisely so a cached client gets an explanation instead of a mystery. 27 endpoints — 8 public, 7 staff, 4 customer, 4 workflow, 2 install, 2 retired. | Endpoint | Surface | Auth required | |---|---|---| | `GET/POST keyring-fetch-locations` | Public | None | | `GET/POST keyring-qr-sheet` | Public | None | | `GET/POST keyring-reports` | Public | None (no personal data) | | `GET/POST keyring-workflow-options` | Public | None (no personal data) | | `POST keyring-claim-incentive` | Public | None | | `POST keyring-fetch-incentive` | Public | None | | `POST keyring-fetch-product` | Public | None | | `POST keyring-staff-session` | Public | None (the PIN is enforced inside when one is configured) | | `POST keyring-enrollment-create` | Staff | Staff token + the location capability for the action | | `POST keyring-enrollment-search` | Staff | Staff token + the location capability for the action | | `POST keyring-generate-incentives` | Staff | Staff token + the location capability for the action | | `POST keyring-print-member-card` | Staff | Staff token + the location capability for the action | | `POST keyring-staff-config` | Staff | Staff token + the location capability for the action | | `POST keyring-staff-home` | Staff | Staff token + the location capability for the action | | `POST keyring-transact` | Staff | Staff token + the location capability for the action | | `POST keyring-fetch-wallet` | Customer | Customer token — identity comes from the token | | `POST keyring-offer-decision` | Customer | Customer token — identity comes from the token | | `POST keyring-request-link` | Customer | Customer token — identity comes from the token | | `POST keyring-resolve-token` | Customer | Customer token — identity comes from the token | | `POST keyring-workflow-award-points` | Workflow | HubSpot signature (v3) | | `POST keyring-workflow-enroll` | Workflow | HubSpot signature (v3) | | `POST keyring-workflow-issue-voucher` | Workflow | HubSpot signature (v3) | | `POST keyring-workflow-mint-magic-link` | Workflow | HubSpot signature (v3) | | `GET keyring-status` | Install | None | | `POST keyring-bootstrap` | Install | None (deliberate — it runs before any token can exist) | | `POST keyring-user-create` | Retired | Retired — returns `410 ENDPOINT_RETIRED` | | `POST keyring-user-lookup` | Retired | Retired — returns `410 ENDPOINT_RETIRED` | ## Use case → endpoint | What the till needs to do | Call | Requires | |---|---|---| | Read a voucher or gift card — balance, type, validity, whether it is claimed | `keyring-fetch-incentive` `{ incentiveId: }` | 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. ![Staff at a bar counter holding a phone showing the Keyring scanner after a scan: the venue name and its capabilities across the top, then the instrument identified as a gift card with its code, an active status, the balance remaining, and an amount field beside a redeem button. A printed voucher carrying a QR code lies on the counter.](../media/keyring/scanner-result.webp) 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.