@myapihq/cli 1.1.0-wip.1 → 1.1.0-wip.3
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/dist/commands/email/mailbox.js +1 -1
- package/dist/commands/funnel.d.ts +1 -1
- package/dist/commands/funnel.js +51 -14
- package/dist/commands/workflow.js +108 -11
- package/dist/flags.js +23 -1
- package/dist/index.js +3 -0
- package/dist/skills/my-workflow-api/README.md +1 -1
- package/dist/skills/my-workflow-api/SKILL.md +17 -6
- package/package.json +1 -1
|
@@ -45,7 +45,7 @@ async function activateSending(flags) {
|
|
|
45
45
|
if (!address)
|
|
46
46
|
error('Missing required arguments.\nUsage: myapi email mailbox activate-sending --address <email>');
|
|
47
47
|
const res = await sdkEmail.activateSending(config.api_key, address);
|
|
48
|
-
success(`Sending activated: ${address} (
|
|
48
|
+
success(`Sending activated: ${address} (${res.emails_quota_remaining} emails/day quota)`);
|
|
49
49
|
}
|
|
50
50
|
const USAGE = {
|
|
51
51
|
'create': `myapi email mailbox create <user@domain> [--display-name <name>]
|
|
@@ -6,5 +6,5 @@ export declare function create(flags: Flags): Promise<void>;
|
|
|
6
6
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
7
7
|
export declare function del(id: string, flags: Flags): Promise<void>;
|
|
8
8
|
export declare function push(slug: string, flags: Flags): Promise<void>;
|
|
9
|
-
export declare function verify(
|
|
9
|
+
export declare function verify(slug: string, flags: Flags): Promise<void>;
|
|
10
10
|
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
package/dist/commands/funnel.js
CHANGED
|
@@ -108,25 +108,48 @@ export async function push(slug, flags) {
|
|
|
108
108
|
if (resolvedFromOrg) {
|
|
109
109
|
info(`(Used the org's only funnel: ${funnelId}. Set a default with: myapi config set-funnel ${funnelId})`);
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
111
|
+
let liveUrl = result?.url;
|
|
112
|
+
if (!liveUrl) {
|
|
115
113
|
const org = await hq.getOrg(config.api_key, orgId);
|
|
116
114
|
if (org.preview_subdomain) {
|
|
117
|
-
|
|
115
|
+
liveUrl = `https://${org.preview_subdomain}.makeautonomous.com${finalSlug}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (liveUrl)
|
|
119
|
+
info(`Preview: ${liveUrl}`);
|
|
120
|
+
// If we're still on the preview subdomain, hint at how to serve on a
|
|
121
|
+
// custom domain. Tailor the hint: if a default_domain is already
|
|
122
|
+
// configured (registered + meant to be used), suggest assigning it
|
|
123
|
+
// directly; otherwise point at register + assign.
|
|
124
|
+
const onPreview = liveUrl?.includes('.makeautonomous.com');
|
|
125
|
+
if (onPreview) {
|
|
126
|
+
const dom = config.default_domain;
|
|
127
|
+
if (dom) {
|
|
128
|
+
info(`(Serving on the preview subdomain. To serve on ${dom} instead: myapi domain assign ${dom})`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
info(`(Serving on the preview subdomain. To serve on a custom domain: myapi domain register <domain> && myapi domain assign <domain>)`);
|
|
118
132
|
}
|
|
119
133
|
}
|
|
120
134
|
}
|
|
121
|
-
|
|
135
|
+
// Verify uses the same shape as push: slug positional (default '/'), funnel
|
|
136
|
+
// resolved from --funnel / default / the org's only funnel.
|
|
137
|
+
export async function verify(slug, flags) {
|
|
122
138
|
const config = requireConfig();
|
|
123
|
-
const orgId = requireOrg(flags, config, 'myapi funnel verify
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
const orgId = requireOrg(flags, config, 'myapi funnel verify [slug] [--funnel <id>] [--org <id>]');
|
|
140
|
+
const rawSlug = slug || flags.slug || '/';
|
|
141
|
+
const finalSlug = rawSlug.startsWith('/') ? rawSlug : `/${rawSlug}`;
|
|
142
|
+
let funnelId = flags.funnel || config.default_funnel;
|
|
143
|
+
if (!funnelId) {
|
|
144
|
+
const existing = await sdkFunnel.listFunnels(config.api_key, orgId);
|
|
145
|
+
if (existing.length === 0)
|
|
146
|
+
error('No funnel found for this org. Create one with: myapi funnel create');
|
|
147
|
+
if (existing.length > 1) {
|
|
148
|
+
error(`Multiple funnels exist for this org and no default is set.\nPick one with --funnel <id>, or set a default:\n myapi config set-funnel <id>\n\nFunnels:\n${existing.map(f => ` ${f.id}`).join('\n')}`);
|
|
149
|
+
}
|
|
150
|
+
funnelId = existing[0].id;
|
|
151
|
+
}
|
|
152
|
+
const v = await sdkFunnel.verifyFunnel(config.api_key, orgId, funnelId, { slug: finalSlug });
|
|
130
153
|
printJson(v);
|
|
131
154
|
}
|
|
132
155
|
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
@@ -140,12 +163,26 @@ const SUBCOMMAND_USAGE = {
|
|
|
140
163
|
Reads HTML from stdin and publishes to <slug> on your funnel. Funnel is resolved
|
|
141
164
|
from --funnel, the default funnel, or (only if the org has exactly one) auto-picked.
|
|
142
165
|
|
|
166
|
+
By default, your funnel is served on a preview subdomain (*.makeautonomous.com).
|
|
167
|
+
To serve on your own domain, register and assign one with:
|
|
168
|
+
myapi domain register <domain>
|
|
169
|
+
myapi domain assign <domain>
|
|
170
|
+
|
|
143
171
|
Examples:
|
|
144
172
|
echo '<h1>Hello</h1>' | myapi funnel push /
|
|
145
173
|
cat about.html | myapi funnel push /about
|
|
146
174
|
myapi funnel push < index.html
|
|
147
175
|
cat p.html | myapi funnel push /pricing --funnel <uuid>`,
|
|
148
|
-
'verify':
|
|
176
|
+
'verify': `myapi funnel verify [slug] [--funnel <id>] [--org <id>]
|
|
177
|
+
|
|
178
|
+
Verifies the page at <slug> on a funnel. Slug defaults to '/'. Funnel is
|
|
179
|
+
resolved from --funnel, the default funnel, or (only if the org has exactly
|
|
180
|
+
one) auto-picked — same shape as "funnel push".
|
|
181
|
+
|
|
182
|
+
Examples:
|
|
183
|
+
myapi funnel verify # verifies '/' on the default funnel
|
|
184
|
+
myapi funnel verify /pricing
|
|
185
|
+
myapi funnel verify / --funnel <uuid>`,
|
|
149
186
|
};
|
|
150
187
|
export async function run(subcommand, args, flags) {
|
|
151
188
|
if (!subcommand || (flags.help && !subcommand)) {
|
|
@@ -9,6 +9,63 @@ export const SCHEMA = {
|
|
|
9
9
|
steps: 'string',
|
|
10
10
|
'no-enable': 'boolean',
|
|
11
11
|
};
|
|
12
|
+
// Mirrors the backend's SupportedStepTypes list. Both alias and underscore
|
|
13
|
+
// forms are accepted by the workflow runner. Keep this in sync if the
|
|
14
|
+
// backend grows new step types.
|
|
15
|
+
const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack'];
|
|
16
|
+
const SLACK_HOOK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/;
|
|
17
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
// Per-step-type required fields. Validated client-side so typos and
|
|
19
|
+
// hallucinated shapes fail fast, before the network call. Backend
|
|
20
|
+
// performs the same validation as defense in depth.
|
|
21
|
+
function validateSteps(steps) {
|
|
22
|
+
if (!Array.isArray(steps))
|
|
23
|
+
error('--steps must be a JSON array of step objects.');
|
|
24
|
+
if (steps.length === 0)
|
|
25
|
+
error('--steps cannot be an empty array.');
|
|
26
|
+
steps.forEach((s, i) => {
|
|
27
|
+
const where = `step ${i}`;
|
|
28
|
+
if (!s || typeof s !== 'object')
|
|
29
|
+
error(`${where}: must be a JSON object.`);
|
|
30
|
+
if (!s.type)
|
|
31
|
+
error(`${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
|
|
32
|
+
if (!SUPPORTED_STEP_TYPES.includes(s.type)) {
|
|
33
|
+
error(`${where}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
if (s.type === 'send_email' || s.type === 'email') {
|
|
36
|
+
const required = ['from', 'to', 'subject'];
|
|
37
|
+
for (const f of required) {
|
|
38
|
+
if (!s[f] || typeof s[f] !== 'string') {
|
|
39
|
+
error(`${where} (${s.type}): missing required field "${f}".`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const bodyForms = ['body', 'html', 'template_id'].filter(f => s[f] !== undefined && s[f] !== '');
|
|
43
|
+
if (bodyForms.length === 0) {
|
|
44
|
+
error(`${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`);
|
|
45
|
+
}
|
|
46
|
+
if (bodyForms.length > 1) {
|
|
47
|
+
error(`${where} (${s.type}): can only use one of "body", "html", "template_id" — got: ${bodyForms.join(', ')}.`);
|
|
48
|
+
}
|
|
49
|
+
if (s.template_vars !== undefined && !s.template_id) {
|
|
50
|
+
error(`${where} (${s.type}): "template_vars" only makes sense with "template_id".`);
|
|
51
|
+
}
|
|
52
|
+
if (s.template_vars !== undefined && (typeof s.template_vars !== 'object' || Array.isArray(s.template_vars))) {
|
|
53
|
+
error(`${where} (${s.type}): "template_vars" must be a JSON object.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (s.type === 'slack_message' || s.type === 'slack') {
|
|
57
|
+
if (!s.webhook_url || typeof s.webhook_url !== 'string') {
|
|
58
|
+
error(`${where} (${s.type}): missing required field "webhook_url".`);
|
|
59
|
+
}
|
|
60
|
+
if (!SLACK_HOOK_RE.test(s.webhook_url)) {
|
|
61
|
+
error(`${where} (${s.type}): "webhook_url" must look like https://hooks.slack.com/services/T.../B.../xxx — got "${s.webhook_url}".`);
|
|
62
|
+
}
|
|
63
|
+
if (!s.text || typeof s.text !== 'string') {
|
|
64
|
+
error(`${where} (${s.type}): missing required field "text".`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
12
69
|
function summarizeWorkflow(w) {
|
|
13
70
|
return {
|
|
14
71
|
id: w.id,
|
|
@@ -60,6 +117,9 @@ export async function create(nameArg, flags) {
|
|
|
60
117
|
if (!name || !endpointId || !flags.steps) {
|
|
61
118
|
error('Missing required arguments.\nUsage: myapi workflow create <name> --endpoint-id <id> --steps <json> [--no-enable] [--org <id>]\n or: myapi workflow create --name <name> --endpoint-id <id> --steps <json> [--no-enable] [--org <id>]');
|
|
62
119
|
}
|
|
120
|
+
if (!UUID_RE.test(endpointId)) {
|
|
121
|
+
error(`Invalid --endpoint-id "${endpointId}". Expected a webhook endpoint UUID.\nList your endpoints with: myapi webhook list`);
|
|
122
|
+
}
|
|
63
123
|
let steps;
|
|
64
124
|
try {
|
|
65
125
|
steps = JSON.parse(flags.steps);
|
|
@@ -67,6 +127,7 @@ export async function create(nameArg, flags) {
|
|
|
67
127
|
catch {
|
|
68
128
|
error('Invalid JSON for --steps');
|
|
69
129
|
}
|
|
130
|
+
validateSteps(steps);
|
|
70
131
|
const wf = await sdkWorkflow.createWorkflow(config.api_key, orgId, {
|
|
71
132
|
name,
|
|
72
133
|
trigger_config: { endpoint_id: endpointId },
|
|
@@ -89,8 +150,13 @@ export async function update(id, flags) {
|
|
|
89
150
|
const payload = {};
|
|
90
151
|
if (typeof flags.name === 'string')
|
|
91
152
|
payload.name = flags.name;
|
|
92
|
-
if (typeof flags['endpoint-id'] === 'string')
|
|
93
|
-
|
|
153
|
+
if (typeof flags['endpoint-id'] === 'string') {
|
|
154
|
+
const eid = flags['endpoint-id'];
|
|
155
|
+
if (!UUID_RE.test(eid)) {
|
|
156
|
+
error(`Invalid --endpoint-id "${eid}". Expected a webhook endpoint UUID.\nList your endpoints with: myapi webhook list`);
|
|
157
|
+
}
|
|
158
|
+
payload.trigger_config = { endpoint_id: eid };
|
|
159
|
+
}
|
|
94
160
|
if (typeof flags.steps === 'string') {
|
|
95
161
|
try {
|
|
96
162
|
payload.steps = JSON.parse(flags.steps);
|
|
@@ -98,6 +164,7 @@ export async function update(id, flags) {
|
|
|
98
164
|
catch {
|
|
99
165
|
error('Invalid JSON for --steps');
|
|
100
166
|
}
|
|
167
|
+
validateSteps(payload.steps);
|
|
101
168
|
}
|
|
102
169
|
if (!payload.name && !payload.trigger_config && !payload.steps) {
|
|
103
170
|
error('Nothing to update. Provide at least one of --name, --endpoint-id, --steps.');
|
|
@@ -161,18 +228,48 @@ const SUBCOMMAND_USAGE = {
|
|
|
161
228
|
|
|
162
229
|
Either form works; the positional name is the recommended shape.
|
|
163
230
|
|
|
164
|
-
--steps is a JSON array of step objects.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
231
|
+
--steps is a JSON array of step objects. Each step has a "type" field
|
|
232
|
+
plus type-specific fields. Two types are supported today:
|
|
233
|
+
|
|
234
|
+
type: "send_email" (alias: "email")
|
|
235
|
+
Required:
|
|
236
|
+
from Sender mailbox address (must be activated for sending)
|
|
237
|
+
to Recipient address (templating allowed)
|
|
238
|
+
subject Email subject (templating allowed)
|
|
239
|
+
Body — exactly one of:
|
|
240
|
+
body Plain text body
|
|
241
|
+
html Raw HTML body
|
|
242
|
+
template_id UUID of an AI-generated template (myapi email template
|
|
243
|
+
generate). Recommended for nice-looking emails.
|
|
244
|
+
Optional:
|
|
245
|
+
template_vars JSON object of variable substitutions, only with
|
|
246
|
+
template_id (e.g. {"name": "{{ payload.name }}"}).
|
|
247
|
+
|
|
248
|
+
type: "slack_message" (alias: "slack")
|
|
249
|
+
Required:
|
|
250
|
+
webhook_url https://hooks.slack.com/services/T.../B.../xxx
|
|
251
|
+
Get one from https://api.slack.com/apps → your app →
|
|
252
|
+
Incoming Webhooks. Other URLs are rejected.
|
|
253
|
+
text Message text (templating allowed)
|
|
254
|
+
|
|
255
|
+
Templating: any string field can reference the incoming webhook payload
|
|
256
|
+
with {{ payload.field }} (whitespace optional). Example:
|
|
257
|
+
"to": "{{ payload.email }}" → the email field from the form submission.
|
|
258
|
+
|
|
259
|
+
Unknown step types AND missing/invalid fields are rejected at create
|
|
260
|
+
time — you find out about typos before any workflow run happens.
|
|
168
261
|
|
|
169
|
-
|
|
170
|
-
|
|
262
|
+
There is NO on-failure notification mechanism today. Inspect run history
|
|
263
|
+
with: myapi workflow runs <id> · myapi workflow get-run <run_id>.
|
|
171
264
|
|
|
172
|
-
|
|
265
|
+
Examples:
|
|
266
|
+
# send a thank-you email using an AI template, then ping Slack
|
|
173
267
|
myapi workflow create "Contact handler" --endpoint-id <wid> --steps '[
|
|
174
|
-
{"type":"
|
|
175
|
-
"subject":"Thanks!","
|
|
268
|
+
{"type":"email","from":"hello@x.com","to":"{{ payload.email }}",
|
|
269
|
+
"subject":"Thanks!","template_id":"<tid>",
|
|
270
|
+
"template_vars":{"name":"{{ payload.name }}"}},
|
|
271
|
+
{"type":"slack","webhook_url":"https://hooks.slack.com/services/T0/B0/xxx",
|
|
272
|
+
"text":"New: {{ payload.message }}"}
|
|
176
273
|
]'
|
|
177
274
|
|
|
178
275
|
See the my-workflow-api skill for the full form-to-email recipe.`,
|
package/dist/flags.js
CHANGED
|
@@ -16,6 +16,7 @@ export function parseFlags(argv, schema = {}) {
|
|
|
16
16
|
const merged = { ...GLOBAL_FLAGS, ...schema };
|
|
17
17
|
const args = [];
|
|
18
18
|
const flags = {};
|
|
19
|
+
const unknownFlags = [];
|
|
19
20
|
for (let i = 0; i < argv.length; i++) {
|
|
20
21
|
const arg = argv[i];
|
|
21
22
|
if (arg === '-h') {
|
|
@@ -44,9 +45,24 @@ export function parseFlags(argv, schema = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
const type = merged[key];
|
|
46
47
|
// Unknown flags: tolerate as boolean (with --key=value still honored)
|
|
47
|
-
// so additions to the schema don't silently break someone's script
|
|
48
|
+
// so additions to the schema don't silently break someone's script —
|
|
49
|
+
// BUT also collect them and emit a stderr warning at the end of
|
|
50
|
+
// parsing. Surfaces typos and AI-agent hallucinations like
|
|
51
|
+
// `--on-error <email>` without breaking back-compat.
|
|
48
52
|
if (type === undefined) {
|
|
49
53
|
flags[key] = inlineValue ?? true;
|
|
54
|
+
unknownFlags.push(`--${key}`);
|
|
55
|
+
// If no inline value and the next token doesn't look like another
|
|
56
|
+
// flag, the user almost certainly intended it as the flag's value
|
|
57
|
+
// (e.g. `--on-error someone@x.com`). Consume it so it doesn't
|
|
58
|
+
// become a stray positional arg later.
|
|
59
|
+
if (inlineValue === undefined) {
|
|
60
|
+
const next = argv[i + 1];
|
|
61
|
+
if (next !== undefined && !next.startsWith('--') && !next.startsWith('-h') && !next.startsWith('-v')) {
|
|
62
|
+
flags[key] = next;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
50
66
|
continue;
|
|
51
67
|
}
|
|
52
68
|
if (type === 'boolean') {
|
|
@@ -84,5 +100,11 @@ export function parseFlags(argv, schema = {}) {
|
|
|
84
100
|
}
|
|
85
101
|
args.push(arg);
|
|
86
102
|
}
|
|
103
|
+
// Surface unknown flags so typos and hallucinated flags don't silently
|
|
104
|
+
// disappear. Don't block — the value is still in `flags` for any handler
|
|
105
|
+
// that wants it — just print one line on stderr.
|
|
106
|
+
if (unknownFlags.length > 0 && !process.env.MYAPI_QUIET_UNKNOWN_FLAGS) {
|
|
107
|
+
process.stderr.write(`› Note: ignoring unknown flag(s): ${unknownFlags.join(', ')}\n`);
|
|
108
|
+
}
|
|
87
109
|
return { args, flags };
|
|
88
110
|
}
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,9 @@ const ERROR_MESSAGES = {
|
|
|
58
58
|
RATE_LIMITED: 'Too many requests. Please wait a moment and try again.',
|
|
59
59
|
INSUFFICIENT_BALANCE: 'Insufficient balance. Run: myapi billing topup <amount>',
|
|
60
60
|
INVALID_AMOUNT: 'Amount out of range. Maximum single top-up is $100. Run: myapi billing topup <amount>',
|
|
61
|
+
// invalid_json_response intentionally absent — the SDK's MyApiError now
|
|
62
|
+
// builds a useful detailed message for that case (status + URL + body
|
|
63
|
+
// snippet), and friendlyError(err.code) would override it.
|
|
61
64
|
};
|
|
62
65
|
function friendlyError(code) {
|
|
63
66
|
return ERROR_MESSAGES[code] || code;
|
|
@@ -6,7 +6,7 @@ Run a chain of actions (send email, post to Slack, call an HTTP endpoint) every
|
|
|
6
6
|
## What it does
|
|
7
7
|
|
|
8
8
|
- Bind ordered step chains to webhook endpoints
|
|
9
|
-
- Steps can: send transactional email,
|
|
9
|
+
- Steps can: send transactional email, post to Slack
|
|
10
10
|
- Template values from the inbound webhook payload (`{{ payload.field }}`)
|
|
11
11
|
- Per-run status tracking (attempt, error, started/finished timestamps)
|
|
12
12
|
- Enable/disable without losing config
|
|
@@ -51,25 +51,36 @@ myapi workflow get-run <run_id>
|
|
|
51
51
|
|
|
52
52
|
`--steps` is a JSON array of step objects. Each step has a `type` and type-specific fields.
|
|
53
53
|
|
|
54
|
+
Supported step types (alias forms in parentheses):
|
|
55
|
+
|
|
56
|
+
| `type` | Aliases | Required fields |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `send_email` | `email` | `from`, `to`, `subject`, plus one of `body` / `html` / `template_id` |
|
|
59
|
+
| `slack_message` | `slack` | `webhook_url`, `text` |
|
|
60
|
+
|
|
54
61
|
```json
|
|
55
62
|
[
|
|
56
63
|
{
|
|
57
|
-
"type": "
|
|
64
|
+
"type": "email",
|
|
58
65
|
"from": "hello@yourdomain.com",
|
|
59
66
|
"to": "{{ payload.email }}",
|
|
60
67
|
"subject": "Welcome, {{ payload.name }}",
|
|
61
68
|
"template_id": "<template_id>"
|
|
62
69
|
},
|
|
63
70
|
{
|
|
64
|
-
"type": "
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"body": "{{ payload | json }}"
|
|
71
|
+
"type": "slack",
|
|
72
|
+
"webhook_url": "https://hooks.slack.com/services/T.../B.../xxx",
|
|
73
|
+
"text": "New submission from {{ payload.name }}"
|
|
68
74
|
}
|
|
69
75
|
]
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
Unknown step types are rejected at workflow create time, so typos surface
|
|
79
|
+
immediately instead of after 3 failed retries during execution.
|
|
80
|
+
|
|
81
|
+
The webhook payload is available as `{{ payload }}` and individual fields
|
|
82
|
+
as `{{ payload.fieldname }}`. Whitespace inside the braces is fine —
|
|
83
|
+
both `{{ payload.email }}` and `{{payload.email}}` work.
|
|
73
84
|
|
|
74
85
|
## End-to-end recipe — react to a contact form submission
|
|
75
86
|
|