@bettercms-ai/mcp 0.83.0 → 0.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -86
- package/SKILL.md +160 -160
- package/dist/index.js +38 -9
- package/dist/index.js.map +1 -1
- package/dist/kit-catalog.json +1509 -1509
- package/dist/skills/bettercms/SKILL.md +206 -206
- package/dist/skills/bettercms/references/accessibility.md +171 -171
- package/dist/skills/bettercms/references/animation.md +296 -296
- package/dist/skills/bettercms/references/astro.md +148 -148
- package/dist/skills/bettercms/references/bindings.md +366 -366
- package/dist/skills/bettercms/references/componentize.md +185 -185
- package/dist/skills/bettercms/references/components.md +415 -415
- package/dist/skills/bettercms/references/content-experiments.md +62 -62
- package/dist/skills/bettercms/references/content-modeling.md +382 -382
- package/dist/skills/bettercms/references/detection.md +465 -465
- package/dist/skills/bettercms/references/dock-input.md +121 -121
- package/dist/skills/bettercms/references/editor-chrome.md +92 -92
- package/dist/skills/bettercms/references/external-content.md +78 -78
- package/dist/skills/bettercms/references/finish-site.md +377 -377
- package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
- package/dist/skills/bettercms/references/frameworks.md +159 -159
- package/dist/skills/bettercms/references/getting-started.md +156 -156
- package/dist/skills/bettercms/references/integrations.md +364 -364
- package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
- package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
- package/dist/skills/bettercms/references/localization.md +85 -85
- package/dist/skills/bettercms/references/media-and-brand.md +181 -181
- package/dist/skills/bettercms/references/next.md +189 -189
- package/dist/skills/bettercms/references/other-frameworks.md +85 -85
- package/dist/skills/bettercms/references/portable-text.md +213 -213
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
- package/dist/skills/bettercms/references/publishing.md +289 -289
- package/dist/skills/bettercms/references/seo.md +357 -357
- package/dist/skills/bettercms/references/shopify-headless.md +52 -52
- package/dist/skills/bettercms/references/site-blueprints.md +895 -895
- package/dist/skills/bettercms/references/site-review.md +74 -74
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
- package/dist/skills/bettercms/references/worked-examples.md +885 -885
- package/dist/skills/bettercms/references/write-limits.md +217 -217
- package/package.json +1 -1
|
@@ -1,364 +1,364 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Custom integrations"
|
|
3
|
-
description: "Connect any vendor with a public API (Greenhouse, JobDiva, Shopify, HubSpot, Zoom, Apollo...) to a BetterCMS site: declare the integration, name its secrets and where they go, have an admin approve it, call the vendor through the server, sync lists both ways (two-way write-back), receive the vendor's webhooks, run your own server code, serve live reads to the site, reference vendor items, sign in with OAuth, reuse it as a template, and recipes per vendor."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Custom integrations
|
|
7
|
-
|
|
8
|
-
**Outcome.** The site shows the user's own data from another tool (jobs from their ATS, products
|
|
9
|
-
from their store, webinars from their video platform) through an integration you declared, that
|
|
10
|
-
an admin approved, and that never exposes a secret. There is no native per-vendor integration:
|
|
11
|
-
any vendor with a public HTTPS API and a key works the same way.
|
|
12
|
-
|
|
13
|
-
Everything here can also be done by a person in the dashboard (Site settings → Integrations), with the
|
|
14
|
-
same records. What you declare, they see and can edit.
|
|
15
|
-
|
|
16
|
-
## 1. The order of work
|
|
17
|
-
|
|
18
|
-
1. **Model where the data lands.** A collection per vendor list (Jobs, Products), with fields for
|
|
19
|
-
what the site shows (content-modeling.md). Nothing vendor-specific in the names.
|
|
20
|
-
2. **Name the secrets, never handle them.** Pick env var names (`GREENHOUSE_API_KEY`,
|
|
21
|
-
`SHOPIFY_ADMIN_TOKEN`). The user sets the VALUES under Settings → Environment variables
|
|
22
|
-
(production) or with `bettercms env push`. `list_env_vars` shows which names exist. Never ask
|
|
23
|
-
the user to paste a key into the chat, and never put one in a file you write.
|
|
24
|
-
3. **Declare the integration** with `create_integration` (section 2).
|
|
25
|
-
4. **Have an admin approve it** (section 3).
|
|
26
|
-
5. **Discover the shape** with `test_integration_fetch` (section 4).
|
|
27
|
-
6. **Sync the list into the collection** (section 6) and check the entries.
|
|
28
|
-
7. **Show it on the site**: register components for the data (components.md); they appear in the
|
|
29
|
-
section library under "Add section".
|
|
30
|
-
|
|
31
|
-
## 2. Declaring one
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
create_integration {
|
|
35
|
-
slug: "shopify", name: "Shopify products", vendor: "Shopify",
|
|
36
|
-
allowedHosts: ["acme.myshopify.com"],
|
|
37
|
-
secrets: { TOKEN: "SHOPIFY_ADMIN_TOKEN" }, // template name -> env var KEY
|
|
38
|
-
inject: { headers: { "X-Shopify-Access-Token": "{{secret.TOKEN}}" } }, // where it goes
|
|
39
|
-
settings: { shop: "acme" } // non-secret, the panel reads it
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
- `allowedHosts` are exact hostnames, called over https on the default port only. A redirect must
|
|
44
|
-
stay on the same origin. List the API host and, for a token exchange, the auth host.
|
|
45
|
-
- `secrets` maps a template name to an env var KEY. `inject` is the ONLY place a secret goes: the
|
|
46
|
-
headers and query parameters added to every request to the integration's hosts. A request you
|
|
47
|
-
send (a sync's, `test_integration_fetch`'s, a panel's) carries no `{{...}}` at all.
|
|
48
|
-
- Vendor calls are GET only. `allowPost: true` lets other requests through: vendor writes, and
|
|
49
|
-
search APIs that take POST (Apollo). Ask for it only when the integration needs it.
|
|
50
|
-
- The answer has `secretsPresent` per name: false means the user has not set that env var yet.
|
|
51
|
-
|
|
52
|
-
## 3. Approval
|
|
53
|
-
|
|
54
|
-
Until an admin approves the integration, every vendor call and sync is refused (409
|
|
55
|
-
`integration_not_approved`). You cannot approve it: no tool and no header does. The create and
|
|
56
|
-
update answers carry `approval.changes` (in plain words: which hosts it will call, which env vars
|
|
57
|
-
it will read, where it sends them) and `approval.approveUrl`. Give the user both, and say why the
|
|
58
|
-
integration needs each host and secret. They (or an admin) press Approve on that page.
|
|
59
|
-
|
|
60
|
-
Changing `allowedHosts`, `secrets`, `inject`, `allowPost` or `auth` later withdraws the approval until an admin
|
|
61
|
-
approves again. Name, settings, panel and syncs never need approval.
|
|
62
|
-
|
|
63
|
-
## 4. Calling the vendor
|
|
64
|
-
|
|
65
|
-
`test_integration_fetch { integration, url, method?, headers?, query?, json? }` runs one request
|
|
66
|
-
through the server and returns `{ status, headers, body }`. The `inject` secrets are added
|
|
67
|
-
server-side and every secret value is replaced by `[secret]` in what comes back. Bodies over 1 MB
|
|
68
|
-
are truncated. Use it to read the response shape before you map anything: list paths and ids are
|
|
69
|
-
JSON Pointers (`/jobs`, `/id`, `/data/0/attributes/name`).
|
|
70
|
-
|
|
71
|
-
## 5. Token exchanges
|
|
72
|
-
|
|
73
|
-
Some vendors trade credentials for a short-lived token (Zoom Server-to-Server OAuth, JobDiva).
|
|
74
|
-
Declare the exchange once and inject `{{token}}`:
|
|
75
|
-
|
|
76
|
-
```
|
|
77
|
-
auth: {
|
|
78
|
-
request: { method: "POST", url: "https://zoom.us/oauth/token",
|
|
79
|
-
query: { grant_type: "account_credentials", account_id: "{{secret.ACCOUNT_ID}}" },
|
|
80
|
-
headers: { authorization: "Basic {{secret.BASIC}}" } },
|
|
81
|
-
tokenPath: "/access_token", expiresInPath: "/expires_in"
|
|
82
|
-
},
|
|
83
|
-
inject: { headers: { authorization: "Bearer {{token}}" } }
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
The token is cached until shortly before it expires, and the token endpoint itself can never be
|
|
87
|
-
called with `test_integration_fetch`. A vendor that wants `base64(id:secret)` in a Basic header
|
|
88
|
-
gets it as ONE env var holding the encoded string (the user can make it with
|
|
89
|
-
`printf 'id:secret' | base64`). `tokenPath: ""` reads a plain-text response as the token.
|
|
90
|
-
|
|
91
|
-
## 6. Syncing a list into a collection
|
|
92
|
-
|
|
93
|
-
A sync reads every page of a vendor list and writes each item as a DRAFT entry of one collection,
|
|
94
|
-
matched on its `externalKey`: a re-run updates what changed and leaves the rest `unchanged`, so a
|
|
95
|
-
sync is safe to run any number of times. Add it with `update_integration { syncs: [...] }` (the
|
|
96
|
-
list is replaced whole) and run it with `run_integration_sync { integration, sync, waitSeconds: 20 }`.
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
{ key: "jobs", label: "Open roles", contentModelId: "<Jobs collection id>",
|
|
100
|
-
request: { method: "GET", url: "https://boards-api.greenhouse.io/v1/boards/acme/jobs",
|
|
101
|
-
query: { content: "true" } },
|
|
102
|
-
pagination: { style: "none" }, // page: param=1,2,3 | cursor: param=<cursorPath value> | link: rel="next"
|
|
103
|
-
items: "/jobs", externalKey: "/id", slug: "/title",
|
|
104
|
-
map: { title: "/title", location: "/location/name", department: { path: "/departments", transform: "join" },
|
|
105
|
-
applyUrl: "/absolute_url", body: "/content" },
|
|
106
|
-
publish: false, schedule: "hourly",
|
|
107
|
-
// For webhooks (section 9): how to fetch ONE item by its id. {{item}} is the id.
|
|
108
|
-
item: { request: { method: "GET", url: "https://boards-api.greenhouse.io/v1/boards/acme/jobs/{{item}}" },
|
|
109
|
-
pointer: "", onMissing: "delete" } }
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
- `map` keys are the collection's field keys; values are JSON Pointers into one item, with an
|
|
113
|
-
optional `transform` (`string`, `number`, `boolean`, `join` for a list of names).
|
|
114
|
-
- Run it once with `publish: false`, read a few entries, then turn on `publish` and a `schedule`
|
|
115
|
-
(`hourly` or `daily`) if the user wants the site to follow the vendor on its own.
|
|
116
|
-
A sync that publishes is a publish: adding one, changing one, or running one needs a connection
|
|
117
|
-
allowed to publish (`PUBLISH_NOT_GRANTED` otherwise). Ask the user to turn it on from the dashboard.
|
|
118
|
-
- The run reports `created`, `updated`, `unchanged`, `failed`, `pages` and `items`. An item with no
|
|
119
|
-
external key, or a row a field rejects (a required field the vendor left empty), is `failed`, and
|
|
120
|
-
a done run says why in `error` (the first few, one per line); adjust the map or the field and run
|
|
121
|
-
again. Two items whose `slug` pointer gives one slug: the second takes its external key as a suffix. A developer-locked field is never written by a sync.
|
|
122
|
-
- Entries remember where they came from (`provenance.source` = `integration:<slug>`), and
|
|
123
|
-
`list_content_entries` with `externalKey` finds the one for a vendor id.
|
|
124
|
-
|
|
125
|
-
## 7. A custom panel
|
|
126
|
-
|
|
127
|
-
The stock integration page (Site settings → Integrations → the integration) already shows the
|
|
128
|
-
approval, secrets, syncs with Import now and Auto-sync, a request tester and the runs. Build a
|
|
129
|
-
panel only when the user wants their own screen for it (a store picker, a mapping matrix, an
|
|
130
|
-
"Add component" drawer), the way Storesynk draws one for Shopify in Webflow.
|
|
131
|
-
|
|
132
|
-
1. Install `@bettercms-ai/dock-input` (0.2.0 or later) and React in the site repo if they are not there.
|
|
133
|
-
2. Write `bettercms/panels/<name>.tsx` (lowercase, hyphens), default-exporting a React component.
|
|
134
|
-
It is built with the site, served from the project's own isolated origin and drawn in a
|
|
135
|
-
sandboxed frame on the integration's page and in the Visual editor's dock.
|
|
136
|
-
3. Set `update_integration { integration, panel: "<name>" }`. A panel never needs approval.
|
|
137
|
-
|
|
138
|
-
```tsx
|
|
139
|
-
import type { PanelProps } from "@bettercms-ai/dock-input";
|
|
140
|
-
import { Button, Switch } from "@bettercms-ai/kit"; // when the site has the kit
|
|
141
|
-
|
|
142
|
-
export default function ShopifyPanel({ integration, call, place }: PanelProps) {
|
|
143
|
-
const sync = integration.syncs[0] as { key: string; schedule?: string | null };
|
|
144
|
-
return (
|
|
145
|
-
<div>
|
|
146
|
-
<Switch checked={!!sync?.schedule} onCheckedChange={(on) =>
|
|
147
|
-
call("syncs.set", { syncs: integration.syncs.map((s) => (s === sync ? { ...s, schedule: on ? "hourly" : null } : s)) })} />
|
|
148
|
-
<Button onClick={() => call("sync.run", { sync: sync.key })}>Import Shopify data</Button>
|
|
149
|
-
{place === "dock" ? <Button onClick={() => call("editor.insertSection", { componentId: "<buy-button component id>" })}>Add buy button</Button> : null}
|
|
150
|
-
</div>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
The panel can make no request of its own (its CSP is `connect-src 'none'`), and it never sees a token
|
|
156
|
-
or a secret; navigating its frame away ends it. A `syncs.set` that would make a sync publish, run on a
|
|
157
|
-
schedule, write into another collection, or add or remove a sync asks the person first. Everything goes through `call(method, params)`, run by the dashboard with the signed-in
|
|
158
|
-
person's rights:
|
|
159
|
-
|
|
160
|
-
| Method | Params → result |
|
|
161
|
-
|---|---|
|
|
162
|
-
| `vendor.fetch` | `{ method?, url, query?, headers?, json? }` → `{ status, headers, body }`, as test_integration_fetch |
|
|
163
|
-
| `sync.run` / `sync.status` | `{ sync }` → the run / `{ runId }` → the run |
|
|
164
|
-
| `runs.list` | → the last runs |
|
|
165
|
-
| `settings.set` | `{ settings }` → settings (non-secret, ≤ 16 KB; store ids, toggles) |
|
|
166
|
-
| `syncs.set` | `{ syncs }` → syncs (schedules, mappings) |
|
|
167
|
-
| `models.list` | → the collections a sync can write into |
|
|
168
|
-
| `components.list` / `editor.insertSection` | the project's section components / `{ componentId }` adds one to the open page (dock only) |
|
|
169
|
-
| `function.run` / `function.status` | `{ name, input? }` → the run / `{ runId }` → the run (output, logs, error) |
|
|
170
|
-
| `reference.search` | `{ reference, query }` → up to 20 `{ id, title, image }` |
|
|
171
|
-
|
|
172
|
-
## 8. Recipes
|
|
173
|
-
|
|
174
|
-
| Vendor | Hosts | Secrets and inject | List request | Items / key |
|
|
175
|
-
|---|---|---|---|---|
|
|
176
|
-
| Greenhouse job board | `boards-api.greenhouse.io` | none (public board) | `GET /v1/boards/{board}/jobs?content=true` | `/jobs` / `/id` |
|
|
177
|
-
| Shopify Admin | `{shop}.myshopify.com` | `TOKEN` → header `X-Shopify-Access-Token` | `GET /admin/api/2024-10/products.json?limit=250` | `/products` / `/id`, Link-header pages |
|
|
178
|
-
| HubSpot CRM | `api.hubapi.com` | `TOKEN` (private app) → header `authorization: Bearer {{secret.TOKEN}}` | `GET /crm/v3/objects/contacts?limit=100` | `/results` / `/id`, cursor `/paging/next/after` |
|
|
179
|
-
| Zoom webinars | `zoom.us`, `api.zoom.us` | `ACCOUNT_ID`, `BASIC` + token exchange → `authorization: Bearer {{token}}` | `GET https://api.zoom.us/v2/users/me/webinars` | `/webinars` / `/id`, cursor `/next_page_token` |
|
|
180
|
-
| JobDiva | `api.jobdiva.com` | `CLIENT_ID`, `USERNAME`, `PASSWORD` + token exchange → `authorization: Bearer {{token}}` | the job search endpoint of their API | per their docs |
|
|
181
|
-
| Apollo | `api.apollo.io` | `KEY` → header `x-api-key`; `allowPost: true` | `POST /api/v1/mixed_people/search` | `/people` / `/id` |
|
|
182
|
-
|
|
183
|
-
Form submissions to a CRM do not need an integration: attach the form to HubSpot or a webhook
|
|
184
|
-
(forms-and-interactions.md). Client-side widgets (a Shopify cart) use the vendor's PUBLIC
|
|
185
|
-
storefront token in site code; an Admin token never goes into the site.
|
|
186
|
-
|
|
187
|
-
## 9. Webhooks: the vendor tells the site something changed
|
|
188
|
-
|
|
189
|
-
Polling hourly is the fallback. Most vendors sign a webhook on every change; declare one per event
|
|
190
|
-
and the site follows within seconds. **The payload is never trusted**: only the item's id is read
|
|
191
|
-
from it, and the item is re-fetched from the vendor through the sync's `item` request (section 6).
|
|
192
|
-
A 404 there means the vendor deleted it (`onMissing: "delete"` removes the entry).
|
|
193
|
-
|
|
194
|
-
```
|
|
195
|
-
update_integration { hooks: [{
|
|
196
|
-
key: "products",
|
|
197
|
-
verify: { scheme: "hmac", header: "X-Shopify-Hmac-Sha256", secret: "WEBHOOK_SECRET",
|
|
198
|
-
algorithm: "sha256", encoding: "base64" },
|
|
199
|
-
deliveryIdHeader: "X-Shopify-Webhook-Id",
|
|
200
|
-
action: { kind: "sync", sync: "products", mode: "item", itemKey: "/id" } }] }
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
- `secret` is a template NAME declared in `secrets` (an env var the user sets). Hooks are on the
|
|
204
|
-
approval surface: adding or changing one needs an admin again.
|
|
205
|
-
- `get_integration` → `hookUrls.<key>` is the URL to paste at the vendor (Shopify: Settings →
|
|
206
|
-
Notifications → Webhooks, JSON). Recent deliveries are in `hookEvents`.
|
|
207
|
-
- `mode: "full"` runs the whole sync instead (a vendor that sends no id). `action: { kind:
|
|
208
|
-
"function", function: "<name>" }` hands the verified payload to a function (section 10).
|
|
209
|
-
- Signature schemes (`verify`):
|
|
210
|
-
|
|
211
|
-
| Vendor | header | algorithm / encoding | prefix | message |
|
|
212
|
-
|---|---|---|---|---|
|
|
213
|
-
| Shopify | `X-Shopify-Hmac-Sha256` | sha256 / base64 | | `{body}` |
|
|
214
|
-
| GitHub | `X-Hub-Signature-256` | sha256 / hex | `sha256=` | `{body}` |
|
|
215
|
-
| Greenhouse | `Signature` | sha256 / hex | `sha256 ` | `{body}` |
|
|
216
|
-
| Stripe | `Stripe-Signature` | sha256 / hex, `parse: "stripe"` | | `{timestamp}.{body}` |
|
|
217
|
-
| Slack | `X-Slack-Signature` | sha256 / hex, `timestampHeader: "X-Slack-Request-Timestamp"` | `v0=` | `v0:{timestamp}:{body}` |
|
|
218
|
-
| HubSpot v3 | `X-HubSpot-Signature-v3` | sha256 / base64, `timestampHeader: "X-HubSpot-Request-Timestamp"` | | `{method}{url}{body}{timestamp}` |
|
|
219
|
-
| a shared token | `scheme: "token"`, the header it arrives in | | `prefix` if any | |
|
|
220
|
-
|
|
221
|
-
A signed timestamp outside `toleranceSeconds` (300 by default) is refused as a replay, and a
|
|
222
|
-
redelivery (same delivery id, or the same body) is answered without running twice. Rotating a
|
|
223
|
-
webhook secret in Settings keeps the old value valid for 24 hours by default.
|
|
224
|
-
|
|
225
|
-
## 10. Functions: your own code, never your secrets
|
|
226
|
-
|
|
227
|
-
For what a declarative sync cannot say (send a form to HubSpot, reshape a webhook payload, a nightly
|
|
228
|
-
report), write `bettercms/functions/<name>.ts` in the site repo and declare it:
|
|
229
|
-
|
|
230
|
-
```ts
|
|
231
|
-
// bettercms/functions/to-hubspot.ts
|
|
232
|
-
export default async (event: { formId: string; submission: { data: Record<string, string> } }, ctx) => {
|
|
233
|
-
const { email, name } = event.submission.data;
|
|
234
|
-
const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts", {
|
|
235
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
236
|
-
body: JSON.stringify({ properties: { email, firstname: name } }),
|
|
237
|
-
});
|
|
238
|
-
console.log("hubspot", res.status);
|
|
239
|
-
return { status: res.status };
|
|
240
|
-
};
|
|
241
|
-
```
|
|
242
|
-
```
|
|
243
|
-
update_integration { functions: [{ name: "to-hubspot", triggers: ["form:<formId>"], cpuMs: 1000 }] }
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
- It runs on Cloudflare Workers when the site's build includes it. Its `fetch()` reaches ONLY this
|
|
247
|
-
integration's approved hosts, JSON bodies only, and the server adds the `inject` secrets: the code
|
|
248
|
-
never sees a key, so never write one into it (there is no env to read).
|
|
249
|
-
- Triggers: `manual` (run_integration_function, the panel's `function.run`), `form:<formId>`
|
|
250
|
-
(each accepted submission: `{ formId, submission: { id, data } }`), `schedule:hourly|daily`, and
|
|
251
|
-
any hook whose action names it (`{ hook, payload }`). `ctx` = `{ integration, function, trigger,
|
|
252
|
-
runId, settings }`.
|
|
253
|
-
- Return `{ upsert: [{ sync, externalKey, data, slug? }] }` to write entries into a sync's
|
|
254
|
-
collection (drafts, like a sync). `run_integration_function { waitSeconds: 20 }` answers with the
|
|
255
|
-
output, the captured `console` lines and any error; runs are in `get_integration.functionRuns`.
|
|
256
|
-
- If the server answers `functions_not_enabled`, functions are not available yet on this
|
|
257
|
-
BetterCMS server: use a sync or an endpoint instead.
|
|
258
|
-
|
|
259
|
-
## 11. Site endpoints: live reads for visitors, no key in the page
|
|
260
|
-
|
|
261
|
-
A page that must show something live (stock, a search, today's webinars) calls the integration's
|
|
262
|
-
endpoint, never the vendor: the key stays on the server and only the fields you pick leave.
|
|
263
|
-
|
|
264
|
-
```
|
|
265
|
-
update_integration { endpoints: [{
|
|
266
|
-
key: "stock",
|
|
267
|
-
request: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/variants/{{param.variant}}.json" },
|
|
268
|
-
params: { variant: { pattern: "[0-9]{1,20}", required: true } },
|
|
269
|
-
pick: { available: "/variant/inventory_quantity", price: "/variant/price" },
|
|
270
|
-
cacheSeconds: 60 }] }
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
- The site calls `GET <endpointUrls.stock>?variant=123` (public, CORS open, no credentials) and gets
|
|
274
|
-
`{ data: { available, price } }`. With `items: "/products"` each list item is picked instead.
|
|
275
|
-
- Every param must be declared with an anchored `pattern`; an unknown or non-matching param is a
|
|
276
|
-
400. Patterns with a repeated group inside a repeat (`(a+)+`) are refused: they can hang.
|
|
277
|
-
- Answers are cached for `cacheSeconds` per params; visitors are rate-limited per IP.
|
|
278
|
-
- Endpoints are on the approval surface (what anyone on the internet can read).
|
|
279
|
-
|
|
280
|
-
## 12. Live references: point at a vendor item without copying it
|
|
281
|
-
|
|
282
|
-
```
|
|
283
|
-
update_integration { references: [{
|
|
284
|
-
key: "product", label: "Shopify product",
|
|
285
|
-
search: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products.json", query: { title: "{{query}}", limit: "20" } },
|
|
286
|
-
lookup: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{id}}.json" },
|
|
287
|
-
items: "/products", item: "/product", id: "/id", title: "/title", image: "/image/src",
|
|
288
|
-
pick: { price: "/variants/0/price" } }] }
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
- The field that holds one: type `json` with `ui.reference: { integration: "<integration slug>",
|
|
292
|
-
key: "<reference key>" }`; editors get a search picker. Its value is `{ integration, reference, id, title, image? }` (title
|
|
293
|
-
and image are a snapshot for editors).
|
|
294
|
-
- `search_integration_reference` finds ids for an agent. The site resolves the live item at
|
|
295
|
-
`<refs URL>/<id>` (`GET /api/v1/public/integrations/<projectId>/<slug>/refs/<key>/<id>`):
|
|
296
|
-
`{ id, title, image?, ...pick }`, cached `cacheSeconds` (300 by default).
|
|
297
|
-
|
|
298
|
-
## 13. Sign in with OAuth (the customer's own OAuth app)
|
|
299
|
-
|
|
300
|
-
For vendors that only offer OAuth (Google, HubSpot public apps, Zoom user-level): the user creates an
|
|
301
|
-
OAuth app at the vendor, sets its client id and secret as env vars, and declares:
|
|
302
|
-
|
|
303
|
-
```
|
|
304
|
-
update_integration { secrets: { CLIENT_ID: "GOOGLE_CLIENT_ID", CLIENT_SECRET: "GOOGLE_CLIENT_SECRET" },
|
|
305
|
-
allowedHosts: ["oauth2.googleapis.com", "www.googleapis.com"],
|
|
306
|
-
oauth: { authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
307
|
-
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
308
|
-
scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
|
|
309
|
-
clientId: "CLIENT_ID", clientSecret: "CLIENT_SECRET",
|
|
310
|
-
extraAuthorizeParams: { access_type: "offline", prompt: "consent" } },
|
|
311
|
-
inject: { headers: { authorization: "Bearer {{token}}" } } }
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
- After approval, `start_integration_signin` gives a `url` for the USER to open (and the
|
|
315
|
-
`redirectUri` they register in their OAuth app first). They sign in at the vendor; the tokens are
|
|
316
|
-
stored encrypted and refreshed by the server. `{{token}}` in `inject` is the access token.
|
|
317
|
-
- `oauth` and `auth` (a token exchange) do not mix. The token URL's host must be an allowed host.
|
|
318
|
-
|
|
319
|
-
## 14. Templates, the call log, alerts
|
|
320
|
-
|
|
321
|
-
- `save_integration_template` stores an integration's shape for the workspace (no value, no
|
|
322
|
-
settings; the collections its syncs write into are copied). `install_integration_template` puts
|
|
323
|
-
it on another project: missing collections are created, the integration waits for that project's
|
|
324
|
-
admin, and `secretsNeeded` lists the env vars to set there.
|
|
325
|
-
- `get_integration.calls` is the call log: every vendor call (source, host, path, status, time),
|
|
326
|
-
never a query string or body. When runs fail `alertAfter` times in a row (3 by default; 0 = never)
|
|
327
|
-
the workspace's admins are notified once; the next success clears it.
|
|
328
|
-
|
|
329
|
-
## 15. Two-way sync: BetterCMS edits go back to the vendor
|
|
330
|
-
|
|
331
|
-
A sync pulls (section 6) and follows the vendor's webhooks (section 9). Add `writeBack` and edits
|
|
332
|
-
made in BetterCMS (dashboard, Visual editor, API, you) go back to the vendor too:
|
|
333
|
-
|
|
334
|
-
```
|
|
335
|
-
{ key: "products", ..., map: { title: "/title", description: "/body_html", price: "/variants/0/price" },
|
|
336
|
-
item: { request: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json" }, pointer: "/product" },
|
|
337
|
-
writeBack: {
|
|
338
|
-
fields: ["description"], // the fields BetterCMS owns
|
|
339
|
-
on: "save", // or "publish": only what goes live is pushed
|
|
340
|
-
update: { method: "PUT", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json",
|
|
341
|
-
json: { product: { body_html: "{{field.description}}" } } },
|
|
342
|
-
create: { method: "POST", url: "https://acme.myshopify.com/admin/api/2024-10/products.json",
|
|
343
|
-
json: { product: { title: "{{field.title}}", body_html: "{{field.description}}" } }, idPointer: "/product/id" },
|
|
344
|
-
delete: { method: "DELETE", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json" } } }
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
- `{{item}}` is the vendor id (the entry's externalKey). `{{field.KEY}}` is the entry's value; a
|
|
348
|
-
JSON string that is exactly one placeholder keeps the value's type (a number stays a number).
|
|
349
|
-
`update` may use only `fields`; `create` may use any field of the new entry.
|
|
350
|
-
- Needs `allowPost: true`, and `writeBack` is on the approval surface (it writes, and may delete,
|
|
351
|
-
at the vendor). `delete` is never done unless declared.
|
|
352
|
-
- **Who wins.** Vendor-owned fields (`map` only) always follow the vendor. For `fields`, a BetterCMS
|
|
353
|
-
edit is pushed within about a minute; until it has reached the vendor, a pull or webhook does not
|
|
354
|
-
overwrite it. After that, the newest change from either side wins.
|
|
355
|
-
- **No loops.** Both sides' agreed values are remembered per entry; our own push coming back
|
|
356
|
-
through a webhook, or a pull's own write, is recognised and never pushed again.
|
|
357
|
-
- **New and deleted entries.** An entry made in BetterCMS is created at the vendor (with `create`)
|
|
358
|
-
and keeps the vendor's id as its externalKey, so the next pull updates it instead of duplicating.
|
|
359
|
-
Without `create` it stays BetterCMS-only. Deleting an entry deletes the vendor item only with `delete`.
|
|
360
|
-
- **Failures** retry with backoff (1, 2, 4, 8, 16 minutes), then count toward `alertAfter`. Pushes
|
|
361
|
-
appear in the call log with source `push`.
|
|
362
|
-
- **Known windows** (seconds to a minute, rare): an edit saved while a pull is writing that same entry,
|
|
363
|
-
or within a minute of changing `writeBack.fields`, can be replaced by the vendor's value. Change the
|
|
364
|
-
field list when editors are idle, and re-save an edit if a pull overwrote it.
|
|
1
|
+
---
|
|
2
|
+
title: "Custom integrations"
|
|
3
|
+
description: "Connect any vendor with a public API (Greenhouse, JobDiva, Shopify, HubSpot, Zoom, Apollo...) to a BetterCMS site: declare the integration, name its secrets and where they go, have an admin approve it, call the vendor through the server, sync lists both ways (two-way write-back), receive the vendor's webhooks, run your own server code, serve live reads to the site, reference vendor items, sign in with OAuth, reuse it as a template, and recipes per vendor."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Custom integrations
|
|
7
|
+
|
|
8
|
+
**Outcome.** The site shows the user's own data from another tool (jobs from their ATS, products
|
|
9
|
+
from their store, webinars from their video platform) through an integration you declared, that
|
|
10
|
+
an admin approved, and that never exposes a secret. There is no native per-vendor integration:
|
|
11
|
+
any vendor with a public HTTPS API and a key works the same way.
|
|
12
|
+
|
|
13
|
+
Everything here can also be done by a person in the dashboard (Site settings → Integrations), with the
|
|
14
|
+
same records. What you declare, they see and can edit.
|
|
15
|
+
|
|
16
|
+
## 1. The order of work
|
|
17
|
+
|
|
18
|
+
1. **Model where the data lands.** A collection per vendor list (Jobs, Products), with fields for
|
|
19
|
+
what the site shows (content-modeling.md). Nothing vendor-specific in the names.
|
|
20
|
+
2. **Name the secrets, never handle them.** Pick env var names (`GREENHOUSE_API_KEY`,
|
|
21
|
+
`SHOPIFY_ADMIN_TOKEN`). The user sets the VALUES under Settings → Environment variables
|
|
22
|
+
(production) or with `bettercms env push`. `list_env_vars` shows which names exist. Never ask
|
|
23
|
+
the user to paste a key into the chat, and never put one in a file you write.
|
|
24
|
+
3. **Declare the integration** with `create_integration` (section 2).
|
|
25
|
+
4. **Have an admin approve it** (section 3).
|
|
26
|
+
5. **Discover the shape** with `test_integration_fetch` (section 4).
|
|
27
|
+
6. **Sync the list into the collection** (section 6) and check the entries.
|
|
28
|
+
7. **Show it on the site**: register components for the data (components.md); they appear in the
|
|
29
|
+
section library under "Add section".
|
|
30
|
+
|
|
31
|
+
## 2. Declaring one
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
create_integration {
|
|
35
|
+
slug: "shopify", name: "Shopify products", vendor: "Shopify",
|
|
36
|
+
allowedHosts: ["acme.myshopify.com"],
|
|
37
|
+
secrets: { TOKEN: "SHOPIFY_ADMIN_TOKEN" }, // template name -> env var KEY
|
|
38
|
+
inject: { headers: { "X-Shopify-Access-Token": "{{secret.TOKEN}}" } }, // where it goes
|
|
39
|
+
settings: { shop: "acme" } // non-secret, the panel reads it
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `allowedHosts` are exact hostnames, called over https on the default port only. A redirect must
|
|
44
|
+
stay on the same origin. List the API host and, for a token exchange, the auth host.
|
|
45
|
+
- `secrets` maps a template name to an env var KEY. `inject` is the ONLY place a secret goes: the
|
|
46
|
+
headers and query parameters added to every request to the integration's hosts. A request you
|
|
47
|
+
send (a sync's, `test_integration_fetch`'s, a panel's) carries no `{{...}}` at all.
|
|
48
|
+
- Vendor calls are GET only. `allowPost: true` lets other requests through: vendor writes, and
|
|
49
|
+
search APIs that take POST (Apollo). Ask for it only when the integration needs it.
|
|
50
|
+
- The answer has `secretsPresent` per name: false means the user has not set that env var yet.
|
|
51
|
+
|
|
52
|
+
## 3. Approval
|
|
53
|
+
|
|
54
|
+
Until an admin approves the integration, every vendor call and sync is refused (409
|
|
55
|
+
`integration_not_approved`). You cannot approve it: no tool and no header does. The create and
|
|
56
|
+
update answers carry `approval.changes` (in plain words: which hosts it will call, which env vars
|
|
57
|
+
it will read, where it sends them) and `approval.approveUrl`. Give the user both, and say why the
|
|
58
|
+
integration needs each host and secret. They (or an admin) press Approve on that page.
|
|
59
|
+
|
|
60
|
+
Changing `allowedHosts`, `secrets`, `inject`, `allowPost` or `auth` later withdraws the approval until an admin
|
|
61
|
+
approves again. Name, settings, panel and syncs never need approval.
|
|
62
|
+
|
|
63
|
+
## 4. Calling the vendor
|
|
64
|
+
|
|
65
|
+
`test_integration_fetch { integration, url, method?, headers?, query?, json? }` runs one request
|
|
66
|
+
through the server and returns `{ status, headers, body }`. The `inject` secrets are added
|
|
67
|
+
server-side and every secret value is replaced by `[secret]` in what comes back. Bodies over 1 MB
|
|
68
|
+
are truncated. Use it to read the response shape before you map anything: list paths and ids are
|
|
69
|
+
JSON Pointers (`/jobs`, `/id`, `/data/0/attributes/name`).
|
|
70
|
+
|
|
71
|
+
## 5. Token exchanges
|
|
72
|
+
|
|
73
|
+
Some vendors trade credentials for a short-lived token (Zoom Server-to-Server OAuth, JobDiva).
|
|
74
|
+
Declare the exchange once and inject `{{token}}`:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
auth: {
|
|
78
|
+
request: { method: "POST", url: "https://zoom.us/oauth/token",
|
|
79
|
+
query: { grant_type: "account_credentials", account_id: "{{secret.ACCOUNT_ID}}" },
|
|
80
|
+
headers: { authorization: "Basic {{secret.BASIC}}" } },
|
|
81
|
+
tokenPath: "/access_token", expiresInPath: "/expires_in"
|
|
82
|
+
},
|
|
83
|
+
inject: { headers: { authorization: "Bearer {{token}}" } }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The token is cached until shortly before it expires, and the token endpoint itself can never be
|
|
87
|
+
called with `test_integration_fetch`. A vendor that wants `base64(id:secret)` in a Basic header
|
|
88
|
+
gets it as ONE env var holding the encoded string (the user can make it with
|
|
89
|
+
`printf 'id:secret' | base64`). `tokenPath: ""` reads a plain-text response as the token.
|
|
90
|
+
|
|
91
|
+
## 6. Syncing a list into a collection
|
|
92
|
+
|
|
93
|
+
A sync reads every page of a vendor list and writes each item as a DRAFT entry of one collection,
|
|
94
|
+
matched on its `externalKey`: a re-run updates what changed and leaves the rest `unchanged`, so a
|
|
95
|
+
sync is safe to run any number of times. Add it with `update_integration { syncs: [...] }` (the
|
|
96
|
+
list is replaced whole) and run it with `run_integration_sync { integration, sync, waitSeconds: 20 }`.
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
{ key: "jobs", label: "Open roles", contentModelId: "<Jobs collection id>",
|
|
100
|
+
request: { method: "GET", url: "https://boards-api.greenhouse.io/v1/boards/acme/jobs",
|
|
101
|
+
query: { content: "true" } },
|
|
102
|
+
pagination: { style: "none" }, // page: param=1,2,3 | cursor: param=<cursorPath value> | link: rel="next"
|
|
103
|
+
items: "/jobs", externalKey: "/id", slug: "/title",
|
|
104
|
+
map: { title: "/title", location: "/location/name", department: { path: "/departments", transform: "join" },
|
|
105
|
+
applyUrl: "/absolute_url", body: "/content" },
|
|
106
|
+
publish: false, schedule: "hourly",
|
|
107
|
+
// For webhooks (section 9): how to fetch ONE item by its id. {{item}} is the id.
|
|
108
|
+
item: { request: { method: "GET", url: "https://boards-api.greenhouse.io/v1/boards/acme/jobs/{{item}}" },
|
|
109
|
+
pointer: "", onMissing: "delete" } }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `map` keys are the collection's field keys; values are JSON Pointers into one item, with an
|
|
113
|
+
optional `transform` (`string`, `number`, `boolean`, `join` for a list of names).
|
|
114
|
+
- Run it once with `publish: false`, read a few entries, then turn on `publish` and a `schedule`
|
|
115
|
+
(`hourly` or `daily`) if the user wants the site to follow the vendor on its own.
|
|
116
|
+
A sync that publishes is a publish: adding one, changing one, or running one needs a connection
|
|
117
|
+
allowed to publish (`PUBLISH_NOT_GRANTED` otherwise). Ask the user to turn it on from the dashboard.
|
|
118
|
+
- The run reports `created`, `updated`, `unchanged`, `failed`, `pages` and `items`. An item with no
|
|
119
|
+
external key, or a row a field rejects (a required field the vendor left empty), is `failed`, and
|
|
120
|
+
a done run says why in `error` (the first few, one per line); adjust the map or the field and run
|
|
121
|
+
again. Two items whose `slug` pointer gives one slug: the second takes its external key as a suffix. A developer-locked field is never written by a sync.
|
|
122
|
+
- Entries remember where they came from (`provenance.source` = `integration:<slug>`), and
|
|
123
|
+
`list_content_entries` with `externalKey` finds the one for a vendor id.
|
|
124
|
+
|
|
125
|
+
## 7. A custom panel
|
|
126
|
+
|
|
127
|
+
The stock integration page (Site settings → Integrations → the integration) already shows the
|
|
128
|
+
approval, secrets, syncs with Import now and Auto-sync, a request tester and the runs. Build a
|
|
129
|
+
panel only when the user wants their own screen for it (a store picker, a mapping matrix, an
|
|
130
|
+
"Add component" drawer), the way Storesynk draws one for Shopify in Webflow.
|
|
131
|
+
|
|
132
|
+
1. Install `@bettercms-ai/dock-input` (0.2.0 or later) and React in the site repo if they are not there.
|
|
133
|
+
2. Write `bettercms/panels/<name>.tsx` (lowercase, hyphens), default-exporting a React component.
|
|
134
|
+
It is built with the site, served from the project's own isolated origin and drawn in a
|
|
135
|
+
sandboxed frame on the integration's page and in the Visual editor's dock.
|
|
136
|
+
3. Set `update_integration { integration, panel: "<name>" }`. A panel never needs approval.
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import type { PanelProps } from "@bettercms-ai/dock-input";
|
|
140
|
+
import { Button, Switch } from "@bettercms-ai/kit"; // when the site has the kit
|
|
141
|
+
|
|
142
|
+
export default function ShopifyPanel({ integration, call, place }: PanelProps) {
|
|
143
|
+
const sync = integration.syncs[0] as { key: string; schedule?: string | null };
|
|
144
|
+
return (
|
|
145
|
+
<div>
|
|
146
|
+
<Switch checked={!!sync?.schedule} onCheckedChange={(on) =>
|
|
147
|
+
call("syncs.set", { syncs: integration.syncs.map((s) => (s === sync ? { ...s, schedule: on ? "hourly" : null } : s)) })} />
|
|
148
|
+
<Button onClick={() => call("sync.run", { sync: sync.key })}>Import Shopify data</Button>
|
|
149
|
+
{place === "dock" ? <Button onClick={() => call("editor.insertSection", { componentId: "<buy-button component id>" })}>Add buy button</Button> : null}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The panel can make no request of its own (its CSP is `connect-src 'none'`), and it never sees a token
|
|
156
|
+
or a secret; navigating its frame away ends it. A `syncs.set` that would make a sync publish, run on a
|
|
157
|
+
schedule, write into another collection, or add or remove a sync asks the person first. Everything goes through `call(method, params)`, run by the dashboard with the signed-in
|
|
158
|
+
person's rights:
|
|
159
|
+
|
|
160
|
+
| Method | Params → result |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `vendor.fetch` | `{ method?, url, query?, headers?, json? }` → `{ status, headers, body }`, as test_integration_fetch |
|
|
163
|
+
| `sync.run` / `sync.status` | `{ sync }` → the run / `{ runId }` → the run |
|
|
164
|
+
| `runs.list` | → the last runs |
|
|
165
|
+
| `settings.set` | `{ settings }` → settings (non-secret, ≤ 16 KB; store ids, toggles) |
|
|
166
|
+
| `syncs.set` | `{ syncs }` → syncs (schedules, mappings) |
|
|
167
|
+
| `models.list` | → the collections a sync can write into |
|
|
168
|
+
| `components.list` / `editor.insertSection` | the project's section components / `{ componentId }` adds one to the open page (dock only) |
|
|
169
|
+
| `function.run` / `function.status` | `{ name, input? }` → the run / `{ runId }` → the run (output, logs, error) |
|
|
170
|
+
| `reference.search` | `{ reference, query }` → up to 20 `{ id, title, image }` |
|
|
171
|
+
|
|
172
|
+
## 8. Recipes
|
|
173
|
+
|
|
174
|
+
| Vendor | Hosts | Secrets and inject | List request | Items / key |
|
|
175
|
+
|---|---|---|---|---|
|
|
176
|
+
| Greenhouse job board | `boards-api.greenhouse.io` | none (public board) | `GET /v1/boards/{board}/jobs?content=true` | `/jobs` / `/id` |
|
|
177
|
+
| Shopify Admin | `{shop}.myshopify.com` | `TOKEN` → header `X-Shopify-Access-Token` | `GET /admin/api/2024-10/products.json?limit=250` | `/products` / `/id`, Link-header pages |
|
|
178
|
+
| HubSpot CRM | `api.hubapi.com` | `TOKEN` (private app) → header `authorization: Bearer {{secret.TOKEN}}` | `GET /crm/v3/objects/contacts?limit=100` | `/results` / `/id`, cursor `/paging/next/after` |
|
|
179
|
+
| Zoom webinars | `zoom.us`, `api.zoom.us` | `ACCOUNT_ID`, `BASIC` + token exchange → `authorization: Bearer {{token}}` | `GET https://api.zoom.us/v2/users/me/webinars` | `/webinars` / `/id`, cursor `/next_page_token` |
|
|
180
|
+
| JobDiva | `api.jobdiva.com` | `CLIENT_ID`, `USERNAME`, `PASSWORD` + token exchange → `authorization: Bearer {{token}}` | the job search endpoint of their API | per their docs |
|
|
181
|
+
| Apollo | `api.apollo.io` | `KEY` → header `x-api-key`; `allowPost: true` | `POST /api/v1/mixed_people/search` | `/people` / `/id` |
|
|
182
|
+
|
|
183
|
+
Form submissions to a CRM do not need an integration: attach the form to HubSpot or a webhook
|
|
184
|
+
(forms-and-interactions.md). Client-side widgets (a Shopify cart) use the vendor's PUBLIC
|
|
185
|
+
storefront token in site code; an Admin token never goes into the site.
|
|
186
|
+
|
|
187
|
+
## 9. Webhooks: the vendor tells the site something changed
|
|
188
|
+
|
|
189
|
+
Polling hourly is the fallback. Most vendors sign a webhook on every change; declare one per event
|
|
190
|
+
and the site follows within seconds. **The payload is never trusted**: only the item's id is read
|
|
191
|
+
from it, and the item is re-fetched from the vendor through the sync's `item` request (section 6).
|
|
192
|
+
A 404 there means the vendor deleted it (`onMissing: "delete"` removes the entry).
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
update_integration { hooks: [{
|
|
196
|
+
key: "products",
|
|
197
|
+
verify: { scheme: "hmac", header: "X-Shopify-Hmac-Sha256", secret: "WEBHOOK_SECRET",
|
|
198
|
+
algorithm: "sha256", encoding: "base64" },
|
|
199
|
+
deliveryIdHeader: "X-Shopify-Webhook-Id",
|
|
200
|
+
action: { kind: "sync", sync: "products", mode: "item", itemKey: "/id" } }] }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `secret` is a template NAME declared in `secrets` (an env var the user sets). Hooks are on the
|
|
204
|
+
approval surface: adding or changing one needs an admin again.
|
|
205
|
+
- `get_integration` → `hookUrls.<key>` is the URL to paste at the vendor (Shopify: Settings →
|
|
206
|
+
Notifications → Webhooks, JSON). Recent deliveries are in `hookEvents`.
|
|
207
|
+
- `mode: "full"` runs the whole sync instead (a vendor that sends no id). `action: { kind:
|
|
208
|
+
"function", function: "<name>" }` hands the verified payload to a function (section 10).
|
|
209
|
+
- Signature schemes (`verify`):
|
|
210
|
+
|
|
211
|
+
| Vendor | header | algorithm / encoding | prefix | message |
|
|
212
|
+
|---|---|---|---|---|
|
|
213
|
+
| Shopify | `X-Shopify-Hmac-Sha256` | sha256 / base64 | | `{body}` |
|
|
214
|
+
| GitHub | `X-Hub-Signature-256` | sha256 / hex | `sha256=` | `{body}` |
|
|
215
|
+
| Greenhouse | `Signature` | sha256 / hex | `sha256 ` | `{body}` |
|
|
216
|
+
| Stripe | `Stripe-Signature` | sha256 / hex, `parse: "stripe"` | | `{timestamp}.{body}` |
|
|
217
|
+
| Slack | `X-Slack-Signature` | sha256 / hex, `timestampHeader: "X-Slack-Request-Timestamp"` | `v0=` | `v0:{timestamp}:{body}` |
|
|
218
|
+
| HubSpot v3 | `X-HubSpot-Signature-v3` | sha256 / base64, `timestampHeader: "X-HubSpot-Request-Timestamp"` | | `{method}{url}{body}{timestamp}` |
|
|
219
|
+
| a shared token | `scheme: "token"`, the header it arrives in | | `prefix` if any | |
|
|
220
|
+
|
|
221
|
+
A signed timestamp outside `toleranceSeconds` (300 by default) is refused as a replay, and a
|
|
222
|
+
redelivery (same delivery id, or the same body) is answered without running twice. Rotating a
|
|
223
|
+
webhook secret in Settings keeps the old value valid for 24 hours by default.
|
|
224
|
+
|
|
225
|
+
## 10. Functions: your own code, never your secrets
|
|
226
|
+
|
|
227
|
+
For what a declarative sync cannot say (send a form to HubSpot, reshape a webhook payload, a nightly
|
|
228
|
+
report), write `bettercms/functions/<name>.ts` in the site repo and declare it:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// bettercms/functions/to-hubspot.ts
|
|
232
|
+
export default async (event: { formId: string; submission: { data: Record<string, string> } }, ctx) => {
|
|
233
|
+
const { email, name } = event.submission.data;
|
|
234
|
+
const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts", {
|
|
235
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
236
|
+
body: JSON.stringify({ properties: { email, firstname: name } }),
|
|
237
|
+
});
|
|
238
|
+
console.log("hubspot", res.status);
|
|
239
|
+
return { status: res.status };
|
|
240
|
+
};
|
|
241
|
+
```
|
|
242
|
+
```
|
|
243
|
+
update_integration { functions: [{ name: "to-hubspot", triggers: ["form:<formId>"], cpuMs: 1000 }] }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- It runs on Cloudflare Workers when the site's build includes it. Its `fetch()` reaches ONLY this
|
|
247
|
+
integration's approved hosts, JSON bodies only, and the server adds the `inject` secrets: the code
|
|
248
|
+
never sees a key, so never write one into it (there is no env to read).
|
|
249
|
+
- Triggers: `manual` (run_integration_function, the panel's `function.run`), `form:<formId>`
|
|
250
|
+
(each accepted submission: `{ formId, submission: { id, data } }`), `schedule:hourly|daily`, and
|
|
251
|
+
any hook whose action names it (`{ hook, payload }`). `ctx` = `{ integration, function, trigger,
|
|
252
|
+
runId, settings }`.
|
|
253
|
+
- Return `{ upsert: [{ sync, externalKey, data, slug? }] }` to write entries into a sync's
|
|
254
|
+
collection (drafts, like a sync). `run_integration_function { waitSeconds: 20 }` answers with the
|
|
255
|
+
output, the captured `console` lines and any error; runs are in `get_integration.functionRuns`.
|
|
256
|
+
- If the server answers `functions_not_enabled`, functions are not available yet on this
|
|
257
|
+
BetterCMS server: use a sync or an endpoint instead.
|
|
258
|
+
|
|
259
|
+
## 11. Site endpoints: live reads for visitors, no key in the page
|
|
260
|
+
|
|
261
|
+
A page that must show something live (stock, a search, today's webinars) calls the integration's
|
|
262
|
+
endpoint, never the vendor: the key stays on the server and only the fields you pick leave.
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
update_integration { endpoints: [{
|
|
266
|
+
key: "stock",
|
|
267
|
+
request: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/variants/{{param.variant}}.json" },
|
|
268
|
+
params: { variant: { pattern: "[0-9]{1,20}", required: true } },
|
|
269
|
+
pick: { available: "/variant/inventory_quantity", price: "/variant/price" },
|
|
270
|
+
cacheSeconds: 60 }] }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- The site calls `GET <endpointUrls.stock>?variant=123` (public, CORS open, no credentials) and gets
|
|
274
|
+
`{ data: { available, price } }`. With `items: "/products"` each list item is picked instead.
|
|
275
|
+
- Every param must be declared with an anchored `pattern`; an unknown or non-matching param is a
|
|
276
|
+
400. Patterns with a repeated group inside a repeat (`(a+)+`) are refused: they can hang.
|
|
277
|
+
- Answers are cached for `cacheSeconds` per params; visitors are rate-limited per IP.
|
|
278
|
+
- Endpoints are on the approval surface (what anyone on the internet can read).
|
|
279
|
+
|
|
280
|
+
## 12. Live references: point at a vendor item without copying it
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
update_integration { references: [{
|
|
284
|
+
key: "product", label: "Shopify product",
|
|
285
|
+
search: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products.json", query: { title: "{{query}}", limit: "20" } },
|
|
286
|
+
lookup: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{id}}.json" },
|
|
287
|
+
items: "/products", item: "/product", id: "/id", title: "/title", image: "/image/src",
|
|
288
|
+
pick: { price: "/variants/0/price" } }] }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- The field that holds one: type `json` with `ui.reference: { integration: "<integration slug>",
|
|
292
|
+
key: "<reference key>" }`; editors get a search picker. Its value is `{ integration, reference, id, title, image? }` (title
|
|
293
|
+
and image are a snapshot for editors).
|
|
294
|
+
- `search_integration_reference` finds ids for an agent. The site resolves the live item at
|
|
295
|
+
`<refs URL>/<id>` (`GET /api/v1/public/integrations/<projectId>/<slug>/refs/<key>/<id>`):
|
|
296
|
+
`{ id, title, image?, ...pick }`, cached `cacheSeconds` (300 by default).
|
|
297
|
+
|
|
298
|
+
## 13. Sign in with OAuth (the customer's own OAuth app)
|
|
299
|
+
|
|
300
|
+
For vendors that only offer OAuth (Google, HubSpot public apps, Zoom user-level): the user creates an
|
|
301
|
+
OAuth app at the vendor, sets its client id and secret as env vars, and declares:
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
update_integration { secrets: { CLIENT_ID: "GOOGLE_CLIENT_ID", CLIENT_SECRET: "GOOGLE_CLIENT_SECRET" },
|
|
305
|
+
allowedHosts: ["oauth2.googleapis.com", "www.googleapis.com"],
|
|
306
|
+
oauth: { authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
307
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
308
|
+
scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
|
|
309
|
+
clientId: "CLIENT_ID", clientSecret: "CLIENT_SECRET",
|
|
310
|
+
extraAuthorizeParams: { access_type: "offline", prompt: "consent" } },
|
|
311
|
+
inject: { headers: { authorization: "Bearer {{token}}" } } }
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
- After approval, `start_integration_signin` gives a `url` for the USER to open (and the
|
|
315
|
+
`redirectUri` they register in their OAuth app first). They sign in at the vendor; the tokens are
|
|
316
|
+
stored encrypted and refreshed by the server. `{{token}}` in `inject` is the access token.
|
|
317
|
+
- `oauth` and `auth` (a token exchange) do not mix. The token URL's host must be an allowed host.
|
|
318
|
+
|
|
319
|
+
## 14. Templates, the call log, alerts
|
|
320
|
+
|
|
321
|
+
- `save_integration_template` stores an integration's shape for the workspace (no value, no
|
|
322
|
+
settings; the collections its syncs write into are copied). `install_integration_template` puts
|
|
323
|
+
it on another project: missing collections are created, the integration waits for that project's
|
|
324
|
+
admin, and `secretsNeeded` lists the env vars to set there.
|
|
325
|
+
- `get_integration.calls` is the call log: every vendor call (source, host, path, status, time),
|
|
326
|
+
never a query string or body. When runs fail `alertAfter` times in a row (3 by default; 0 = never)
|
|
327
|
+
the workspace's admins are notified once; the next success clears it.
|
|
328
|
+
|
|
329
|
+
## 15. Two-way sync: BetterCMS edits go back to the vendor
|
|
330
|
+
|
|
331
|
+
A sync pulls (section 6) and follows the vendor's webhooks (section 9). Add `writeBack` and edits
|
|
332
|
+
made in BetterCMS (dashboard, Visual editor, API, you) go back to the vendor too:
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
{ key: "products", ..., map: { title: "/title", description: "/body_html", price: "/variants/0/price" },
|
|
336
|
+
item: { request: { method: "GET", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json" }, pointer: "/product" },
|
|
337
|
+
writeBack: {
|
|
338
|
+
fields: ["description"], // the fields BetterCMS owns
|
|
339
|
+
on: "save", // or "publish": only what goes live is pushed
|
|
340
|
+
update: { method: "PUT", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json",
|
|
341
|
+
json: { product: { body_html: "{{field.description}}" } } },
|
|
342
|
+
create: { method: "POST", url: "https://acme.myshopify.com/admin/api/2024-10/products.json",
|
|
343
|
+
json: { product: { title: "{{field.title}}", body_html: "{{field.description}}" } }, idPointer: "/product/id" },
|
|
344
|
+
delete: { method: "DELETE", url: "https://acme.myshopify.com/admin/api/2024-10/products/{{item}}.json" } } }
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- `{{item}}` is the vendor id (the entry's externalKey). `{{field.KEY}}` is the entry's value; a
|
|
348
|
+
JSON string that is exactly one placeholder keeps the value's type (a number stays a number).
|
|
349
|
+
`update` may use only `fields`; `create` may use any field of the new entry.
|
|
350
|
+
- Needs `allowPost: true`, and `writeBack` is on the approval surface (it writes, and may delete,
|
|
351
|
+
at the vendor). `delete` is never done unless declared.
|
|
352
|
+
- **Who wins.** Vendor-owned fields (`map` only) always follow the vendor. For `fields`, a BetterCMS
|
|
353
|
+
edit is pushed within about a minute; until it has reached the vendor, a pull or webhook does not
|
|
354
|
+
overwrite it. After that, the newest change from either side wins.
|
|
355
|
+
- **No loops.** Both sides' agreed values are remembered per entry; our own push coming back
|
|
356
|
+
through a webhook, or a pull's own write, is recognised and never pushed again.
|
|
357
|
+
- **New and deleted entries.** An entry made in BetterCMS is created at the vendor (with `create`)
|
|
358
|
+
and keeps the vendor's id as its externalKey, so the next pull updates it instead of duplicating.
|
|
359
|
+
Without `create` it stays BetterCMS-only. Deleting an entry deletes the vendor item only with `delete`.
|
|
360
|
+
- **Failures** retry with backoff (1, 2, 4, 8, 16 minutes), then count toward `alertAfter`. Pushes
|
|
361
|
+
appear in the call log with source `push`.
|
|
362
|
+
- **Known windows** (seconds to a minute, rare): an edit saved while a pull is writing that same entry,
|
|
363
|
+
or within a minute of changing `writeBack.fields`, can be replaced by the vendor's value. Change the
|
|
364
|
+
field list when editors are idle, and re-save an edit if a pull overwrote it.
|