Execute complete client engagements back to back. Build, fix, deliver, and hand over real automation systems across different Upwork client archetypes — then take each capability to market.
Build a complete B2B lead-gen tracking system from scratch — Typeform, Calendly, Stripe, n8n, Zapier — then package and price the deliverable for Upwork.
Walk into a broken Shopify store, audit all 5 tracking installation points, fix the duplication, and deliver a handover package you can replicate for every client after.
GTM & Server-Side GTM Pipelines
Multi-Platform Tracking (Google, TikTok, LinkedIn)
iOS Privacy, Consent & Optimization
Execute a complete SaaS B2B client engagement — build three production AI integrations, price a $2,800 fixed project, and position for retainer revenue.
Factual breakdown of 8 automation sub-markets on Upwork — growth rates, rate ranges, saturation analysis, and opportunity rankings sourced from Upwork's official reports.
12-week timetable covering all three labs in recommended learning order — paced at 5–6 hours per week for busy professionals.
Total cost commitment, every tool you'll use, current Upwork market positioning, and what each module unlocks for your freelance career.
MODULE 1 Conversion Tracking & Attribution Track
A complete, hands-on student laboratory based on a real Upwork client project.
Covers Meta Pixel, Conversions API, n8n, Zapier, Typeform, Calendly, Google Sheets, and Stripe. 20 implementation layers. Platform-independent automation architecture.
How to use this laboratory: Work through each Part and Layer in sequence. Every layer builds on the one before it. You will set up real accounts, build real workflows, and send real test events. By the end, you will have built and verified an end-to-end conversion tracking system and be able to explain every component of it.
Read this exactly as a freelancer would see it on Upwork. Do not skim it. Do not skip sections.
End of raw client brief. Everything above is the client's exact language. We have not changed a single word.
Translate the client's business language into technical requirements. Challenge every assumption.
A professional freelancer does not simply execute a client brief line by line. They read the brief, identify what the client actually needs, separate it from the specific words the client used, and flag anything that requires verification or discussion. Below, we analyze each major requirement.
onSubmit callback, or by a Shopify app — not by the Pixel "watching" the form.action_source for a Calendly booking that originated from a website visit would be "website" (the booking flow started from the client's site), though "system_generated" is also defensible.Schedule, action_source: "website", and include hashed email + phone in user_data.POST /invitee_no_shows), but it does not have a positive "attended" status or webhook. The absence of a no-show marking does not reliably indicate attendance, because the host may not have marked anyone. There is no invitee.attended webhook event.What is the actual business problem the client needs solved?
The client runs Meta ads that drive traffic to a B2B marketing agency website. Visitors fill out a Typeform, which fires a Lead event to Meta. Meta's ad delivery algorithm receives this single signal and optimizes for it — finding more people likely to fill out a form.
After the form submission, the client's funnel continues: qualified leads book Calendly calls, attend those calls, get evaluated, and some sign contracts worth €1,400–€1,700/month via Stripe. Meta knows nothing about any of this. As far as Meta's algorithm is concerned, every form submission is equally valuable. A tire-kicker who submits the form and never books a call looks identical to a client who signs a €1,700/month contract.
Meta's ad delivery optimizes for the signal it receives. If the only signal is "Lead" (form submission), Meta finds more people who fill out forms — not more people who book calls, attend calls, qualify, and pay. The client is spending ad budget attracting form-fillers instead of buyers.
The client wants Meta to receive five signals along the funnel, each representing a progressively more valuable business action:
| Signal | Business Action | Business Value |
|---|---|---|
Lead | Typeform submitted | Interest expressed |
Schedule | Calendly call booked | Commitment made |
CallAttended | Prospect showed up | Engagement confirmed |
QualifiedLead | Prospect evaluated as fit | Sales opportunity |
Purchase | Stripe payment received | €1,400–€1,700 revenue |
With all five signals, Meta can learn which ad clicks ultimately lead to paying clients — and optimize ad delivery to find more people like them.
Separate the client's request into distinct objective categories.
Improve Meta ad performance by giving the algorithm richer downstream conversion signals. Reduce cost per acquisition by optimizing for qualified leads and purchases rather than form fills. Gain visibility into the full funnel from ad click to signed contract.
Track five conversion events: Lead (form submission), Schedule (Calendly booking), CallAttended (prospect showed up), QualifiedLead (manually assessed), Purchase (Stripe payment with value). Each event must carry identity data sufficient for Meta to match it back to the original ad click.
Automate the transmission of conversion events to Meta whenever a business action occurs. Minimize manual work — the team should only need to mark attendance and qualification in a spreadsheet; everything else should happen automatically.
All five events visible in Meta Events Manager. Events usable for campaign optimization in Ads Manager. Event Match Quality high enough for reliable attribution.
A Google Sheet tracking every lead through the funnel. A Loom walkthrough video. Written documentation with screenshots. The client must be able to troubleshoot independently after handover.
| Constraint | Value |
|---|---|
| Budget | $300–$400 fixed price |
| Timeline | 5–7 days |
| Estimated effort | 4–6 hours for experienced implementer |
| Existing tools | Meta Business Manager, Pixel, Typeform (Business), Calendly (Standard), Stripe, Shopify, Google Workspace |
| Missing tools | Zapier (client needs guidance on plan selection) |
| Required deliverables | Working system + Loom video + written documentation |
What the student must be able to do after completing this laboratory.
| # | Technical Objective | Covered In |
|---|---|---|
| 1 | Audit an existing Meta Pixel installation | Layers 1–2 |
| 2 | Verify Lead event tracking | Layer 4 |
| 3 | Understand website → embedded Typeform data flow | Layer 3 |
| 4 | Understand Typeform → Calendly redirect | Layer 5 |
| 5 | Capture a Calendly booking via webhook | Layer 9 |
| 6 | Send a Schedule event to Meta via CAPI | Layer 9 |
| 7 | Capture attendance via manual entry + sheet trigger | Layer 10 |
| 8 | Send a CallAttended event to Meta via CAPI | Layer 10 |
| 9 | Capture qualification via sheet update + trigger | Layer 11 |
| 10 | Send a QualifiedLead event to Meta via CAPI | Layer 11 |
| 11 | Capture a Stripe payment via webhook | Layer 12 |
| 12 | Send a Purchase event with value/currency to Meta | Layer 12 |
| 13 | Preserve identity (email, phone, fbp, fbc) across the funnel | Layer 16 |
| 14 | Generate and manage event IDs for deduplication | Layer 17 |
| 15 | Test every handshake individually | Layers 9–12 |
| 16 | Troubleshoot common failures | Layer 18 |
| 17 | Verify events in Meta Events Manager | Layer 14 |
| 18 | Understand Meta event configuration vs. transmission | Layer 15 |
| 19 | Reproduce n8n workflows in Zapier | Layer 13 |
| 20 | Document the final implementation for client handover | Layer 20 |
The funnel and the five handshakes, exactly as the client describes them.
Typeform submission → Meta Pixel (browser-side) → Lead
Trigger: user submits form. Transport: browser JavaScript. No automation platform needed.
Calendly booking → Webhook → n8n/Zapier → CAPI → Schedule
Trigger: Calendly invitee.created webhook. Transport: server-to-server HTTP POST. Identity: email + phone from Calendly invitee data.
Call occurs → Human marks Attended = TRUE in Google Sheet → n8n/Zapier → CAPI → CallAttended
Trigger: Google Sheet row update. Manual step: team member marks attendance. Transport: server-to-server HTTP POST.
Team evaluates lead → Human marks Qualified = TRUE in Google Sheet → n8n/Zapier → CAPI → QualifiedLead
Trigger: Google Sheet row update. Manual step: team member marks qualification. Transport: server-to-server HTTP POST.
Client pays via Stripe → Webhook → n8n/Zapier → CAPI → Purchase (with value + currency)
Trigger: Stripe checkout.session.completed webhook. Transport: server-to-server HTTP POST. Identity: email from Stripe customer. Value: transaction amount in EUR.
Challenge every assumption before building anything.
Before implementation, a professional reviews each requirement against technical reality.
| Client Request | Technical Validation | Recommended Implementation |
|---|---|---|
| "Add Meta Pixel to Calendly" | Not possible. Calendly hosted pages do not support custom JS. Embedded Calendly uses an iframe the parent Pixel cannot access. | Send Schedule event via CAPI (server-side), triggered by Calendly webhook. |
| "Fire Meta 'Schedule' offline conversion" | "Offline Conversions" is deprecated terminology. The Offline Conversions API was merged into the Conversions API. | Use the Conversions API. The event name Schedule is a standard Meta event and is correct. |
| "Call attended (marked in Calendly)" | Calendly has no "attended" status or webhook. It only supports marking no-shows. | Use Google Sheets as the attendance record. Team marks Attended = TRUE after the call. Sheet change triggers the automation. |
| "Configure Custom Conversions for all 5 events" | Lead, Schedule, Purchase are standard events — they do not require Custom Conversions to appear in Meta. CallAttended and QualifiedLead are custom events — they also appear automatically once received. | Send all events via CAPI. Verify they appear in Events Manager. Create Custom Conversions only if the client needs filtered subsets (e.g., "Purchase > €1,500"). |
| "All 5 events usable as optimisation events" | Any event received by Meta can be selected for optimization, but Meta needs ~50 events/week to exit learning phase. Lower-funnel events (Purchase) may not have enough volume. | Start optimizing for Lead or Schedule (higher volume). Move to Purchase optimization once volume is sufficient. Explain this tradeoff to the client. |
| "Zapier" as the automation platform | Free plan insufficient (2-step limit, 100 tasks/month). Professional plan required ($29.99/month+). | Build in n8n first to learn. Reproduce in Zapier for the client. Advise client on Professional plan. |
| "Auto-populated" Google Sheet | Typeform and Calendly can push data to Sheets via integrations. Manual columns (Attended, Qualified, Signed) cannot be auto-populated — they require human input. | Auto-populate from Typeform submissions. Add Calendly booking data via n8n/Zapier. Manual columns for human decisions. |
Set up the tools. Understand what is free, what is trial, and what costs money.
n8n is a workflow automation tool that you can self-host for free. Unlike Zapier (which abstracts the HTTP layer), n8n exposes every HTTP request, every JSON payload, and every transformation. This makes it the ideal laboratory for understanding what actually happens when you "send an event to Meta."
n8n and Zapier solve the same category of problem (workflow automation), but they are architecturally different. n8n is a technical tool that exposes raw data flows. Zapier is a consumer tool that abstracts them. In this laboratory, n8n is where you learn the architecture. Zapier is where you reproduce it for the client.
| Tool | Cost | Lab Usage | Notes |
|---|---|---|---|
| n8n (self-hosted) | FREE | Primary automation lab | Install via npm or Docker |
| n8n Cloud | FREE TRIAL (14 days) | Alternative if Docker unavailable | No credit card required for trial |
| Meta Business Suite | FREE | Pixel, Events Manager, CAPI | Requires Facebook account |
| GitHub Pages | FREE | Test website | Static hosting, custom JS allowed |
| Typeform | FREE (Basic) | Test form | 10 questions/form, 10 responses/month on free plan |
| Calendly | FREE (Basic) | Test booking page | 1 event type on free plan |
| Google Sheets | FREE | Lead tracking | Requires Google account |
| Stripe (test mode) | FREE | Test payments | Test card: 4242 4242 4242 4242 |
| Zapier | FREE / PAID | Client-facing reproduction | Free plan: 100 tasks/month, 2-step Zaps only |
Typeform Free: Limited to 10 responses/month. Redirect-on-completion may require a paid plan. The embed SDK's onSubmit callback is available on all plans.
Calendly Free: Only 1 event type. No Zapier integration (requires Standard plan, $10/month). Webhooks require a developer account (free).
Zapier Free: 100 tasks/month, 5 Zaps, 2-step Zaps only. Multi-step Zaps (with filters/conditionals) require Professional plan ($29.99/month).
n8n self-hosted: Fully free and unlimited. Requires Node.js 18+ or Docker.
Choose one method:
npx n8n
This downloads and starts n8n. Access the UI at http://localhost:5678. Your workflows are saved locally.
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v n8n_data:/home/node/.n8n \
docker.n8n.io/n8nio/n8n
Access at http://localhost:5678. The -v flag persists your data.
Go to n8n.io → Start Free → Create account. 14-day trial, no credit card. Webhooks work without tunneling.
If you use self-hosted n8n (Options A/B), your webhooks are on localhost and unreachable from external services (Calendly, Stripe). You need a tunnel. Options: ngrok http 5678 (free), cloudflared tunnel (free), or set n8n's WEBHOOK_URL environment variable to your tunnel URL. n8n Cloud (Option C) handles this automatically.
20 layers. Work through them in order. Do not skip any layer.
Set up and understand the Meta infrastructure that receives conversion events.
Every conversion event in this laboratory is ultimately sent to Meta. Before building anything, you must understand what Meta's tracking infrastructure is, how it works, and where to verify that events arrive.
BROWSER (visitor's device) SERVER (your automation)
│ │
Meta Pixel JS HTTP POST
(fbevents.js) (Conversions API)
│ │
└──────────┐ ┌──────────────┘
▼ ▼
┌─────────────────────┐
│ META DATASET │
│ (one Pixel ID) │
│ (two inputs) │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ EVENTS MANAGER │
│ (view, test, │
│ verify events) │
└─────────────────────┘
Meta Business Suite / Business Manager: The administrative container for all business assets — ad accounts, Pages, Pixels/Datasets, catalogs, users. Everything lives inside this container.
Dataset (formerly "Pixel"): Meta now uses the term "Dataset" for the unified tracking asset. A Dataset has one ID (the Pixel ID) and accepts events from two sources: browser-side (Meta Pixel JavaScript) and server-side (Conversions API). Both paths feed the same Dataset.
Meta Pixel: A JavaScript snippet (fbevents.js) that runs in the visitor's browser. It sends events to Meta when the visitor performs actions on your website. It has access to browser cookies (_fbp, _fbc) that help Meta identify the visitor.
Conversions API (CAPI): A server-to-server HTTP API. Your server sends an HTTP POST request to https://graph.facebook.com/v21.0/{PIXEL_ID}/events with event data. No browser is involved. The API version may change — always verify the current version at developers.facebook.com/docs/graph-api/changelog.
As of mid-2026, Meta Graph API v26.0 is the latest version. However, Conversions API calls typically work with versions v18.0 and above. This lab uses v21.0 in examples. Before production implementation, check the Graph API Changelog for the current version and any deprecation notices.
Events Manager: The Meta dashboard where you see incoming events, test events, verify event parameters, check Event Match Quality, and diagnose problems. This is your primary verification tool throughout the lab.
Test Events: A feature in Events Manager that lets you send events with a test_event_code. Test events appear in a separate tab and do not affect your ad delivery or reporting. Use this for all laboratory work.
business.facebook.com. If you don't have a Business account, create one — it's free. You'll need a Facebook Page (create a test one if needed).facebook.com/events_manager2 directly). This is where all event data flows.123456789012345. You will use this ID in every CAPI request.TEST12345). Any CAPI request that includes this code in the payload will appear here without affecting production data.PIXEL_ID = ___________________________
ACCESS_TOKEN = ___________________________
TEST_EVENT_CODE = ___________________________
No data mapping in this layer — we are setting up infrastructure, not sending events.
No trigger in this layer.
No workflow in this layer.
All steps are manual in this layer — this is one-time setup.
No event transmission in this layer.
No event sent yet.
Verify: Dataset is active (green dot in Events Manager). Access token is generated. Test Event Code is visible.
Send a manual test event using curl to verify your credentials work:
curl -X POST \
"https://graph.facebook.com/v21.0/YOUR_PIXEL_ID/events" \
-H "Content-Type: application/json" \
-d '{
"data": [{
"event_name": "PageView",
"event_time": CURRENT_UNIX_TIMESTAMP,
"action_source": "website",
"user_data": {
"client_ip_address": "1.2.3.4",
"client_user_agent": "Mozilla/5.0 Test"
}
}],
"test_event_code": "YOUR_TEST_EVENT_CODE",
"access_token": "YOUR_ACCESS_TOKEN"
}'
Replace YOUR_PIXEL_ID, CURRENT_UNIX_TIMESTAMP (get it: date +%s in terminal, or use an online converter), YOUR_TEST_EVENT_CODE, and YOUR_ACCESS_TOKEN.
Go to Events Manager → Test Events tab. You should see a PageView event appear within 30 seconds.
A PageView event in the Test Events tab with "Conversions API" as the source. The response from curl should be:
{"events_received": 1, "messages": [], "fbtrace_id": "..."}
Your access token is wrong or has been revoked. Go back to Events Manager → Settings → generate a new one.
Check your JSON syntax carefully. Common issues: missing commas, wrong quotes (use straight double quotes, not curly), event_time must be a number (no quotes), and the timestamp must be within the last 7 days.
Wait 60 seconds and refresh. Check that your test_event_code matches exactly (case-sensitive). Check the curl response — if events_received is 1, the event was accepted; give Meta time to process it.
1. What is the difference between the Meta Pixel and the Conversions API?
2. Why does one Dataset have two input paths (browser and server)?
3. What does the Test Event Code do, and why should you use it during development?
4. What would happen if you sent a CAPI event without the test_event_code?
Create a free test website and install the Meta Pixel to track browser-side page views.
The client's real site is on Shopify, but for this laboratory we use a free static website (GitHub Pages). This lets you understand Pixel installation at the HTML level — without Shopify's abstraction. In production, Shopify installs the Pixel via its Meta channel app, but the underlying mechanism is the same: a JavaScript snippet in the page's <head>.
VISITOR (browser)
│
▼
┌────────────────────────────┐
│ YOUR WEBSITE │
│ (GitHub Pages) │
│ │
│ <head> │
│ Meta Pixel base code │
│ fbq('init', PIXEL_ID) │
│ fbq('track','PageView')│
│ </head> │
└────────────────────────────┘
│
│ fbevents.js fires
│ HTTP request to Meta
▼
┌────────────────────────────┐
│ META EVENTS MANAGER │
│ → PageView received │
│ → Source: Pixel (browser) │
└────────────────────────────┘
Meta Pixel base code: A JavaScript snippet that loads fbevents.js, initializes with your Pixel ID, and fires a PageView event on every page load. This is the foundation of browser-side tracking.
GitHub Pages: Free static website hosting from GitHub. You push HTML files to a repository and GitHub serves them at https://yourusername.github.io/reponame/. You have full control over the HTML, including the <head> section where the Pixel goes.
Why not Shopify for the lab: Shopify requires a paid plan ($39/month+). The Meta channel on Shopify installs the Pixel automatically — useful for a real client, but it hides the mechanism. You need to understand what the Pixel code actually is before relying on a platform to install it for you.
meta-capi-lab → Public → check "Add a README file" → Create.main, folder: / (root) → Save. Your site will be live at https://YOURUSERNAME.github.io/meta-capi-lab/ within a minute.index.html. In the repository, click "Add file" → "Create new file" → name it index.html → paste the following:<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScaleUp Partner — Discovery Call</title>
<!-- META PIXEL BASE CODE -->
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');
</script>
<noscript><img height="1" width="1" style="display:none"
src="https://www.facebook.com/tr?id=YOUR_PIXEL_ID&ev=PageView&noscript=1"
/></noscript>
<!-- END META PIXEL BASE CODE -->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8f9fa;
color: #1a1a2e; line-height: 1.6; }
.hero { max-width: 640px; margin: 3rem auto; padding: 2rem;
background: #fff; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
h1 { font-size: 1.6rem; margin-bottom: 0.5rem; }
p { margin: 0.5rem 0; color: #555; }
.cta { display: inline-block; margin-top: 1rem; padding: 0.8rem 2rem;
background: #1565E6; color: #fff; text-decoration: none;
border-radius: 8px; font-weight: 600; }
#typeform-container { margin-top: 2rem; min-height: 500px; }
</style>
</head>
<body>
<div class="hero">
<h1>Get More Qualified Leads for Your Business</h1>
<p>ScaleUp Partner helps B2B companies generate qualified enquiries
through Meta advertising. Fill out the form below to see if we're
a good fit.</p>
<div id="typeform-container">
<!-- Typeform will be embedded here in Layer 3 -->
<p style="color:#999; text-align:center; padding:3rem;">
[Typeform embed placeholder — completed in Layer 3]
</p>
</div>
</div>
</body>
</html>
YOUR_PIXEL_ID in both places (the fbq('init',...) call and the <noscript> image) with your actual Pixel ID from Layer 1.https://YOURUSERNAME.github.io/meta-capi-lab/. The Pixel Helper should show a green badge with "1" (one PageView event detected).Open Events Manager → Overview or Test Events. Within a few minutes, you should see a PageView event from your Pixel, with "Pixel" as the connection method.
A live website with Meta Pixel installed. Every visit generates a PageView event visible in Events Manager.
View page source (Ctrl+U) and verify the Pixel base code is present and your Pixel ID is correct. Check the browser console (F12 → Console) for JavaScript errors. Ensure you replaced both instances of YOUR_PIXEL_ID.
GitHub Pages takes 1–2 minutes to deploy. Check Settings → Pages to confirm it's enabled. Ensure the file is named exactly index.html (lowercase).
<head>1. What does fbq('init', 'PIXEL_ID') do?
2. What does fbq('track', 'PageView') do?
3. Why is the Pixel code placed in the <head> rather than the <body>?
4. In the real client project, how would the Pixel be installed on Shopify instead of manually?
Create a qualifying Typeform and embed it in your test website. Understand the boundary between the website and the embedded form.
The client's funnel starts with a Typeform that captures lead information. You need to understand exactly how an embedded Typeform relates to the parent page — because this boundary determines whether the Meta Pixel can detect form submissions.
┌─────────────────────────────────────────────┐
│ YOUR WEBSITE (GitHub Pages) │
│ ┌─ Meta Pixel (runs here) ──────────────┐ │
│ │ Can see: page loads, clicks, │ │
│ │ URL changes on THIS domain │ │
│ └───────────────────────────────────────┘ │
│ │
│ ┌─ TYPEFORM IFRAME ─────────────────────┐ │
│ │ ╔═══════════════════════════════════╗ │ │
│ │ ║ Typeform renders HERE ║ │ │
│ │ ║ Separate origin (typeform.com) ║ │ │
│ │ ║ Pixel CANNOT see inside this ║ │ │
│ │ ╚═══════════════════════════════════╝ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
iframe isolation: When Typeform is embedded on your page, it renders inside an <iframe>. The iframe's origin is typeform.com, not your domain. Due to browser same-origin policy, your Meta Pixel JavaScript cannot detect events inside the iframe — it cannot see form field inputs, button clicks, or submission. This is a fundamental browser security boundary.
Typeform embed SDK: Typeform provides an embed library (@typeform/embed) that offers callbacks — including onSubmit — that fire on the parent page when the user completes the form inside the iframe. This is the bridge that lets your page know something happened inside the Typeform.
https://form.typeform.com/to/AbCdEf. The AbCdEf part is your form ID.<div data-tf-widget="AbCdEf" data-tf-inline-on-mobile
data-tf-medium="snippet" style="width:100%;height:500px;">
</div>
<script src="//embed.typeform.com/next/embed.js"></script>
index.html. Replace the placeholder inside #typeform-container with the embed code from Typeform. Your updated container should look like:<div id="typeform-container">
<div data-tf-widget="AbCdEf" data-tf-inline-on-mobile
data-tf-medium="snippet"
data-tf-on-submit="typeformSubmitted"
style="width:100%;height:500px;">
</div>
<script src="//embed.typeform.com/next/embed.js"></script>
</div>
Note the addition of data-tf-on-submit="typeformSubmitted" — this is the callback we'll use in Layer 4 to detect submission. Add it now.
| Typeform Field | Data Captured | Used Later In |
|---|---|---|
| Full Name | fn, ln | CAPI user_data (hashed) |
| em | CAPI user_data (hashed), Google Sheet, identity chain | |
| Phone | ph | CAPI user_data (hashed), Google Sheet |
| Country | country | CAPI user_data (hashed), Google Sheet |
| Business | qualification | Google Sheet |
| Budget | qualification | Google Sheet |
| Challenge | qualification | Google Sheet |
Submit the form with test data. After submission, the Typeform should show a "Thank you" screen (or redirect, depending on settings). The Meta Pixel Helper should still show the PageView — but no Lead event yet. That comes in Layer 4.
Check that the embed script URL is correct: //embed.typeform.com/next/embed.js. Ensure your form ID is correct. Try opening the form URL directly (https://form.typeform.com/to/AbCdEf) to verify it's published.
data-tf-on-submit="typeformSubmitted" attribute is present (for Layer 4)1. Why can't the Meta Pixel detect form submission inside an embedded Typeform?
2. What is the same-origin policy and how does it apply here?
3. What does data-tf-on-submit do?
Fire a Meta Lead event when a visitor submits the Typeform. This is Handshake #1.
This is the client's existing tracking mechanism — the only event Meta currently receives. We implement it properly so we understand the baseline before adding the four remaining handshakes.
Visitor submits Typeform
│
▼ (Typeform embed SDK fires onSubmit callback)
│
▼ (callback runs on PARENT page, where Pixel lives)
│
fbq('track', 'Lead')
│
▼ (Pixel sends Lead event to Meta via browser)
│
META EVENTS MANAGER → Lead received (source: Pixel)
The onSubmit callback bridge: When the Typeform embed SDK detects a successful submission inside the iframe, it calls the JavaScript function named in data-tf-on-submit on the parent page. This is the bridge across the iframe boundary. The callback receives the response ID and any hidden fields.
fbq('track', 'Lead'): This is a Meta Pixel standard event call. When executed, the Pixel JavaScript sends a Lead event to Meta via the visitor's browser. Because it runs in the browser, it has access to the _fbp cookie (first-party browser ID) and the _fbc cookie (click ID from fbclid parameter), which help Meta match the event back to the ad click.
index.html. Before the closing </body> tag, add this script:<script>
function typeformSubmitted(event) {
// event.responseId contains the Typeform response ID
console.log('Typeform submitted. Response ID:', event.responseId);
// Fire the Meta Lead event via Pixel
if (typeof fbq === 'function') {
fbq('track', 'Lead', {
content_name: 'Discovery Application',
content_category: 'B2B Lead'
});
console.log('Meta Lead event fired via Pixel.');
} else {
console.error('fbq not found — Meta Pixel may not be loaded.');
}
}
</script>
| Source | Meta Parameter | Value |
|---|---|---|
| Typeform submission event | event_name | Lead (standard event) |
| Pixel JavaScript | _fbp cookie | Auto-captured by Pixel |
| URL parameter | _fbc cookie | Auto-captured by Pixel (if fbclid present in URL) |
| Browser | IP address, user agent | Auto-captured by Pixel |
Typeform form submission → onSubmit callback on parent page.
No automation platform involved. This is a pure browser-side event: Typeform SDK → JavaScript callback → Pixel → Meta.
None. Fully automated — fires every time a visitor submits the form.
Browser-side. The visitor's browser sends the event to Meta. This means: the visitor's IP address and user agent are automatically included; the _fbp and _fbc cookies provide identity; but the visitor's email and phone are not sent (the Pixel standard event call does not include form field data by default).
event_name: "Lead"
event source: Pixel (browser)
action_source: "website" (implicit — browser events are always website)
user_data: _fbp, _fbc, IP, user agent (auto-captured)
custom_data: content_name, content_category (from our fbq call)
Lead is a Meta standard event. It appears automatically in Events Manager once received. No Custom Conversion needed.
Submit the Typeform. Check: (1) browser console for log messages, (2) Pixel Helper for Lead event, (3) Events Manager for Lead event.
Lead event in Events Manager with source "Pixel." Event Match Quality may be low because the browser event only carries cookies and IP — no hashed email or phone. This is normal for browser-side events and is one reason the Conversions API exists.
The Pixel base code is not loading. Check that the Pixel snippet is in the <head> and your Pixel ID is correct. Check for JavaScript errors above the callback in the console.
The data-tf-on-submit="typeformSubmitted" attribute is missing from the Typeform embed div, or the function name doesn't match exactly (JavaScript is case-sensitive).
1. Why doesn't the Pixel automatically detect the Typeform submission?
2. What identity information does this browser-side Lead event carry?
3. Why might Event Match Quality be low for this event?
4. How could you improve identity matching for this event? (Hint: think about what the Conversions API can add.)
After Typeform submission, redirect the visitor to a Calendly booking page.
In the client's funnel, after submitting the qualifying form, the visitor should be able to book a discovery call. This is a navigation step — moving the visitor from the form to the booking page.
Visitor submits Typeform
│
▼ onSubmit callback fires
│
├── fbq('track', 'Lead') ← Handshake #1
│
└── window.location.href = Calendly URL ← redirect
│
▼
Visitor lands on Calendly booking page
Redirect mechanism: In the typeformSubmitted callback, after firing the Lead event, we redirect the visitor to the Calendly booking page. This is a simple JavaScript redirect. Alternatively, Typeform's own "Redirect on completion" setting (in form settings) can handle this — but that may require a paid Typeform plan.
Typeform's built-in redirect-on-completion may require a paid plan (Plus or higher). In this lab, we use the JavaScript redirect in the onSubmit callback instead — this works on all plans because it runs on your page, not inside Typeform.
Calendly Free plan allows only 1 event type. Create a single "Discovery Call" event type for this lab.
https://calendly.com/yourusername/discovery-call.typeformSubmitted function in your index.html to add the redirect:<script>
function typeformSubmitted(event) {
console.log('Typeform submitted. Response ID:', event.responseId);
// Fire the Meta Lead event via Pixel
if (typeof fbq === 'function') {
fbq('track', 'Lead', {
content_name: 'Discovery Application',
content_category: 'B2B Lead'
});
console.log('Meta Lead event fired via Pixel.');
}
// Redirect to Calendly after a short delay
// (delay gives the Pixel time to send the Lead event)
setTimeout(function() {
window.location.href =
'https://calendly.com/YOURUSERNAME/discovery-call';
}, 1500);
}
</script>
YOURUSERNAME with your actual Calendly username.Submit the form → verify Lead event fires (check Pixel Helper quickly before redirect) → confirm you land on the Calendly booking page.
The redirect may be happening before the Pixel has time to send the event. Increase the setTimeout delay to 2000–3000ms. In production, you might use the Pixel's fbq('track', 'Lead') with a callback, but the simple delay is sufficient for learning.
1. Why do we use a setTimeout before redirecting?
2. Could we install the Meta Pixel on the Calendly page to capture the booking? Why or why not?
3. If this were a production implementation, what concerns would you have about the setTimeout approach?
Create a Google Sheet that tracks every lead through the entire funnel. This sheet serves as both a reporting tool and a trigger source for Handshakes #3 and #4.
The client needs visibility into the full funnel. The sheet also serves as the trigger mechanism for the "attended" and "qualified" events — when a team member updates a row, it triggers an automation. This is where the manual business decisions (attendance, qualification) become automated technical transmissions.
| Column | Header | Purpose | Populated By |
|---|---|---|---|
| A | Lead_ID | Unique identifier | Auto (formula or n8n) |
| B | Date | Typeform submission date | Auto |
| C | Name | Full name | Typeform |
| D | Email address | Typeform | |
| E | Phone | Phone number | Typeform |
| F | Country | Country | Typeform |
| G | Typeform_Response_ID | Typeform's response ID | Auto |
| H | Calendly_Booked | TRUE/FALSE | n8n/Zapier |
| I | Calendly_Event_URI | Calendly event identifier | n8n/Zapier |
| J | Attended | TRUE/FALSE | Manual (team member) |
| K | Qualified | TRUE/FALSE | Manual (team member) |
| L | Signed | TRUE/FALSE | Stripe webhook / manual |
| M | Value | Contract value (€) | Stripe |
| N | Stripe_Payment_ID | Stripe payment identifier | n8n/Zapier |
| O | CallAttended_Sent | Timestamp | n8n (prevents duplicate send) |
| P | QualifiedLead_Sent | Timestamp | n8n (prevents duplicate send) |
| Q | Notes | Free text | Manual |
=IF(D2="","",TEXT(ROW()-1,"0000")). This generates a simple sequential ID when an email is present. Drag down for 100 rows. In production, you would use a more robust ID (e.g., combining a date hash with a counter).test@example.com), phone (+1234567890), and country. Leave Attended, Qualified, Signed as empty (they'll be filled manually later).Email alone is not a reliable primary identifier: people use multiple email addresses, typos occur, and email is PII that needs careful handling. A stable Lead_ID lets you track a lead across the funnel without depending solely on email matching. It also becomes part of event ID generation for deduplication (Layer 17).
1. Why are columns O and P (CallAttended_Sent, QualifiedLead_Sent) important?
2. What would happen if someone edited the Attended column twice without deduplication protection?
3. Why is Lead_ID superior to using email as the sole identifier?
Learn the core concepts of n8n by building a simple test workflow. Understand nodes, triggers, data flow, and executions before building the real CAPI workflows.
n8n is where you will build the four server-side CAPI workflows (Handshakes #2–#5). Before doing that, you need to understand how n8n works: what a workflow is, how data flows between nodes, how webhooks trigger workflows, and how to inspect the data at each step.
Workflow: A sequence of connected nodes that processes data. Equivalent to a Zapier "Zap," but with more visibility and control.
Node: A single operation in a workflow — a trigger, a data transformation, an HTTP request, a conditional branch. Equivalent to a Zapier "step."
Trigger node: The first node in a workflow. It starts the workflow when an event occurs. Examples: Webhook (receives an HTTP request), Schedule (runs on a timer), Google Sheets Trigger (detects row changes).
Webhook: An HTTP endpoint that n8n creates for you. When an external service (Calendly, Stripe) sends an HTTP POST to this URL, n8n triggers the workflow.
HTTP Request node: Makes an HTTP request to an external API. This is what you will use to send events to Meta's Conversions API.
Code node: Runs custom JavaScript. You will use this for SHA-256 hashing of PII and event ID generation.
Execution: One run of a workflow. n8n logs every execution so you can see the input/output of each node — this is invaluable for debugging.
Expressions: Dynamic values in node fields. Syntax: {{ $json.fieldName }} to reference data from a previous node's output.
Credentials: Stored authentication details (API keys, OAuth tokens). Configured once, reusable across workflows.
http://localhost:5678 (self-hosted) or your n8n Cloud URL. Create an account if prompted.test-echo
http://localhost:5678/webhook/test-echo (or your cloud URL equivalent).
received_message
{{ $json.body.name || 'no name provided' }}
timestamp
{{ new Date().toISOString() }}
{{ JSON.stringify({ status: 'ok', received: $json.received_message, at: $json.timestamp }) }}
curl -X POST http://localhost:5678/webhook/test-echo \
-H "Content-Type: application/json" \
-d '{"name": "Test Student", "email": "test@example.com"}'
You should receive a JSON response with the status, received name, and timestamp.
In n8n, you can see the raw HTTP request that arrived, the exact JSON body, every transformation, and the exact HTTP request that went out. Zapier abstracts this. When something goes wrong in Zapier, you see "Task failed." In n8n, you see the exact HTTP status code, the error response body, and the data that caused it.
1. What is the difference between a Webhook trigger and a Schedule trigger?
2. What does the expression {{ $json.body.name }} mean?
3. Where do you look in n8n when a workflow fails?
4. How is this different from building the same thing in Zapier?
Understand the exact structure of a Conversions API request. Build and send a manual CAPI event from n8n, with proper PII hashing.
Before building automated workflows, you must understand what happens at the HTTP level when you "send an event to Meta." Every Zapier action and every n8n HTTP Request node is ultimately making this same HTTP POST request. If you understand the raw API, you can debug any integration on any platform.
YOUR SERVER (n8n)
│
│ HTTP POST
│ https://graph.facebook.com/v21.0/{PIXEL_ID}/events
│
│ Headers:
│ Content-Type: application/json
│
│ Body (JSON):
│ {
│ "data": [{
│ "event_name": "Lead",
│ "event_time": 1700000000,
│ "event_id": "unique-id-here",
│ "action_source": "website",
│ "user_data": {
│ "em": ["sha256-hashed-email"],
│ "ph": ["sha256-hashed-phone"],
│ "fn": ["sha256-hashed-first-name"],
│ "ln": ["sha256-hashed-last-name"],
│ "client_ip_address": "1.2.3.4",
│ "client_user_agent": "Mozilla/5.0..."
│ },
│ "custom_data": {}
│ }],
│ "access_token": "YOUR_ACCESS_TOKEN",
│ "test_event_code": "TEST12345"
│ }
│
▼
META GRAPH API
│
▼
Response: {"events_received": 1, "messages": []}
Endpoint: https://graph.facebook.com/v21.0/{PIXEL_ID}/events — replace {PIXEL_ID} with your Pixel ID. Verify the current API version at developers.facebook.com/docs/graph-api/changelog.
Required fields in every CAPI event:
| Field | Type | Description |
|---|---|---|
event_name | string | The event name: Lead, Schedule, Purchase, or custom names like CallAttended |
event_time | integer | Unix timestamp (seconds). Must be within the last 7 days. |
action_source | string | "website" for events originating from web interactions. "system_generated" for internal business process events (attendance, qualification). |
user_data | object | Identity information. At minimum: one of em, ph, or external_id. |
SHA-256 hashing: All PII in user_data must be hashed with SHA-256 before sending. The process: normalize (lowercase, trim whitespace) → hash. Meta hashes its own data the same way and compares hashes to match users.
Hashing rules:
| Field | Normalize | Then Hash |
|---|---|---|
em (email) | lowercase, trim | SHA-256 |
ph (phone) | digits only (remove +, -, spaces, parens), include country code | SHA-256 |
fn (first name) | lowercase, trim | SHA-256 |
ln (last name) | lowercase, trim | SHA-256 |
ct (city) | lowercase, trim, no punctuation | SHA-256 |
country | 2-letter ISO code, lowercase | SHA-256 |
Fields that are NOT hashed: client_ip_address, client_user_agent, fbc, fbp, external_id.
const crypto = require('crypto');
// --- Test identity data ---
const email = 'test@example.com';
const phone = '+1 (234) 567-8900';
const firstName = 'Test';
const lastName = 'Student';
// --- Hashing helper ---
function sha256(value) {
return crypto.createHash('sha256')
.update(value)
.digest('hex');
}
// --- Normalize and hash ---
const hashedEmail = sha256(email.toLowerCase().trim());
const hashedPhone = sha256(phone.replace(/[^0-9]/g, ''));
const hashedFn = sha256(firstName.toLowerCase().trim());
const hashedLn = sha256(lastName.toLowerCase().trim());
// --- Build the CAPI payload ---
const payload = {
data: [{
event_name: 'Lead',
event_time: Math.floor(Date.now() / 1000),
event_id: 'test-lead-' + Date.now(),
action_source: 'website',
user_data: {
em: [hashedEmail],
ph: [hashedPhone],
fn: [hashedFn],
ln: [hashedLn],
client_ip_address: '1.2.3.4',
client_user_agent: 'Mozilla/5.0 (Lab Test)'
},
custom_data: {
content_name: 'Discovery Application'
}
}],
access_token: 'YOUR_ACCESS_TOKEN',
test_event_code: 'YOUR_TEST_EVENT_CODE'
};
return [{ json: payload }];
YOUR_ACCESS_TOKEN and YOUR_TEST_EVENT_CODE with your actual values from Layer 1.https://graph.facebook.com/v21.0/YOUR_PIXEL_ID/events
{{ JSON.stringify($json) }}
{"events_received": 1}.You built the exact same HTTP request that Zapier's "Facebook Conversions" action makes internally. The difference: you can see every byte of the request and response. When Zapier says "Task failed," you'll know where to look because you understand what's underneath.
client_ip_address (not hashed) and em (hashed)1. Why must PII be hashed before sending to Meta?
2. What is the normalization step and why is it necessary before hashing?
3. What is action_source and when would you use "website" vs "system_generated"?
4. What is event_id and why is it important? (Detailed answer in Layer 17.)
5. What happens if event_time is older than 7 days?
Build an automated workflow: when a Calendly booking occurs, send a Schedule event to Meta via the Conversions API.
This is the first fully automated server-side handshake. A visitor books a call on Calendly → Calendly sends a webhook to n8n → n8n transforms the data and sends it to Meta. The client specifically requested this as "Zap 1."
CALENDLY
│ invitee.created webhook
│ (name, email, questions_and_answers, event URI)
▼
N8N WEBHOOK TRIGGER
│
▼
CODE NODE (extract data, normalize, SHA-256 hash)
│
▼
HTTP REQUEST NODE
│ POST https://graph.facebook.com/v21.0/{PIXEL_ID}/events
│ event_name: "Schedule"
│ user_data: { em, ph, fn, ln }
│ action_source: "website"
▼
META EVENTS MANAGER → Schedule received
Calendly webhooks: Calendly can send HTTP POST notifications when events occur. The key event for us is invitee.created — fired when someone books a meeting. The webhook payload includes the invitee's name, email, and answers to any custom questions you've set up. Phone number may be included if you've added a phone field to the booking form.
Calendly Free plan: Does not include API webhook subscriptions through the Calendly dashboard. However, you can create webhook subscriptions via the Calendly API using a Personal Access Token (free). Alternatively, n8n has a built-in Calendly trigger node that handles this if you connect your Calendly account.
To get a Personal Access Token: Go to calendly.com → Integrations → API & Webhooks → Personal Access Tokens → Create.
calendly-schedule
https://your-tunnel.ngrok.io/webhook/calendly-schedule).
curl -X POST https://api.calendly.com/webhook_subscriptions \
-H "Authorization: Bearer YOUR_CALENDLY_PAT" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-n8n-url/webhook/calendly-schedule",
"events": ["invitee.created"],
"organization": "https://api.calendly.com/organizations/YOUR_ORG_ID",
"scope": "organization"
}'
To find your organization URI, call: curl -H "Authorization: Bearer YOUR_PAT" https://api.calendly.com/users/me and look at current_organization.
payload.invitee:const crypto = require('crypto');
function sha256(val) {
return crypto.createHash('sha256').update(val).digest('hex');
}
// Extract from Calendly webhook payload
const invitee = $json.body?.payload?.invitee || $json.payload?.invitee || {};
const event = $json.body?.payload?.event || $json.payload?.event || {};
const email = (invitee.email || '').toLowerCase().trim();
const name = (invitee.name || '').trim();
const nameParts = name.split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
// Look for phone in questions_and_answers if configured
const questions = invitee.questions_and_answers || [];
const phoneAnswer = questions.find(q =>
q.question.toLowerCase().includes('phone'));
const phone = phoneAnswer ? phoneAnswer.answer.replace(/[^0-9]/g, '') : '';
// Calendly event URI — used as the basis for event_id
const calendlyEventUri = event.uri || '';
const calendlyEventId = calendlyEventUri.split('/').pop() || '';
// Build CAPI payload
const payload = {
data: [{
event_name: 'Schedule',
event_time: Math.floor(Date.now() / 1000),
event_id: 'schedule-' + calendlyEventId,
action_source: 'website',
user_data: {
em: email ? [sha256(email)] : [],
ph: phone ? [sha256(phone)] : [],
fn: firstName ? [sha256(firstName.toLowerCase())] : [],
ln: lastName ? [sha256(lastName.toLowerCase())] : []
}
}],
access_token: 'YOUR_ACCESS_TOKEN',
test_event_code: 'YOUR_TEST_EVENT_CODE'
};
return [{ json: payload }];
https://graph.facebook.com/v21.0/YOUR_PIXEL_ID/events
{{ JSON.stringify($json) }}
| Calendly Field | n8n Transformation | CAPI Field |
|---|---|---|
invitee.email | lowercase, trim, SHA-256 | user_data.em |
invitee.name | split, lowercase, SHA-256 | user_data.fn, user_data.ln |
questions_and_answers[phone] | digits only, SHA-256 | user_data.ph |
event.uri | extract ID → prefix with "schedule-" | event_id |
Calendly invitee.created webhook → n8n Webhook node.
None. Fully automated.
Server-to-server. n8n sends the HTTP POST to Meta. No browser involved.
event_name: "Schedule" (standard event)
action_source: "website"
event_id: "schedule-{calendly_event_id}" (for deduplication)
user_data: em, ph, fn, ln (all SHA-256 hashed)
Schedule is a Meta standard event. It appears automatically in Events Manager. No Custom Conversion required.
{"events_received": 1}Verify the webhook URL is accessible from the internet (not just localhost). Check your ngrok tunnel is running. Verify the webhook subscription is active: curl -H "Authorization: Bearer YOUR_PAT" https://api.calendly.com/webhook_subscriptions?organization=YOUR_ORG_URI
Calendly doesn't include phone by default. You need to add a "Phone Number" custom question to your event type in Calendly settings. Go to Event Type → "Invitee Questions" → add a phone question.
The event_time must be a Unix timestamp in seconds (not milliseconds) and within the last 7 days. Verify: Math.floor(Date.now() / 1000) gives seconds.
1. Why do we use the Calendly event URI as the basis for event_id instead of a random value?
2. Why is action_source set to "website" even though this is a server-to-server request?
3. What identity information is available from Calendly? What is NOT available?
4. If the client also wanted to fire a browser-side Schedule event (e.g., on a thank-you page), how would deduplication work?
Build the workflow for the CallAttended event: when a team member marks a lead as having attended the call (in Google Sheets), send the event to Meta.
The client requested: "Call attended (marked in Calendly) → Fire Meta custom event 'CallAttended'." As we identified in Part 2, Calendly does not have an "attended" status. The real architecture uses Google Sheets as the trigger: a human marks Attended = TRUE, and the automation fires.
DISCOVERY CALL OCCURS
│
▼ (human decision)
TEAM MEMBER opens Google Sheet
│
▼ (manual action)
Sets column J (Attended) = TRUE
│
▼ (automatic trigger)
N8N — Google Sheets Trigger (row updated)
│
▼ (condition check)
IF node: Attended = TRUE AND CallAttended_Sent is empty
│
▼ (data extraction)
CODE NODE: read email, phone from sheet row → SHA-256 hash
│
▼ (API call)
HTTP REQUEST: POST to CAPI → event_name: "CallAttended"
│
▼ (record keeping)
GOOGLE SHEETS NODE: write timestamp to CallAttended_Sent column
│
▼
META EVENTS MANAGER → CallAttended received
Manual business decision vs automated transmission: This is the clearest example of the separation. "Did the prospect attend the call?" is a human judgment. "Send the CallAttended event to Meta" is an automated technical step. The Google Sheet is the bridge between the two worlds.
Duplicate-send prevention: If someone edits the Attended column again (e.g., corrects a typo in another column on the same row), the Google Sheets trigger may fire again. Without protection, Meta would receive a duplicate CallAttended event. Column O (CallAttended_Sent) prevents this: the workflow only fires if this column is empty, and it fills the column with a timestamp after successful transmission.
{{ $json.Attended }} equals TRUE AND {{ $json.CallAttended_Sent }} is empty.const crypto = require('crypto');
function sha256(val) {
return crypto.createHash('sha256').update(val).digest('hex');
}
const email = ($json.Email || '').toLowerCase().trim();
const phone = ($json.Phone || '').replace(/[^0-9]/g, '');
const name = ($json.Name || '').trim();
const nameParts = name.split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
const leadId = $json.Lead_ID || '';
const payload = {
data: [{
event_name: 'CallAttended',
event_time: Math.floor(Date.now() / 1000),
event_id: 'callattended-' + leadId,
action_source: 'system_generated',
user_data: {
em: email ? [sha256(email)] : [],
ph: phone ? [sha256(phone)] : [],
fn: firstName ? [sha256(firstName.toLowerCase())] : [],
ln: lastName ? [sha256(lastName.toLowerCase())] : [],
external_id: leadId ? [sha256(leadId)] : []
}
}],
access_token: 'YOUR_ACCESS_TOKEN',
test_event_code: 'YOUR_TEST_EVENT_CODE'
};
return [{ json: { ...payload, _rowNumber: $json._rowNumber || $json.rowIndex } }];
{{ $json._rowNumber }} (from the Code node output)
{{ new Date().toISOString() }}
For this event, we use action_source: "system_generated" rather than "website". The attendance decision is an internal business process — it didn't happen on a website. Meta documentation specifies that system_generated is for events that occur "as a result of your system" rather than a direct user action on a website or app.
Check: (1) the poll interval — if set to every minute, wait at least 60 seconds; (2) the OAuth connection is valid; (3) the sheet is shared with the correct Google account; (4) you're editing the correct sheet (Sheet1 vs Sheet2).
Check the exact value in the Attended column. It must be exactly TRUE (or a boolean true, depending on how Google Sheets reports it). Use n8n's expression editor to inspect the actual value. Try: {{ $json.Attended === true || $json.Attended === 'TRUE' || $json.Attended === 'true' }}.
CallAttended visible in Events Manager1. Why is action_source set to "system_generated" here but "website" for Schedule?
2. Why do we need the CallAttended_Sent column?
3. Is CallAttended a standard Meta event or a custom event? How does Meta treat it?
4. What would happen if the team accidentally set Attended = TRUE for the wrong person?
Build the workflow for the QualifiedLead event: when Qualified = TRUE in Google Sheets, send the event to Meta.
This is architecturally identical to Layer 10 — same trigger mechanism (Google Sheets row update), same conditional logic (check column + deduplication guard), same CAPI transmission pattern. The only differences are: the column being watched (K: Qualified instead of J: Attended), the event name (QualifiedLead instead of CallAttended), and the deduplication column (P: QualifiedLead_Sent instead of O: CallAttended_Sent).
Same as Layer 10, but:
- Watch column: K (Qualified)
- Guard column: P (QualifiedLead_Sent)
- event_name: "QualifiedLead"
- event_id: "qualifiedlead-{Lead_ID}"
Qualified = TRUE AND QualifiedLead_Sent is empty.event_name: 'QualifiedLead' and event_id: 'qualifiedlead-' + leadId.QualifiedLead_Sent) instead of column O.You could handle both Attended and Qualified in a single workflow with branching logic. In this lab, we use separate workflows for clarity — each handshake is its own workflow. In production, the choice depends on maintainability: separate workflows are easier to debug; combined workflows reduce the number of Google Sheets triggers polling your sheet.
1. What are the similarities and differences between Handshake #3 and #4?
2. Could a lead be marked as Qualified without first being marked as Attended? Should the workflow enforce this?
3. In what order should the events reach Meta for a single lead? Does Meta care about order?
Build the workflow for the Purchase event: when a Stripe payment is received, send the event to Meta with the transaction value and currency.
This is the most valuable signal in the funnel. A Purchase event with value tells Meta exactly how much revenue each conversion generates — enabling return-on-ad-spend (ROAS) optimization.
CLIENT PAYS VIA STRIPE
│
▼ (Stripe sends webhook)
│ checkout.session.completed
│ (customer email, payment amount, currency)
│
N8N WEBHOOK TRIGGER
│
▼
CODE NODE
│ Extract: email, amount (convert cents → whole), currency
│ SHA-256 hash email
│ Generate event_id from Stripe session/payment ID
│
▼
HTTP REQUEST → CAPI
│ event_name: "Purchase"
│ custom_data: { value: 1400.00, currency: "EUR" }
│ user_data: { em: [hashed_email] }
│
▼
META EVENTS MANAGER → Purchase with value received
Stripe test mode: Every Stripe account has a test mode with separate API keys. Test mode uses test card numbers (like 4242 4242 4242 4242) and no real money is charged. Webhooks in test mode work the same as production.
Amounts in cents: Stripe reports amounts in the currency's smallest unit. For EUR, this means cents. A payment of €1,400.00 is reported as 140000 in Stripe. You must divide by 100 to get the actual amount for Meta's value field.
Purchase as a standard event: Purchase is a Meta standard event. It supports value and currency in custom_data, which enables ROAS-based optimization in Ads Manager.
stripe-purchase. Note the URL.checkout.session.completed
const crypto = require('crypto');
function sha256(val) {
return crypto.createHash('sha256').update(val).digest('hex');
}
// Extract from Stripe webhook
const session = $json.body?.data?.object || $json.data?.object || {};
const email = (session.customer_details?.email
|| session.customer_email || '').toLowerCase().trim();
// Stripe amounts are in cents — convert to whole currency units
const amountInCents = session.amount_total || 0;
const value = amountInCents / 100;
const currency = (session.currency || 'eur').toUpperCase();
// Stripe session/payment ID — stable identifier for event_id
const stripeSessionId = session.id || '';
const paymentIntent = session.payment_intent || '';
const payload = {
data: [{
event_name: 'Purchase',
event_time: Math.floor(Date.now() / 1000),
event_id: 'purchase-' + (paymentIntent || stripeSessionId),
action_source: 'website',
user_data: {
em: email ? [sha256(email)] : [],
external_id: stripeSessionId ? [sha256(stripeSessionId)] : []
},
custom_data: {
value: value,
currency: currency
}
}],
access_token: 'YOUR_ACCESS_TOKEN',
test_event_code: 'YOUR_TEST_EVENT_CODE'
};
return [{ json: payload }];
checkout.session.completed → Send.# Install Stripe CLI, then:
stripe trigger checkout.session.completed
| Stripe Field | Transformation | CAPI Field |
|---|---|---|
customer_details.email | lowercase, trim, SHA-256 | user_data.em |
amount_total | ÷ 100 (cents to whole) | custom_data.value |
currency | uppercase | custom_data.currency |
payment_intent | prefix "purchase-" | event_id |
You forgot to divide by 100. Stripe sends amounts in cents. Add: const value = amountInCents / 100;
The Stripe webhook's checkout.session.completed event structure varies based on how the Checkout Session was created. Try session.customer_details.email, session.customer_email, or fetch the customer object using the customer ID. For test webhooks sent via "Send test webhook," the email field may be empty — use the Stripe CLI's stripe trigger instead.
1. Why is the Purchase event more valuable to Meta's optimization algorithm than the Lead event?
2. Why do we divide the Stripe amount by 100?
3. Why do we use payment_intent for the event ID rather than a timestamp?
4. What would happen if this event were sent with value: 0?
Reproduce the four CAPI workflows (Handshakes #2–#5) in Zapier. The client specifically requested Zapier — this is the client-facing deliverable.
The client said "Build 4 Zapier Automations." They don't have Zapier yet and need guidance on plan selection. You've already built and tested the architecture in n8n. Now you translate it to Zapier, understanding what Zapier abstracts and what it exposes.
| n8n Concept | Zapier Equivalent |
|---|---|
| Workflow | Zap |
| Trigger Node | Trigger Step |
| Action Node | Action Step |
| Code Node | Code by Zapier (JavaScript or Python) |
| IF Node | Filter Step or Paths |
| HTTP Request Node | Webhooks by Zapier (POST) or the Facebook Conversions app |
Expression {{ $json.field }} | Field mapping (point-and-click) |
| Execution log | Task History |
| Credentials | Connected Accounts |
Free plan: 100 tasks/month, 5 Zaps, 2-step Zaps only. Cannot use Filters, Paths, or multi-step Zaps.
Professional plan ($29.99/month): Required for multi-step Zaps (Zaps 2 and 3 need a Filter step). 750 tasks/month.
Recommendation for client: Professional plan. Estimated monthly task usage with moderate lead volume (~50 leads/month): approximately 200 tasks.
Schedule
created_at field.
website
Invitee Email field. (Zapier's Facebook Conversions app handles hashing automatically.)
The Facebook Conversions app in Zapier automatically SHA-256 hashes the PII fields (email, phone, name) before sending to Meta. You don't need a Code step for hashing. This is convenient but means you can't inspect the hashed values — you trust Zapier to hash correctly.
CallAttended
system_generated
Same pattern as Zap 2. Change: Trigger Column to K (Qualified), Filter checks Qualified = TRUE AND QualifiedLead_Sent is empty, Event Name to QualifiedLead, and update column P.
EUR.
1. What does Zapier's Facebook Conversions app do automatically that you had to do manually in n8n?
2. What can you see in n8n's execution log that you cannot see in Zapier's task history?
3. Why does Zap 2 require a Professional plan but Zap 1 does not?
4. If a Zap fails, what information does Zapier give you for debugging compared to n8n?
Verify that all five events are being received correctly by Meta, with proper parameters and identity data.
| Check | Lead | Schedule | CallAttended | QualifiedLead | Purchase |
|---|---|---|---|---|---|
| Received? | ☐ | ☐ | ☐ | ☐ | ☐ |
| Source | Pixel | CAPI | CAPI | CAPI | CAPI |
| Event type | Standard | Standard | Custom | Custom | Standard |
| Has email? | ☐ | ☐ | ☐ | ☐ | ☐ |
| Has phone? | ☐ | ☐ | ☐ | ☐ | ☐ |
| Has event_id? | ☐ | ☐ | ☐ | ☐ | ☐ |
| Has value? | N/A | N/A | N/A | N/A | ☐ |
| Warnings? | |||||
| Event Match Quality |
fbc and fbp can push it to 9-10/10.Understand the difference between event transmission, event configuration, and ad optimization. Configure events appropriately in Meta — but do not blindly create Custom Conversions for everything.
Three separate things:
| Concept | What It Means | Where It Happens |
|---|---|---|
| Event Transmission | Sending the event data to Meta (via Pixel or CAPI) | Your website / your server (n8n/Zapier) |
| Event Configuration | How Meta categorizes, names, and organizes the event in its system | Meta Events Manager |
| Ad Optimization | Using the event as a signal for ad delivery optimization | Meta Ads Manager (campaign settings) |
Standard Events (Lead, Schedule, Purchase): Meta recognizes these names automatically. They appear in Events Manager with predefined categories. They can be used for optimization immediately.
Custom Events (CallAttended, QualifiedLead): These are event names Meta does not pre-define. They appear in Events Manager automatically once received. They can also be used for optimization — you can select them when creating a campaign.
Custom Conversions: A separate feature that lets you define a conversion based on rules — e.g., "Purchase events where value > €1,500." You do NOT need a Custom Conversion just to use an event for optimization. Custom Conversions are for creating filtered subsets of events.
The client said: "Configure Custom Conversions in Meta Events Manager so all 5 events are usable as optimisation events." This conflates Custom Conversions with optimization eligibility. The events are usable for optimization without creating Custom Conversions. Custom Conversions are only needed if you want to optimize for a subset (e.g., only purchases above a certain value).
Lead — Standard ✓
Schedule — Standard ✓
Purchase — Standard ✓ (with value and currency)
CallAttended — Custom ✓
QualifiedLead — Custom ✓
1. What is the difference between a custom event and a Custom Conversion?
2. When would you create a Custom Conversion?
3. Why might optimizing for Purchase not work immediately for this client?
4. What does "learning phase" mean in Meta's ad delivery?
Understand how identity is preserved across the entire funnel — from ad click to purchase — and why this is critical for Meta's ability to attribute conversions to the correct ad.
When Meta receives a Purchase event for test@example.com, it needs to connect that purchase back to the specific ad click that started the journey. If the identity chain is broken, Meta receives the event but cannot attribute it — and the advertiser gets no optimization benefit.
META AD CLICK
│ fbclid parameter added to URL
│ _fbc cookie set in browser
│ _fbp cookie set in browser
▼
WEBSITE (your page)
│ Pixel captures: _fbc, _fbp, IP, user agent
│ URL: example.com?fbclid=abc123
▼
TYPEFORM (iframe)
│ Captures: email, phone, name, country
│ ⚠ Does NOT have access to _fbc, _fbp (different origin)
▼
CALENDLY (separate site)
│ Captures: email, name
│ ⚠ Does NOT have _fbc, _fbp
│ ⚠ Does NOT have phone (unless custom question added)
▼
GOOGLE SHEETS
│ Has: email, phone, name, country (from Typeform)
│ Has: Calendly booking info (from n8n/Zapier)
│ ⚠ Does NOT have _fbc, _fbp
▼
STRIPE
│ Has: email (from customer)
│ Has: payment amount, currency
│ ⚠ Does NOT have phone, _fbc, _fbp
▼
CAPI → META
│ Can send: em, ph, fn, ln, external_id, fbc, fbp
│ ⚠ fbc and fbp are ONLY available if captured on the website
| Parameter | Name | EMQ Impact | Source | Hashed? |
|---|---|---|---|---|
em | +4 points | Typeform, Calendly, Stripe | Yes | |
ph | Phone | +3 points | Typeform | Yes |
fn | First name | +1 point | Typeform, Calendly | Yes |
ln | Last name | +1 point | Typeform, Calendly | Yes |
country | Country | +0.5 point | Typeform | Yes (2-letter ISO) |
fbc | Click ID | +3 points | Browser cookie (website only) | No |
fbp | Browser ID | +2 points | Browser cookie (website only) | No |
external_id | Your ID | +1 point | Lead_ID | Yes (recommended) |
client_ip_address | IP | +1 point | Request headers | No |
client_user_agent | User agent | +0.5 point | Request headers | No |
The _fbc and _fbp cookies are set by the Meta Pixel on your website. They exist only in the visitor's browser. When the visitor submits a Typeform and moves to Calendly and beyond, these cookie values are lost — they cannot cross iframe or domain boundaries automatically.
In this laboratory: We do not have these values for CAPI events because we cannot easily extract them from the browser and pass them through Typeform → Calendly → Google Sheets. Our CAPI events rely on email + phone matching, which works but produces lower EMQ.
In production: To capture _fbp and _fbc, you would: (1) read these cookies on the website using JavaScript, (2) pass them as hidden fields through Typeform (using URL parameters or hidden fields), (3) include them in the Google Sheet, and (4) send them with every CAPI event. This is an advanced optimization that significantly improves event match quality.
// On your website, BEFORE the Typeform embed, capture the cookies:
<script>
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : '';
}
const fbp = getCookie('_fbp');
const fbc = getCookie('_fbc');
// Pass to Typeform via hidden fields in the embed URL:
// data-tf-hidden="fbp=VALUE,fbc=VALUE"
</script>
Then include these values in your Google Sheet and send them (unhashed) with every CAPI event as user_data.fbp and user_data.fbc.
Understand how Meta deduplicates events and why stable, source-system-derived event IDs are critical.
Why duplicates occur: In a system with both browser-side (Pixel) and server-side (CAPI) tracking, the same action can generate two events. Example: a visitor submits the Typeform → the Pixel fires a Lead event → simultaneously, a Typeform webhook triggers n8n, which sends the same Lead event via CAPI. Meta now has two Lead events for the same action. Without deduplication, Meta counts it twice — inflating your conversion numbers and corrupting optimization signals.
How Meta deduplicates: Meta uses the combination of event_name + event_id within a 48-hour window. If two events have the same event_name and event_id, Meta keeps one and discards the duplicate.
Stable vs random event IDs:
| Approach | Example | Risk |
|---|---|---|
| Random (bad) | Math.random().toString() | Every send generates a unique ID — no deduplication possible |
| Timestamp-based (risky) | "lead-" + Date.now() | Two sends milliseconds apart get different IDs — deduplication fails |
| Source-system ID (good) | "schedule-" + calendlyEventId | Same booking always produces the same event_id — deduplication works even if the workflow fires twice |
| Event | event_id Format | Source | Why It's Stable |
|---|---|---|---|
| Lead | lead-{typeform_response_id} | Typeform response ID | Each form submission has exactly one response ID |
| Schedule | schedule-{calendly_event_id} | Calendly event URI | Each booking has exactly one event ID |
| CallAttended | callattended-{lead_id} | Lead_ID from Google Sheet | Each lead can only attend once |
| QualifiedLead | qualifiedlead-{lead_id} | Lead_ID from Google Sheet | Each lead can only be qualified once |
| Purchase | purchase-{stripe_payment_intent_id} | Stripe payment intent | Each payment has exactly one payment intent ID |
Deliberately create failures, diagnose them, and fix them. A freelancer who cannot troubleshoot is not worth $300.
Create the failure: In one of your n8n workflows, change event_name to "Purchse" (misspelled).
Symptom: Meta accepts the event (HTTP 200, events_received: 1), but it appears as a new custom event called "Purchse" — not as a Purchase standard event.
Where to inspect: Events Manager → Overview. You'll see "Purchse" as a separate event from "Purchase."
How to confirm: The event count for "Purchase" stops increasing. A new "Purchse" event appears.
Fix: Correct the event_name to "Purchase" (exact spelling, case-sensitive). Re-send the event.
Lesson: Meta does not validate event names against its standard events list. It accepts anything. A typo creates a new custom event rather than failing.
Create the failure: Send a CAPI event with an empty user_data (no email, no phone, no external_id).
Symptom: Meta may still accept the event, but Event Match Quality will be very low or zero. The event cannot be attributed to any user.
Where to inspect: Events Manager → your event → Event Match Quality.
Fix: Always include at least one identity parameter (email is the most impactful).
Create the failure: Send user_data.em as "test@example.com" instead of the SHA-256 hash.
Symptom: Meta will return a warning or error: "Invalid format for parameter 'em'. Expected SHA-256 hash."
Where to inspect: The HTTP response body from the CAPI call. n8n shows this in the HTTP Request node's output.
Fix: Hash all PII with SHA-256 before sending.
Create the failure: Run the same workflow twice with the same data — but use a random event_id (e.g., Math.random()).
Symptom: Two events appear in Events Manager for the same action. Conversion count is inflated.
Where to inspect: Events Manager → event count is higher than expected.
Fix: Use stable, source-system-derived event IDs (Layer 17).
Create the failure: Send a CAPI request with broken JSON (e.g., missing closing brace).
Symptom: HTTP 400 error response.
Where to inspect: n8n HTTP Request node → output → error message.
Fix: Validate JSON structure. Use JSON.stringify() to build the body rather than string concatenation.
Create the failure: Use an expired or invalid access token.
Symptom: The n8n workflow completes (the webhook was received, the Code node processed the data), but the HTTP Request node returns an error: "Invalid OAuth access token."
Where to inspect: n8n execution log → HTTP Request node → response body.
Key insight: "Automation succeeded" (n8n ran) ≠ "Meta received the event" (CAPI accepted it). Always check the HTTP response.
Fix: Generate a new access token in Events Manager.
Create the failure: Send a Purchase event with value: 140000 instead of value: 1400.
Symptom: Meta accepts it. The event shows a purchase value of €140,000 — absurdly high. ROAS calculations are wrong.
Where to inspect: Events Manager → Purchase event → value parameter.
Fix: Divide Stripe's cent-based amount by 100. Add validation: if value > 10000 for this client, log a warning.
Create the failure: Send a CAPI event with only external_id and no email or phone.
Symptom: Event is received, but EMQ is 1-2/10. Meta cannot reliably attribute it.
Where to inspect: Events Manager → Event Match Quality score → breakdown of which parameters contributed.
Fix: Include email (em) at minimum. Add phone (ph) and name (fn, ln) for better matching.
Run one complete test journey through the entire funnel and verify every stage.
Name: Lab TestUser
Email: labtestuser@example.com
Phone: +1 555 000 1234
Country: United States
https://YOURUSERNAME.github.io/meta-capi-lab/. Verify PageView in Pixel Helper.After completing all steps, fill out this timeline:
| Time | Action | Event | Source | Verified In Meta? |
|---|---|---|---|---|
| T+0:00 | Page visit | PageView | Pixel | ☐ |
| T+0:02 | Typeform submitted | Lead | Pixel | ☐ |
| T+0:05 | Calendly booked | Schedule | CAPI | ☐ |
| T+1:00 | Attended marked | CallAttended | CAPI | ☐ |
| T+1:05 | Qualified marked | QualifiedLead | CAPI | ☐ |
| T+2:00 | Stripe payment | Purchase | CAPI | ☐ |
Review the original client brief as a professional freelancer. Identify every assumption, ambiguity, limitation, and risk. Produce a production-ready assessment.
Re-read the Raw Client Brief from Part 1. For each major point, produce:
| Item | Client Request | What It Means | What Must Be Verified | Recommended Implementation |
|---|---|---|---|---|
| 1 | "Verify the Pixel is installed correctly on scaleuppartner.co" | Audit existing Pixel on Shopify site | Is Pixel installed via Meta channel app or custom code? Is it firing correctly? What events are currently configured? | Use Meta Pixel Helper + Events Manager. Check Shopify's Meta channel settings. |
| 2 | "Add Meta Pixel to Calendly" | Client wants Schedule tracking on Calendly | Cannot install Pixel on Calendly's hosted pages | Use CAPI via Zapier, triggered by Calendly webhook. Explain to client. |
| 3 | "offline conversion" | Outdated term | Offline Conversions API deprecated | Use Conversions API. Educate client on current terminology. |
| 4 | "Call attended (marked in Calendly)" | Attendance tracking | Calendly has no positive attendance status | Use Google Sheet manual entry + Zapier trigger. Explain to client. |
| 5 | "Configure Custom Conversions for all 5 events" | Events usable for optimization | Custom Conversions not required for standard/custom event optimization | Send events, verify in Events Manager, explain optimization requirements (volume). |
| 6 | "Zapier" | Client's chosen platform | Free plan insufficient. Professional plan required. | Recommend Professional plan ($29.99/month). Estimate task usage. |
| 7 | "$300–$400 budget" | Fixed price for implementation | Scope includes audit, 4 automations, sheet, documentation, education | Achievable for experienced implementer. Ongoing Zapier cost is separate. |
| 8 | "Shopify (scaleuppartner.co is on Shopify)" | Website platform | Shopify's Meta channel may already handle Pixel installation | Verify if Meta channel is installed. Check if it handles CAPI for Shopify checkout events. |
| 9 | No mention of identity matching | Client not aware of EMQ | Current events may have low match quality | Implement _fbp/_fbc capture for production. Explain EMQ to client. |
| 10 | No mention of deduplication | Risk of double-counting if Pixel + CAPI fire for same event | Lead event: Pixel fires on submit. If CAPI also sends Lead, dedup needed. | Use consistent event_ids. Educate client on dedup mechanism. |
Privacy/Compliance: PII is being hashed and sent to Meta. The client should verify compliance with GDPR (EU data), cookie consent requirements, and their privacy policy. Meta's Terms of Service require that advertisers have appropriate consent.
Error handling: What happens when a CAPI call fails? Production workflows need retry logic, error notifications (email/Slack), and a dead-letter queue for failed events.
Monitoring: Who checks that the automations are still running? Set up alerts for: n8n/Zapier workflow failures, drop in event volume in Events Manager, changes to access tokens or permissions.
Documentation: The client requested a Loom video and written documentation. This should cover: how each Zap works, how to troubleshoot common failures, how to read Events Manager, and who is responsible for marking attendance/qualification.
Complete all of these before considering the laboratory finished.
What does success look like from the client's perspective?
The system is "done" when the client's business can:
Answer these without looking at the guide. If you cannot answer all 20, go back and review.
You have not completed this laboratory if you can only click buttons. You must be able to reason through the chain for any event:
WHAT HAPPENED? → WHICH SYSTEM KNOWS? → WHAT TRIGGERED? → WHICH WORKFLOW RUNS? → WHAT DATA MOVES? → HOW IS IT TRANSFORMED? → HOW IS IT TRANSMITTED? → WHAT DOES META RECEIVE? → HOW DOES META IDENTIFY THE USER? → HOW IS THE EVENT CONFIGURED? → HOW CAN THE SIGNAL BE USED? → WHAT COULD FAIL? → HOW DO WE DIAGNOSE IT?
— End of Module 1 —
MODULE 2 Conversion Tracking & Attribution Track
Audit, diagnose, and fix broken Meta Pixel & CAPI tracking on a Shopify store. Built from real Upwork client problems.
You must complete Module 1: Meta CAPI Automation Lab before this module. Module 1 teaches the raw mechanics of Meta Pixel, Conversions API, SHA-256 hashing, event deduplication, and Event Match Quality. This module assumes you understand those fundamentals and can now apply them to a different context: diagnosing and fixing an existing Shopify store's broken tracking.
Mindset shift: In Module 1 you built everything from scratch. In this module, someone else already built it — badly. Your job is to walk into a mess, find every problem, fix it, and hand back a clean system. This is the most common CAPI job on Upwork.
Reproduced exactly as posted on Upwork. Do not correct, rewrite, or modernize.
This brief is based on a real Upwork posting from 2026. The client has a working Shopify store with an active Meta ad spend. Their tracking is already installed — the problem is that it's broken. This is fundamentally different from Module 1 where you built tracking from zero. Here you are a diagnostic specialist walking into someone else's implementation.
What the client said vs. what it actually means technically.
event_id values. If the store ALSO has a manual Pixel in theme.liquid, a GTM container with a Meta tag, or a third-party app (Elevar, Trackify, Triple Whale) firing its own events, those create a second event stream with different event_id values. Meta cannot deduplicate across mismatched IDs, so it counts both.checkout_completed once per order, but legacy code in Additional Scripts or theme.liquid may fire on every page load of the /thank_you route. SPA re-renders are less common on Shopify (it's not a pure SPA), but some themes and headless storefronts do re-render.theme.liquid (manual code)
Structure the mess before touching anything.
The client has a Shopify store running Meta ad campaigns. Tracking is installed — Pixel fires, CAPI events arrive in Events Manager — but the data is dramatically wrong. Meta reports 5x the actual purchase volume and revenue. The client cannot make ad spend decisions because ROAS numbers are meaningless. They're flying blind on a live ad budget.
The client does not know which of the 5 possible installation points is causing the duplication. They suspect "dual firing" but haven't audited systematically. They may have installed the native Meta app, then hired a developer who added GTM tracking, then installed a third-party attribution app — each adding its own Pixel/CAPI layer without removing the previous one. Nobody documented what was installed or when.
Every day of inflated data is a day of wrong optimization decisions. Meta's algorithm is learning from garbage signal — it thinks 5x more people are converting than actually are, so it targets broader and shallower instead of finding the real converters. The client is burning money while the tracking is broken.
After your work:
• Exactly one Purchase event fires per actual order (within the 5–15% tolerance for ad blockers/consent).
• Purchase values match Shopify order totals (same currency, same amount).
• Browser Pixel and CAPI events are properly deduplicated via matching event_id.
• Meta ROAS aligns with actual Shopify/Stripe revenue within a reasonable margin.
• A single, documented tracking architecture with one clear source of truth.
• A runbook the client's team can use to verify tracking health going forward.
What the client actually needs from you, organized by category.
Identify every source of Meta events on the store. Map which Pixel IDs are firing, from which installation points, on which pages. Determine the exact mechanism causing over-counting.
Remove or disable all duplicate event sources. Ensure exactly one browser-side and one server-side event path, both using the same event_id for deduplication. Fix value parameters. Fix firing conditions.
Prove the fix works with data. Compare Meta events to Shopify orders over 3+ days. Show before/after screenshots. Demonstrate that Meta purchase count matches reality.
Write a runbook the client can follow. Include: what is installed, where, how to check it's working, what to do when it breaks, and a monthly health check procedure.
Budget is $10–$30/hr. This is a diagnostic-heavy job — most of the time is investigation, not building. Phase 1 (diagnosis) should be fast. The client is time-sensitive because every day of bad data costs them ad spend.
The 15 things you must be able to do after completing this module.
| # | Objective |
|---|---|
| 1 | Set up a Shopify development store for tracking experiments |
| 2 | Install the native Facebook & Instagram channel app and configure data sharing to "Maximum" |
| 3 | Use Meta Pixel Helper to audit which Pixel IDs fire on every page of the store |
| 4 | Inspect theme.liquid for hardcoded fbq('init') blocks |
| 5 | Audit a GTM container for Meta Pixel/CAPI tags |
| 6 | Inspect Shopify Settings → Checkout → Additional Scripts for legacy tracking code |
| 7 | Audit Shopify Customer Events (Settings → Customer Events) for third-party pixel installations |
| 8 | Explain Shopify's Web Pixels API: strict sandbox (app pixels) vs. lax sandbox (custom pixels) |
| 9 | Map all discovered event sources into a single diagnostic matrix |
| 10 | Deliberately create a duplicate-event scenario and verify it in Events Manager |
| 11 | Remove duplicate sources and verify single-fire behavior |
| 12 | Verify event_id deduplication between browser Pixel and CAPI events |
| 13 | Reconcile Meta Events Manager data against Shopify Orders export |
| 14 | Write a tracking health runbook for client handover |
| 15 | Explain why reported Meta purchases should be within 5–15% of actual orders, not exactly matching |
Where Meta events can come from on a Shopify store.
On a Shopify store, Meta tracking code can be installed in five independent locations. Each one can fire events to Meta without knowing the others exist. This is the root cause of most duplication problems — tracking was installed at different times by different people, and nobody removed the old installation when the new one went in.
| # | Location | What It Does | Who Usually Installs It | How to Check |
|---|---|---|---|---|
| 1 | Facebook & Instagram channel app | Official Meta integration. Installs both browser Pixel and server-side CAPI. Handles event_id deduplication automatically. Sets data sharing to "Standard," "Enhanced," or "Maximum." |
Store owner following Shopify's setup wizard | Shopify Admin → Sales Channels → Facebook & Instagram → Settings → Data Sharing |
| 2 | theme.liquid |
Manual fbq('init', 'PIXEL_ID') code pasted into the theme's layout file. Fires browser-side events on every page load. No CAPI, no automatic deduplication. |
A developer hired before the native app existed, or one who didn't know the app was already installed | Shopify Admin → Online Store → Themes → Edit Code → search for fbq |
| 3 | Google Tag Manager | GTM container with one or more Meta Pixel tags (and possibly a server-side GTM forwarding CAPI events). Fires based on GTM triggers, completely independent of Shopify's native tracking. | A marketing agency or PPC specialist | View page source → search for GTM- container IDs. Open GTM → filter tags by "Facebook" or "Meta" |
| 4 | Additional Scripts (checkout) | Code in Settings → Checkout → Additional Scripts textarea. Fires on the order status page (thank-you page). This was the standard way to track purchases before Customer Events existed. | An older developer, a tutorial from 2020–2022, or a Shopify support article | Shopify Admin → Settings → Checkout → scroll to "Order status page" → Additional Scripts |
| 5 | Third-party apps (Customer Events) | Apps like Elevar, Trackify, Triple Whale, or upsell apps (ReConvert, Zipify) that install their own pixels via Shopify's Customer Events system. May inject their own event_id values. |
The store owner or a marketing agency trying to improve attribution | Shopify Admin → Settings → Customer Events. Also check each app's settings for "Pixel" or "Meta" configuration. |
Each installation point is independent. The native app doesn't know about the GTM tag. The GTM tag doesn't know about the code in theme.liquid. The third-party app doesn't know about any of them. So a single purchase can fire:
Purchase (native app, Pixel) — event_id: "shopify-abc123"
Purchase (native app, CAPI) — event_id: "shopify-abc123" ← deduplicates with above
Purchase (theme.liquid) — no event_id
Purchase (GTM Pixel tag) — event_id: "gtm-xyz789"
Purchase (GTM sGTM CAPI) — event_id: "gtm-xyz789" ← deduplicates with above
Purchase (third-party app) — event_id: "elevar-def456"
Meta receives 4 distinct Purchase events for 1 order. The native app's pair deduplicates (same event_id), and the GTM pair deduplicates (same event_id), but the 4 remaining distinct events are all counted. Purchase value is multiplied by 4.
Since Shopify moved checkout to Checkout Extensibility (2024–2025), the old method of pasting JavaScript into checkout.liquid no longer works for most stores. Instead, Shopify publishes Customer Events — a standardized set of tracking events that any app or custom pixel can subscribe to. These events fire inside a sandboxed iframe, which means pixel code cannot access the main page's DOM, cookies (except via browser.cookie.get()), or other scripts.
| Customer Event | When It Fires | Maps To (Meta) |
|---|---|---|
page_viewed | Any storefront page load | PageView |
product_viewed | Product detail page | ViewContent |
product_added_to_cart | Add to Cart click | AddToCart |
checkout_started | Checkout begins | InitiateCheckout |
payment_info_submitted | Payment details entered | AddPaymentInfo |
checkout_completed | Order placed (fires once per order) | Purchase |
checkout_completed fires once per orderShopify's Customer Events system guarantees that checkout_completed fires exactly once per completed order. This is the correct event to subscribe to for Purchase tracking. Code that instead fires on "page URL contains /thank_you" will fire on every page load of that URL — including reloads, back-button navigation, and order status lookups by existing customers.
| Action | Allowed? | How |
|---|---|---|
Read cookies (_fbp, _fbc) | Yes | await browser.cookie.get('_fbp') |
Read document.cookie directly | No | Returns undefined — silent failure, no error |
| Access main page DOM | No | Sandbox isolation |
| Send HTTP requests | Yes | fetch() with keepalive: true |
Use navigator.sendBeacon | Yes | For guaranteed delivery on page unload |
| Access checkout data (order ID, total, line items) | Yes | Via event payload: event.data.checkout |
If code uses document.cookie instead of browser.cookie.get('_fbp') inside a Web Pixels sandbox, it returns undefined silently. The code doesn't crash — it just sends events with no _fbp or _fbc values. This means identity matching fails silently, and Event Match Quality drops without any error to alert you. This is one of the most common bugs in custom Shopify Pixel implementations.
Set up your sandbox for Shopify tracking experiments.
| Tool | Purpose | Cost |
|---|---|---|
| Shopify Partner Account | Create free development stores | Free |
| Shopify Development Store | Full Shopify store without paying for a plan | Free (via Partner account) |
| Meta Business Suite | Business Manager, Pixel/Dataset, Events Manager | Free |
| Meta Pixel Helper (Chrome extension) | Audit which Pixel IDs fire on each page | Free |
| Google Tag Manager | Simulate a GTM-based tracking installation | Free |
| Chrome DevTools (Network tab) | Inspect actual HTTP requests to facebook.com/tr | Free |
| Incognito/Private browsing | Test without cached cookies or extensions interfering | Free |
Shopify development stores only allow free and partner-approved apps. The official Facebook & Instagram channel app is free and should be installable on a development store. If installation fails, use Shopify's 3-day free trial + $1/month for 3 months offer on a Basic plan instead — total cost under $5 for 3 months of a full store with all apps available.
Alternative: Even without the native app, you can still complete 80% of this lab by manually installing tracking code in theme.liquid, GTM, and Additional Scripts — which is what you'll be auditing on client stores anyway.
12 layers. Each one builds a skill you need for real Shopify diagnostic work.
Set up a free Shopify store you can break and fix without consequences.
You cannot practice diagnostic work on a client's live store. You need a sandbox where you can install tracking in all 5 locations, create deliberate duplication, and verify fixes — all without risking a client's real ad data.
Shopify Partner Program — a free program that gives developers and agencies access to development tools, including unlimited development stores. A development store is a full Shopify store with no time limit, but it cannot process real payments or be accessed publicly without a password.
partners.shopify.com. Sign up with your email. No credit card required. This gives you access to the Partner Dashboard.tracking-lab. Store URL will be: tracking-lab.myshopify.com.Bogus Gateway.
Install the diagnostic tools and establish what fires on a clean store (nothing, yet).
Before you install any tracking, you need to know what the "zero state" looks like. This is your baseline. Every event you see after this point was added by a specific installation step, and you can trace it back.
facebook.com/tr.
Install the official Facebook & Instagram channel app. This is the correct, Shopify-recommended way to install Meta tracking.
On real client stores, this app is almost always installed. You need to understand what it does automatically so you can recognize when other installations conflict with it.
Data Sharing Levels:
| Level | What It Sends | CAPI? |
|---|---|---|
| Standard | Browser Pixel events only (PageView, ViewContent, AddToCart, Purchase) | No |
| Enhanced | Browser Pixel + hashed customer data (email, phone) appended to events | No |
| Maximum | Browser Pixel + server-side CAPI events with automatic event_id deduplication | Yes |
• Browser Pixel installation (injected into the storefront via Shopify's Web Pixels API)
• Server-side CAPI events (sent from Shopify's servers, not the browser)
• Matching event_id between browser and server events (deduplication)
• SHA-256 hashing of customer PII (email, phone)
• Correct event timing and value parameters
If this app is the only thing installed, tracking works correctly out of the box.
1. What is the difference between "Standard" and "Maximum" data sharing?
2. If a store has data sharing set to "Standard," is CAPI active?
3. Who generates the event_id for deduplication — the browser Pixel code or Shopify's server?
Deliberately install a second, conflicting Meta Pixel in theme.liquid. This simulates the most common duplication scenario (~30% of cases).
On real client stores, a developer often pasted Meta Pixel code into theme.liquid in 2019–2022, before the native app existed or before the store owner installed it. When the native app was later added, nobody removed the old code. Both now fire independently. You need to create this scenario, see its effects, and then fix it.
theme.liquid (under Layout).</head> tag. Use a different Pixel ID than the native app (create a second Pixel in Meta Events Manager, or use the same one — even the same ID without matching event_id will cause duplication):
<!-- DELIBERATELY ADDED FOR LAB: DUPLICATE PIXEL -->
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_SECOND_PIXEL_ID');
fbq('track', 'PageView');
</script>
Replace YOUR_SECOND_PIXEL_ID with an actual Pixel ID (either a second Pixel you created, or the same Pixel ID as the native app).theme.liquid code does NOT fire Purchase (it only has fbq('track', 'PageView')). But it fires an extra PageView on every page, including the thank-you page.
fbq('track', 'Purchase', ...) to the theme.liquid code, then Purchase fires twice on the browser side with different event_id values.You now have a store with TWO independent Pixel installations. The native app handles deduplication between its own browser + server events. But the theme.liquid code generates events with NO event_id at all — raw fbq('track', ...) calls don't include one. Meta cannot deduplicate events that have no event_id. Even if both use the same Pixel ID, the events are counted separately.
1. If both installations use the same Pixel ID, does Meta automatically deduplicate them?
2. What happens if the theme.liquid code uses a different Pixel ID than the native app?
3. Why does the theme.liquid Pixel code not include an event_id?
Install a Google Tag Manager container with a Meta Pixel tag. This simulates the most common duplication scenario (~40% of cases).
Agencies and PPC specialists love GTM. They install a GTM container, add a Meta Pixel tag, and configure triggers. When they leave, the container stays. The store owner later installs the native Meta app (or another agency does). Now GTM and the native app both fire. Nobody audits the GTM container because "it's managed by the agency."
tagmanager.google.com. Create an account and a Web container. Name it "Tracking Lab."theme.liquid. Paste the GTM <script> snippet right after the opening <head> tag. Paste the <noscript> snippet right after the opening <body> tag. Save.fbq initialization and PageView code as Layer 4, using a Pixel ID (the same or different).
<script>
fbq('track', 'Purchase', {
value: {{Purchase Value}},
currency: 'USD'
});
</script>
theme.liquid, and GTM.
The GTM Purchase trigger uses "Page URL contains thank_you." This fires on every page load of that URL — including:
• Customer reloading the thank-you page
• Customer using browser back button and navigating forward again
• Customer checking their order status (which uses the same URL)
Shopify's native checkout_completed event fires once per order. The GTM URL-based trigger fires on every page load. This is a compounding duplication on top of the Pixel-source duplication.
1. How many independent Purchase events can fire on a single order with all three installations active?
2. Why is "Page URL contains thank_you" a dangerous GTM trigger for Purchase?
3. How would you find the GTM container ID on a client's store without GTM admin access?
Add legacy purchase tracking code in Shopify's Additional Scripts field. This simulates the ~15% of duplication cases caused by forgotten checkout scripts.
Before Shopify's Customer Events system existed (pre-2023), the standard way to track purchases was to paste JavaScript into Settings → Checkout → Additional Scripts. Thousands of tutorials and Shopify support articles recommended this approach. The code is still there on many stores — invisible unless you know to look for it.
<script>
// DELIBERATELY ADDED FOR LAB: LEGACY CHECKOUT SCRIPT
if (typeof fbq !== 'undefined') {
fbq('track', 'Purchase', {
value: {{ checkout.total_price | money_without_currency }},
currency: '{{ checkout.currency }}'
});
}
</script>
This uses Shopify's Liquid template variables to pull the actual order value.theme.liquid code fires PageView everywhere but may not fire Purchase unless you added it.)Additional Scripts are hidden deep in Shopify's settings. There is no visual indicator that code exists there. A store owner who hires you to "fix tracking" may not even know this field exists. You must check it on every audit.
Map every Meta event source on the store into a single diagnostic document. This is the deliverable you hand to the client as "Phase 1: Diagnosis."
You cannot fix what you haven't mapped. A freelancer who says "I found a duplicate Pixel" and removes one thing may miss two other sources. The matrix forces a systematic audit of all 5 installation points before you touch anything.
| Installation Point | Location | Pixel ID | Events Fired | Has event_id? | CAPI? | Status | Action |
|---|---|---|---|---|---|---|---|
| 1. Native App | Sales Channels → FB & IG | [your ID] | PageView, ViewContent, AddToCart, Purchase | Yes (automatic) | Yes (Maximum) | KEEP | This is the correct installation |
| 2. theme.liquid | Layout/theme.liquid, line ~45 | [your ID or different] | PageView (all pages) | No | No | REMOVE | Delete the script block |
| 3. GTM | GTM container GTM-XXXXXX | [your ID or different] | PageView (all pages), Purchase (thank_you URL) | No | No | REMOVE | Pause or delete Meta tags in GTM |
| 4. Additional Scripts | Settings → Checkout | Same as #2 (uses existing fbq) | Purchase (thank-you page) | No | No | REMOVE | Delete the script |
| 5. Third-party apps | Settings → Customer Events | N/A | None found | N/A | N/A | N/A | No action |
event_id
event_id) = counted separately
event_id) = counted separately
On a real Upwork job, this matrix IS the diagnosis. You present it to the client with: "I found 3 independent sources of Meta tracking on your store. Here's where each one is, what it does, and which ones need to be removed." The client sees you've been thorough. This is how you justify your rate.
Remove all duplicate tracking sources, leaving only the native app. Verify single-fire behavior.
The fix is conceptually simple: remove everything except the one correct installation. But on a real client store, you must do it carefully — document what you're removing, take backups, and verify after each removal.
theme.liquid code. Edit Code → theme.liquid → find the fbq script block you added in Layer 4 → delete the entire block (from the comment to the closing </script> tag). Save.
1. Why do you pause GTM tags rather than delete them immediately?
2. Why duplicate the theme before editing?
3. After removing all duplicates, should Meta's reported purchases go up, down, or stay the same?
4. The client will see their "ROAS drop" after your fix. How do you explain this to them?
Compare Meta Events Manager data against Shopify's order records. This is the validation step that proves your fix worked.
The client said: "Validate corrected data against our backend Shopify/Stripe transaction records for at least 3 days." This is how a professional proves the fix. You don't just say "I removed the duplicates" — you show the numbers match.
Why it won't be exactly 100% match:
| Factor | Effect | Typical Impact |
|---|---|---|
| Ad blockers | Block browser Pixel, but CAPI still sends | Browser events 10–30% lower than actual |
| Consent banners | Suppress tracking before consent | Varies by region (EU: significant) |
| Safari ITP | Blocks third-party cookies, limits first-party | Reduces _fbc cookie availability |
| CAPI with Maximum sharing | Server-side events bypass ad blockers | Should capture 85–95% of actual orders |
| Attribution window | Meta attributes purchases to ad clicks within 7/1 day windows | Organic purchases not counted as conversions |
| Metric | Shopify Orders | Meta Events Manager | Ratio | Verdict |
|---|---|---|---|---|
| Purchase count | 5 | 5 | 1.0 | Clean (in test environment, no ad blockers) |
| Total revenue | $249.95 | $249.95 | 1.0 | Values match |
You would run this comparison over 3–7 days of real traffic, not test orders. The ratio should settle between 0.85 and 1.0 (Meta reports 85–100% of actual orders). Below 0.85 means tracking is under-reporting (possible consent/blocking issue). Above 1.0 means duplication still exists. Above 1.5 means significant duplication. The client's starting ratio was ~5.0.
Understand and improve the Event Match Quality (EMQ) score on the store's events. Fixing duplication is half the job — the other half is making sure the remaining events carry enough identity data for Meta to match them to users.
The client brief mentions "demonstrable CAPI and server-side tracking experience." A freelancer who removes duplicates but ignores EMQ has only done half the work. Low EMQ means Meta can't attribute conversions to ad clicks, which means the client's campaign optimization suffers even with accurate event counts.
Recall from Module 1: EMQ scores each event on a 1–10 scale based on the identity parameters it carries.
| Parameter | Approximate EMQ Impact | Shopify Native App Sends It? |
|---|---|---|
em (hashed email) | +4 points | Yes (with Maximum sharing) |
ph (hashed phone) | +3 points | Yes (if customer provides phone) |
fbc (click ID) | +3 points | Yes (if customer arrived via Meta ad) |
fbp (browser ID) | +2 points | Yes (browser Pixel sets this cookie) |
fn, ln (name) | +1 point each | Yes (with Maximum sharing) |
external_id | +1 point | Depends on configuration |
client_ip_address | +1 point | Yes (CAPI sends this) |
client_user_agent | +1 point | Yes (CAPI sends this) |
fbc missing: Customer didn't arrive via Meta ad (common in test environment)
ph missing: Customer didn't provide phone at checkout
external_id not sent: Native app may not send this by defaultEMQ is only meaningful for CAPI events (server-side). Browser Pixel events don't have an EMQ score because they match via cookies. The native Shopify app with "Maximum" sharing typically achieves EMQ 6–8 out of 10 for logged-in customers, and 3–5 for guest checkouts. Improving beyond this requires custom implementation (adding external_id, capturing phone more aggressively, etc.) — which is out of scope for a $10–$30/hr diagnostic fix job, but worth mentioning to the client as a future improvement.
Encounter and diagnose the specific failure modes you'll see on real Shopify stores.
Create the failure: Re-enable the GTM Purchase tag with the "URL contains thank_you" trigger. Complete a test order. Then close the browser, reopen it, and visit the same thank-you URL (copy it from your order confirmation email or browser history).
Symptom: Purchase fires again. Events Manager shows 2 Purchase events for 1 order.
Root cause: URL-based triggers fire on every page load. The order status page is reachable after the initial purchase.
Fix: Use Shopify's checkout_completed Customer Event instead of URL matching. Or, if GTM must stay, use a GTM variable that checks for a "purchase_tracked" cookie and only fires once.
Lesson: URL-based triggers are never safe for purchase tracking on Shopify. Always use the event-based approach.
Create the failure: In a custom pixel (Settings → Customer Events → Add Custom Pixel), write code that tries to read _fbp using document.cookie instead of browser.cookie.get('_fbp').
Symptom: No error. The code runs. But _fbp is undefined. The event fires to Meta with no browser ID.
Where to inspect: Add console.log('fbp:', document.cookie) to the custom pixel — it logs empty string. Use console.log('fbp:', await browser.cookie.get('_fbp')) instead — it returns the actual value.
Fix: Always use browser.cookie.get() inside Shopify's Web Pixels sandbox.
Lesson: Silent failures are the most dangerous. The tracking "works" but sends incomplete data. Only an EMQ audit reveals the problem.
Create the failure: In a custom pixel, subscribe to checkout_completed and send a CAPI event via fetch() WITHOUT keepalive: true.
Symptom: Most purchases track correctly, but 3–7% silently fail. The customer completes checkout and navigates away (or the browser starts unloading the page) before the fetch completes.
Root cause: Without keepalive: true, the browser can cancel the request when the page unloads. Checkout completion often triggers a redirect, which starts unloading.
Fix: Always use fetch(url, { method: 'POST', keepalive: true, body: ... }) or navigator.sendBeacon(url, body) for events that fire during page transitions.
Lesson: This failure is intermittent and invisible. You'll only catch it by comparing Meta event counts to Shopify order counts over time. A 5% drop in tracked purchases is enough to hurt campaign optimization.
Create the failure: Install the native app with Pixel A (owned by the store's Business Manager). Add a theme.liquid Pixel with Pixel B (owned by an agency's Business Manager that ran campaigns last year).
Symptom: Pixel Helper shows TWO different Pixel IDs. Events go to two different Event Managers. The current ad campaigns (using Pixel A) see correct data. But the old Pixel B is still receiving events, and if anyone accidentally uses Pixel B for a campaign, they get data that includes all traffic but is completely disconnected from the store's actual conversion history.
Fix: Identify which Pixel ID is currently used for ad campaigns. Remove all others.
Lesson: Always ask the client: "Which Pixel ID are your current ads using?" Then verify it matches what's installed on the store.
Create the failure: In a GTM or custom pixel Purchase event, map the value from Shopify's checkout data without checking the format. Some data sources report in cents (5000 for $50.00), others in dollars (50.00).
Symptom: Events Manager shows purchases with values 100x higher than actual. A $50 order reports as $5,000.
Fix: Always verify the unit of the value field. Shopify's Liquid {{ checkout.total_price }} reports in the store's subunit (cents). {{ checkout.total_price | money_without_currency }} reports in dollars. The Customer Events API event.data.checkout.totalPrice.amount reports as a decimal string ("50.00").
Lesson: This is the value-inflation component of the client's "5x ROAS" problem. A single value error multiplied across all purchases dramatically inflates reported revenue.
Create the failure: Disconnect and reconnect the Facebook & Instagram app, or revoke the app's permissions in Meta Business Settings.
Symptom: Browser Pixel events continue (they don't need a token). CAPI events stop. Events Manager shows events from "Browser" only, no "Server" source. Purchase count appears to drop because CAPI was catching events that ad blockers prevented the browser from sending.
Fix: Reconnect the app. In Facebook & Instagram channel settings, verify the connection is active. In Events Manager, verify both Browser and Server sources are receiving events.
Lesson: "Tracking stopped working" on Shopify often means CAPI stopped, not the Pixel. Always check for both sources in Events Manager.
Write the maintenance runbook the client requested. This is the final deliverable.
The client brief says: "Document the final setup and provide a maintenance runbook so our team can troubleshoot." A freelancer who fixes the problem but doesn't document it will get hired again when it breaks. A freelancer who documents it earns the client's trust and a 5-star review. The review matters more than the repeat work.
Write a document with these sections:
1. Current Tracking Architecture
Single source: Facebook & Instagram channel app, data sharing set to "Maximum." Pixel ID: [ID]. No other tracking installations on the store.
2. What Was Removed (and why)
• theme.liquid manual Pixel code (lines X–Y): Causing duplicate PageView events. Removed [date].
• GTM container GTM-XXXXXX, tags "Meta Pixel — All Pages" and "Meta Purchase": Causing duplicate Purchase events with URL-based trigger. Tags paused [date].
• Additional Scripts Purchase code: Legacy checkout tracking causing duplicate Purchase. Removed [date].
3. Monthly Health Check (5 minutes)
• Open Meta Events Manager. Check that both "Browser" and "Server" sources are listed for Purchase. If only "Browser" appears, the CAPI connection may have broken — reconnect the Facebook & Instagram app.
• Compare last 7 days: Meta Purchase count vs. Shopify Orders count. Ratio should be 0.85–1.0. Above 1.0 = possible duplication returned. Below 0.85 = possible CAPI/consent issue.
• Open Pixel Helper on your store. Verify only ONE Pixel ID fires. If you see a second ID, someone installed something new.
4. What to Do When Tracking Breaks
• "ROAS suddenly doubled overnight" → Check for new app installations, theme changes, or GTM updates that re-introduced duplicate tracking.
• "Purchase events dropped to zero" → Check Facebook & Instagram app connection. Check if data sharing was changed from "Maximum."
• "Events show in Pixel Helper but not in Events Manager" → Check that the Pixel ID in Pixel Helper matches the Dataset in Events Manager. They might be looking at different Pixels.
5. Rules for Future Changes
• Do NOT add Meta Pixel code to theme.liquid. The native app handles this.
• Do NOT add Purchase tracking to Additional Scripts. The native app handles this.
• If you install a new marketing app, check Settings → Customer Events to verify it's not injecting its own Meta Pixel.
• If an agency needs GTM for other platforms (Google Ads, TikTok), that's fine — but NO Meta tags in GTM.
Complete all of these before considering this module finished.
What success looks like from the client's perspective.
The system is "done" when:
Answer these without looking at the guide. If you cannot answer all 15, go back and review.
fbq('track', 'Purchase') in theme.liquid does not deduplicate with it?document.cookie inside a Shopify custom pixel instead of browser.cookie.get()?event_id. Will Meta deduplicate them? Why or why not?You have not completed this module if you can only remove duplicate Pixels. You must be able to reason through the full diagnostic chain:
WHAT IS THE SYMPTOM? → WHAT DATA CONFIRMS IT? → WHERE ARE ALL THE INSTALLATION POINTS? → WHICH ONES ARE DUPLICATES? → DO THEY HAVE EVENT_IDs? → DO THE IDs MATCH? → WHAT IS THE FIX? → HOW DO I VERIFY? → HOW DO I PROVE IT TO THE CLIENT? → HOW DO I PREVENT IT FROM COMING BACK?
— End of Module 2 —
MARKET RESEARCH Upwork Market Intelligence
Factual breakdown of automation sub-markets on Upwork. Data sourced from Upwork's official reports, hiring pages, and third-party market analyses.
Upwork's AI segment reached $300M+ annualized GSV (gross services volume) with 50% year-over-year growth. Within that, the AI Integration & Automation sub-segment grew over 90% YoY. The platform has 784,000 active clients as of Q1 2026, with GSV per client rising 5% to $5,138 despite flat total GSV. AI-enabled freelancers earn approximately 40% more per hour than those who don't use AI.
Upwork doesn't publish job volumes by category. The best proxy for market health is reply rate — the percentage of proposals that get a client response. Platform mean is 7.45%. Categories above that have more demand than supply; below it, the reverse. Automation niches sit across both sides of that line.
From Upwork's official 2026 In-Demand Skills report (measured by completed job earnings, Feb 2026 release).
Note: "Scripting & Automation" was a fastest-growing skill in the 2025 report's Coding & Web Development category. By 2026, AI-specific skills overtook it in growth rate, though base volume for scripting/automation remains higher.
Zapier, Make.com, n8n workflow builds, migrations, and maintenance.
Meta Pixel/CAPI, GTM, GA4, server-side tagging, pixel audits.
HubSpot, Salesforce, Airtable — implementation, workflows, RevOps.
Conversational AI, customer support bots, AI agent builders.
Connecting AI models (OpenAI, Claude, etc.) into business tools and workflows.
Shopify/WooCommerce pixel setups, server-side tracking, purchase event debugging.
AI video generation/editing, AI image generation, creative automation pipelines.
AI data labeling, knowledge representation, model fine-tuning support.
| Niche | Entry Rate | Mid-Level | Senior/Expert | Typical Project |
|---|---|---|---|---|
| Workflow Auto. (Zapier/Make/n8n) | $12–$30/hr | $40–$80/hr | $100–$180/hr | $300–$10K |
| Conversion Tracking (GTM/CAPI/GA4) | $20–$40/hr | $50–$80/hr | $100–$150/hr | $200–$5K |
| CRM — HubSpot | $25–$40/hr | $55–$100/hr | $140–$275/hr | $5K–$70K |
| CRM — Salesforce | $30–$55/hr | $80–$135/hr | $150–$350/hr | $25K–$200K |
| AI Chatbot Dev | $25–$50/hr | $50–$100/hr | $100–$180/hr | $1K–$15K |
| AI Integration | $30–$50/hr | $50–$100/hr | $100–$180+/hr | $1K–$25K |
| n8n (standalone) | $10–$25/hr | $40–$80/hr | $80–$150/hr | $300–$15K |
| sGTM / Server-Side | $30–$50/hr | $60–$100/hr | $120–$200/hr | $2K–$5K |
Rate data compiled from Upwork hiring pages (Aug 2026), Ciphernutz pricing guide, and Jobbers CRM freelancing guide. Upwork median rates skew lower than direct-client rates — the gap is widest in CRM consulting, where Upwork's HubSpot median ($25/hr) sits far below direct-client rates ($100–$200+/hr).
| Niche | Competition Level | Reply Rate Signal | Barrier to Entry | Verdict |
|---|---|---|---|---|
| Workflow Auto. | High — large freelancer pool | Above average for specialists; below for generalists | Low — Zapier/Make are drag-and-drop | CROWDED BUT ACTIVE |
| Conversion Tracking | Moderate — technical barrier filters | Strong — constant demand from ad spend | Medium — requires Pixel/CAPI/GTM knowledge | PROMISING |
| CRM (HubSpot) | Moderate-High | Strong at mid-level; saturated at entry | Medium — requires HubSpot certification | PROMISING |
| CRM (Salesforce) | High — mature market | Strong for certified specialists | High — Salesforce certs + Apex/SOQL | HIGH CEILING |
| AI Chatbot Dev | Growing rapidly — still room | Below platform mean (7.21% vs 7.45%) | Medium — API skills + conversational design | GROWING |
| AI Integration | Low — demand outpacing supply | Strong — 178% growth, nascent freelancer pool | Medium-High — API + ML + business context | MOST PROMISING |
| E-commerce Tracking | Moderate | Strong — tied to Shopify/WooCommerce volume | Medium — platform-specific knowledge | PROMISING |
| sGTM / Server-Side | Low — specialist skill | Strong — iOS privacy driving adoption | High — cloud infra + data layer + consent | MOST PROMISING |
"Promising" here means: growing demand, lower freelancer saturation relative to job volume, and a rate ceiling that rewards skill depth.
Why: +178% YoY growth, the AI sub-segment alone is $300M+ annualized on Upwork and growing 90%+. Demand is outpacing supply — businesses want AI connected to their existing tools (CRMs, spreadsheets, workflows), not just chatbots. Few freelancers can bridge the gap between an API key and a production-grade integration. Senior rates reach $180+/hr.
Risk: Tooling is evolving fast. What's manual today may be one-click tomorrow. Skills built on a specific framework can depreciate quickly.
Why: iOS privacy changes, cookie deprecation, and consent regulations are structurally forcing businesses from browser-side to server-side tracking. Projects run $2K–$5K for setup alone, with ongoing maintenance contracts. The knowledge barrier (cloud infrastructure + data layer design + consent frameworks + platform APIs) keeps the freelancer pool small. Top GTM freelancers on Upwork have completed 595 jobs and earned $100K+ on the platform.
Risk: Platforms are building native sGTM alternatives (Shopify's Web Pixels API, Meta's native CAPI integrations). Some of the setup work will get commoditized.
Why: Every business running paid ads needs tracking. It breaks constantly — platform updates, consent changes, developer errors, duplicate installations. GTM has 8,034 client reviews on Upwork, indicating massive throughput. The "repair" sub-niche (fixing broken tracking) has higher conversion rates and less competition than new-build work. Entry at $200 fixed-price jobs, scaling to $5K for full audits and sGTM implementations.
Risk: Entry-level pixel installs are a commodity ($50–$100 jobs). You must move up to audits, CAPI, and server-side work to earn meaningful rates.
Why: Highest absolute rate ceiling of any automation niche. Salesforce CPQ implementations go to $200K. RevOps consulting reaches $250–$350/hr at the senior level. HubSpot implementations run $5K–$70K. The gap between Upwork marketplace rates (median $25/hr for HubSpot) and direct-client rates ($100–$200+/hr) is the widest in automation — meaning room to position above the floor.
Risk: Entry barrier is real. Salesforce requires certifications + Apex/SOQL. HubSpot is easier to enter but the entry-level market is crowded. Project timelines are long (weeks to months), and scope creep is common.
Why: Largest volume — 8,838 Zapier client reviews alone. Low entry barrier means fast start. n8n migration work is growing as businesses move from Zapier's rising pricing ($29.99/mo+ for multi-step) to self-hosted alternatives. The niche that overlaps most with every other niche on this list.
Risk: Most crowded category. Pure Zapier builds are increasingly commoditized. You need a specialization on top of the platform skill (e.g., "Zapier + CRM" or "n8n + AI agents") to differentiate.
Why: +71% YoY growth, $9.6B global market heading to $41B by 2033 (19.6% CAGR). Businesses want customer-facing bots, internal knowledge bots, and support automation.
Risk: Reply rate is 7.21% — just below platform mean, suggesting freelancer supply is catching up to demand. No-code chatbot builders are lowering the floor. Differentiation requires deeper NLP/RAG/agent skills.
| Fact | Source |
|---|---|
| AI-enabled freelancers earn 40% more per hour across all categories | Upwork 2026 In-Demand Skills Report |
| Top AI skills demand grew 109% YoY (vs 23% for general high-demand skills) | Upwork 2026 In-Demand Skills Report |
| 84% of freelancers regularly use AI tools (up from 41% in 2023) | Upwork / SelfEmployed.com analysis |
| Automation repair jobs convert better than new-build jobs | RemoteAITools market analysis |
| Web Development reply rate: 5.80% (saturated); Lead Generation: 14.38% (undersupplied) | GigRadar Upwork Market Report 2026 (133K proposals) |
| Speed-to-bid matters: reply rate drops from 8.99% to 6.91% after 5 minutes | GigRadar Upwork Market Report 2026 |
| 77% of business leaders say AI is increasing demand for specialized freelancers | Upwork 2026 In-Demand Skills Report |
| Upwork's variable fee structure charges 15% for commodity categories, 5–10% for specialized ones | GigRadar Upwork Market Report 2026 |
| CRM consulting has the widest gap between Upwork marketplace rates and direct-client rates | Jobbers CRM Freelancing Guide |
| Niche | Covered By | Market Signal |
|---|---|---|
| Conversion Tracking (Meta CAPI, browser Pixel) | Module 1 — Meta CAPI Lab | PROMISING |
| E-commerce Tracking (Shopify diagnostics) | Module 2 — Shopify Diagnostic | PROMISING |
| GTM / sGTM pipelines | Module 3 (planned) | MOST PROMISING |
| Multi-platform tracking (Google, TikTok, LinkedIn) | Module 4 (planned) | PROMISING |
| iOS / Consent / Optimization | Module 5 (planned) | MOST PROMISING |
| Workflow Automation (n8n/Zapier) | Modules 1 & 2 (as tooling) | CROWDED BUT ACTIVE |
| CRM & RevOps | Track B (planned) | PROMISING |
| AI Integration | Track C — Module 1 | MOST PROMISING |
| AI Chatbot Dev | Not yet covered | GROWING |
— End of Market Research —
MODULE 1 AI Integration & Automation Track
A complete, hands-on student laboratory based on real Upwork AI integration projects.
Covers OpenAI & Claude APIs, n8n AI workflows, HubSpot CRM webhooks, Slack bots, and production prompt engineering. 16 implementation layers. Three live AI use cases delivered to a single SaaS client.
How to use this laboratory: Work through each Part and Layer in sequence. Every layer builds on the one before it. You will call real LLM APIs, build real n8n workflows, and process real data. By the end, you will have built and delivered three production AI integrations for a B2B SaaS client — and be able to scope, price, and pitch this work on Upwork.
| # | Deliverable | Layers |
|---|---|---|
| 1 | AI lead enrichment & scoring pipeline (HubSpot → n8n → LLM → HubSpot) | Layers 7–9 |
| 2 | Support ticket triage & AI draft-response system (Webhook → n8n → LLM → Slack) | Layers 10–12 |
| 3 | Internal knowledge-base Q&A bot (Slack → n8n → document retrieval → LLM → Slack) | Layers 13–15 |
| 4 | Client handover package with documentation, runbook, and maintenance proposal | Layer 16 |
Read this exactly as a freelancer would see it on Upwork. This is the actual posting language.
End of raw client brief. Everything above is the client's exact language.
Translate the client's business language into technical requirements. Challenge every assumption.
This client is more sophisticated than most Upwork clients — they know their tools, they've defined an ICP, and they understand that AI shouldn't auto-reply to customers. That's a good sign. It also means your proposal needs to demonstrate technical depth, not just enthusiasm.
/ask) that hits an n8n webhook. The mention-based approach feels more natural but requires a Slack app with bot token scopes. The slash command is simpler to set up but less conversational. For a first version, a slash command is fastest to deliver and easiest for the client to maintain.The client's budget is $2,000–$3,500. Let's break down the actual work:
| Deliverable | Estimated Hours | Implied Rate |
|---|---|---|
| Lead enrichment pipeline | 8–12 hrs | $2,800 fixed ≈ $70–$93/hr effective |
| Support ticket triage | 8–10 hrs | |
| Knowledge bot | 6–8 hrs | |
| Documentation & handover | 4–6 hrs |
Total: 26–36 hours. At $2,800 fixed price, your effective rate is $78–$108/hr. This is within the mid-level range for AI integration work on Upwork ($50–$100/hr). The monthly retainer ($300–$500/mo for ongoing maintenance) is a recurring revenue stream — always accept these.
The client asked you to estimate their monthly API costs. This demonstrates your technical depth and builds trust.
| Use Case | Monthly Volume | Model | Avg Tokens/Call | Monthly Cost |
|---|---|---|---|---|
| Lead enrichment | 200 leads | GPT-4.1-mini | ~2,000 in / ~500 out | ~$0.32 |
| Ticket triage + draft | 1,500 tickets | GPT-4.1-mini | ~3,000 in / ~800 out | ~$3.72 |
| Knowledge bot | ~500 queries | GPT-4.1-mini | ~4,000 in / ~600 out | ~$1.28 |
| Estimated monthly total | ~$5.32 | |||
Most clients expect AI API costs to be hundreds of dollars per month. When you show them it's under $10/mo for their volume using GPT-4.1-mini ($0.40/M input, $1.60/M output), you immediately build credibility and remove a objection. If they need higher quality for the knowledge bot, Claude Haiku 3.5 ($0.80/$4.00) or GPT-4.1 ($2.00/$8.00) are still under $30/mo at their volume. Always lead with the cheapest model that works, then offer upgrades.
Your proposal is your first deliverable. It must demonstrate competence, not just claim it.
The client said: "Please don't apply with a generic proposal." They also asked for four specific things. Address every single one.
Hi [Client name],
I've built AI integrations with n8n for three SaaS companies this year — lead scoring with HubSpot webhooks, support triage pipelines, and Slack-based knowledge bots. Here's a recent one: [link to portfolio piece or Loom walkthrough].
Your API costs will be ~$5-10/month. At 200 leads + 1,500 tickets + ~500 bot queries, GPT-4.1-mini handles all three use cases for under $6/mo. If you want higher accuracy for the knowledge bot, Claude Haiku at ~$4/mo is the upgrade path — still under $15/mo total.
Model recommendation: GPT-4.1-mini for lead scoring and ticket triage (fast, cheap, reliable for structured classification). For the knowledge bot, I'd test both GPT-4.1-mini and Claude Haiku 3.5 during development and let the quality of answers decide. Both excel at document Q&A with citations.
My approach for the three use cases:
1. Lead enrichment: HubSpot webhook → n8n → scrape company website → LLM scores against your ICP criteria → writes summary + score back to HubSpot custom properties. No manual research needed.
2. Ticket triage: Your webhook → n8n → LLM classifies (category + urgency) → keyword-matches relevant help docs → LLM drafts response with doc context → posts to Slack for human review. Never auto-sends to customers.
3. Knowledge bot: Slack slash command → n8n webhook → searches your indexed docs → LLM answers with source citations → replies in Slack thread. Says "I don't know" when it should.
Fixed price: $2,800. Timeline: 2 weeks. I'll deliver in milestones: lead enrichment first (days 1-4), ticket triage second (days 5-9), knowledge bot third (days 10-13), documentation on day 14. Happy to discuss the monthly retainer after delivery.
I'm available for a 30-min call this week. What works for you?
Leads with cost: "$5-10/month" immediately differentiates you from applicants who don't understand API pricing. Names specific models: Shows you know the landscape, not just "we'll use AI." Architecture in plain English: The three numbered paragraphs mirror the client's three requirements — they can see you understood every piece. Milestone-based delivery: Reduces the client's risk — they see progress weekly, not a black box for 3 weeks. Defers the retainer conversation: Smart — don't negotiate recurring revenue before you've proven value.
Map the full system before writing a single workflow. Every integration decision has cost and maintenance implications.
┌─────────────────────────┐
│ TRIGGER SOURCES │
│ │
│ HubSpot Webhook ─────┐ │
│ Support Widget ──────┤ │
│ Slack Command ───────┤ │
└───────────────────────┼──┘
│
▼
┌─────────────────────────┐
│ n8n MIDDLEWARE │
│ │
│ ┌── Receive trigger │
│ ├── Fetch context data │
│ ├── Build prompt │
│ ├── Call LLM API │
│ ├── Parse response │
│ └── Route output │
└───────────┬──────────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ HubSpot │ │ Slack │ │ Logging │
│ (update │ │ (post msg │ │ (Google │
│ contact) │ │ to chan) │ │ Sheets) │
└──────────────┘ └──────────────┘ └──────────────┘
| Model | Input / 1M tokens | Output / 1M tokens | Best For | Latency |
|---|---|---|---|---|
| GPT-4.1-nano | $0.10 | $0.40 | Simple classification only | ~200ms |
| GPT-4.1-mini | $0.40 | $1.60 | Classification + drafting (our default) | ~400ms |
| GPT-4.1 | $2.00 | $8.00 | Complex reasoning, long documents | ~800ms |
| Claude Haiku 3.5 | $0.80 | $4.00 | Document Q&A with nuanced citations | ~300ms |
| Claude Sonnet 4 | $3.00 | $15.00 | High-stakes drafting, edge cases | ~1s |
Start with the cheapest model that produces acceptable output. For this project: GPT-4.1-mini handles lead scoring, ticket classification, and basic drafting at <$6/mo. Only upgrade if the client tests the outputs and finds quality insufficient. Never start with GPT-4.1 or Claude Sonnet "just in case" — the client will see the API bill and question whether you optimized costs.
| Factor | n8n | Make.com | Zapier |
|---|---|---|---|
| AI node support | Native OpenAI + HTTP Request for any API | OpenAI module + HTTP | ChatGPT integration (limited) |
| Self-hosting | Yes — Docker, $0/mo | No | No |
| Per-execution cost | $0 self-hosted | $0.0033/operation (Team) | $0.01–$0.05/task |
| Complex branching | Full JS code nodes, IF/Switch | Routers, limited code | Paths (limited) |
| HTTP flexibility | Full request builder, auth headers | HTTP module | Webhooks by Zapier (basic) |
| Client preference | "prefer n8n... want to self-host eventually" | — | — |
The client explicitly prefers n8n with a path to self-hosting. This is a common pattern for Series A SaaS companies — they want to own their infrastructure and avoid per-execution pricing as volumes grow. At 1,500 tickets/month on Zapier, that's $75–$150/mo in Zapier costs alone. n8n cloud is ~$24/mo; self-hosted is $0.
Before building workflows, understand how to talk to an LLM programmatically. This is the skill that separates you from no-code freelancers.
Understand the anatomy of an OpenAI API call and make your first successful request.
Every OpenAI API call is an HTTP POST to a single endpoint. No SDK required — any tool that can make HTTP requests (n8n, curl, Postman) can call the API.
POST https://api.openai.com/v1/chat/completions
Headers:
Content-Type: application/json
Authorization: Bearer sk-proj-YOUR_API_KEY
Body:
{
"model": "gpt-4.1-mini",
"messages": [
{
"role": "system",
"content": "You are a lead scoring assistant for a B2B SaaS company."
},
{
"role": "user",
"content": "Score this lead: Company is 'TechNova', found at technova.io, they sell analytics dashboards to mid-market retailers."
}
],
"temperature": 0.2,
"max_tokens": 500
}
| Parameter | What it does | For client work |
|---|---|---|
model | Which model to use | Start with gpt-4.1-mini — cheapest that works well |
messages | The conversation history. Array of role+content objects | Always include a system message defining the task |
temperature | Randomness. 0 = deterministic, 2 = creative chaos | Use 0.1–0.3 for classification and scoring. Use 0.5–0.7 for drafting responses |
max_tokens | Maximum output length | Set a reasonable cap. Lead summaries: 200. Draft replies: 500. Don't leave unlimited |
response_format | Force JSON output | {"type": "json_object"} — critical for structured data. The model returns valid JSON |
{
"id": "chatcmpl-abc123",
"model": "gpt-4.1-mini-2025-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"score\": 72, \"fit\": \"partial\", \"summary\": \"TechNova sells analytics dashboards to mid-market retailers. Partial ICP match — right market segment (mid-market) but wrong vertical (retail, not e-commerce). Worth a discovery call if they serve Shopify Plus merchants.\"}"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 89,
"completion_tokens": 67,
"total_tokens": 156
}
}
usageThe usage object tells you exactly how many tokens the call consumed. At GPT-4.1-mini rates ($0.40/M input, $1.60/M output), this call cost $0.000036 + $0.000107 = $0.000143. Under one hundredth of a cent. Log these numbers for every call in production — token costs are how you prove to the client their bill is reasonable.
platform.openai.com. Sign up and add a payment method (you'll need ~$1 of credit for this entire lab).curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-proj-YOUR_KEY" \
-d '{
"model": "gpt-4.1-mini",
"messages": [
{"role": "system", "content": "Reply with valid JSON only."},
{"role": "user", "content": "Score this company as an ICP fit for a B2B e-commerce analytics SaaS: Acme Corp, 50 employees, Shopify Plus, $20M revenue."}
],
"temperature": 0.2,
"response_format": {"type": "json_object"}
}'
platform.openai.com/usage to see the API call logged with token counts and cost.Learn the Claude API as an alternative. Many clients prefer Anthropic or want you to benchmark both providers.
POST https://api.anthropic.com/v1/messages
Headers:
Content-Type: application/json
x-api-key: sk-ant-YOUR_KEY
anthropic-version: 2023-06-01
Body:
{
"model": "claude-haiku-3-5-20241022",
"max_tokens": 500,
"system": "You are a lead scoring assistant for a B2B SaaS company. Always respond in valid JSON.",
"messages": [
{
"role": "user",
"content": "Score this lead: Company is 'ShopFlow', found at shopflow.com, they build inventory management for Shopify Plus stores. 80 employees, $15M ARR."
}
]
}
| Feature | OpenAI | Anthropic/Claude |
|---|---|---|
| Auth header | Authorization: Bearer sk-... | x-api-key: sk-ant-... |
| System prompt | Inside messages array as role "system" | Top-level "system" field (separate from messages) |
| JSON mode | response_format: {"type": "json_object"} | No native JSON mode — instruct in system prompt + parse output |
| API version | In URL path (/v1/) | In header (anthropic-version) |
| Response path | choices[0].message.content | content[0].text |
| Max tokens | Optional (has default) | Required — call fails without it |
console.anthropic.com. Sign up and add billing.curl https://api.anthropic.com/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: sk-ant-YOUR_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-haiku-3-5-20241022",
"max_tokens": 500,
"system": "You are a lead scoring assistant. Always respond in valid JSON with keys: score (0-100), fit (strong/partial/weak), summary (2-3 sentences).",
"messages": [
{"role": "user", "content": "Score: ShopFlow, shopflow.com, inventory management for Shopify Plus, 80 employees, $15M ARR."}
]
}'
Write production-grade prompts that return consistent, structured, actionable outputs. This is the single most important skill in AI integration — the difference between a demo that impresses and a system that works in production.
Rule 1: Define the output format explicitly. Never let the model decide how to format its response. Specify exact JSON keys, field types, and value constraints.
// BAD — the model invents its own format every time
"Score this lead and tell me if they're a good fit."
// GOOD — deterministic, parseable output
"Analyze this lead against our ICP. Return valid JSON with exactly these keys:
{
\"score\": integer 0-100,
\"fit\": \"strong\" | \"partial\" | \"weak\",
\"icp_match\": {
\"market_segment\": boolean, // mid-market ($5M-$100M revenue)
\"vertical\": boolean, // e-commerce
\"platform\": boolean // Shopify Plus or custom
},
\"summary\": string (2-3 sentences, actionable for sales rep),
\"next_action\": \"call\" | \"email_sequence\" | \"disqualify\"
}"
Rule 2: Give the model the ICP criteria, not just the lead data. The model doesn't know your client's ICP unless you tell it. Include the scoring rubric in the system prompt.
SYSTEM PROMPT:
"You are a lead scoring assistant for ClearMetrics, a B2B analytics platform.
Our Ideal Customer Profile (ICP):
- Market: Mid-market e-commerce companies
- Revenue: $5M-$100M annually
- Platform: Shopify Plus or custom-built e-commerce
- Pain point: Need better analytics/attribution for their ad spend
- Decision maker: VP Marketing, CMO, or Head of Growth
Scoring rubric:
- 80-100: Strong ICP match on 4-5 criteria. Prioritize for immediate outreach.
- 50-79: Partial match. Worth a discovery call to explore fit.
- 0-49: Weak match. Add to nurture sequence, don't prioritize.
Always respond in valid JSON. Never include text outside the JSON object."
Rule 3: Include few-shot examples for consistent quality. Show the model what a good output looks like. This is especially important for tone and judgment calls.
USER PROMPT:
"Here is a lead to score:
Company: StyleHub
Website content: 'StyleHub is a DTC fashion brand selling through Shopify Plus. We did $22M in revenue last year and are scaling our paid social spend.'
Example of a correct scoring output:
{
\"score\": 88,
\"fit\": \"strong\",
\"icp_match\": {\"market_segment\": true, \"vertical\": true, \"platform\": true},
\"summary\": \"StyleHub is a $22M DTC fashion brand on Shopify Plus actively scaling paid social. Strong ICP match — they need attribution analytics as they increase ad spend.\",
\"next_action\": \"call\"
}
Now score the following lead:
Company: DataPipe
Website content: 'DataPipe provides ETL pipelines for enterprise data teams. Fortune 500 clients. Annual contracts from $200K.'"
Rule 4: Handle edge cases in the prompt, not in your code. Tell the model what to do when data is missing, ambiguous, or outside its knowledge.
"If the company website is unreachable or returns no useful content:
- Set score to 0
- Set fit to \"unknown\"
- Set summary to \"Unable to access company website. Manual research required.\"
- Set next_action to \"email_sequence\"
If the company is clearly not in e-commerce (e.g., healthcare, government, fintech):
- Score based on any partial ICP overlap
- Always explain WHY in the summary"
0.0–0.2: Classification tasks, scoring, categorization. You want the same input to produce the same output every time.
0.3–0.5: Summarization and structured drafting. Some variety is acceptable but the output should stay professional.
0.5–0.7: Draft email/message responses. You want natural language variation so responses don't feel robotic.
Never above 0.7 for client work. High temperatures produce creative but unreliable output. Save that for brainstorming, not production systems.
Set up your n8n environment and learn the nodes you'll use in every AI integration.
Get n8n running and configure credentials for OpenAI, HubSpot, and Slack.
app.n8n.cloud → Start free trial. The free tier gives you 2,500 executions/month — plenty for development. Production will use self-hosted, but cloud is faster for building.crm.objects.contacts.read, crm.objects.contacts.write, crm.schemas.contacts.read → Create App → copy the token.api.slack.com/apps → Create New App → From Scratch → name it "ClearMetrics AI Bot" → select your workspace.chat:write, commands, channels:read.xoxb-...).Always use n8n's credential store. When you export a workflow to share with the client, credentials are automatically stripped. If you hardcode a key in an HTTP Request node URL or body, it will be visible to anyone who opens the workflow. This is a career-ending mistake on Upwork — if a client's API key leaks because of your workflow, you're liable.
Learn to call any LLM API from n8n using the HTTP Request node. This is more flexible than the native OpenAI node and works with any provider.
n8n has a native OpenAI node, but the HTTP Request node is better for client work because: (1) it works with any LLM provider (OpenAI, Anthropic, local models) without switching nodes, (2) you can see and control the exact request being sent, (3) it's easier to debug, and (4) it matches the API documentation directly.
| Field | Value |
|---|---|
| Method | POST |
| URL | https://api.openai.com/v1/chat/completions |
| Authentication | Generic Credential Type → Header Auth |
| Header Name | Authorization |
| Header Value | Bearer sk-proj-YOUR_KEY (from credential) |
| Send Body | Yes → JSON → specify body below |
| Response Format | JSON |
// Body for the HTTP Request node:
{
"model": "gpt-4.1-mini",
"messages": [
{
"role": "system",
"content": "{{ $json.systemPrompt }}"
},
{
"role": "user",
"content": "{{ $json.userPrompt }}"
}
],
"temperature": 0.2,
"max_tokens": 500,
"response_format": {"type": "json_object"}
}
The LLM's response is nested inside the HTTP response body. In the next n8n node, access it with:
// The raw text response:
{{ $json.choices[0].message.content }}
// If you used JSON mode, parse it:
{{ JSON.parse($json.choices[0].message.content) }}
// Access a specific field from the JSON:
{{ JSON.parse($json.choices[0].message.content).score }}
// Token usage (for logging):
{{ $json.usage.total_tokens }}
LLM API calls fail. Rate limits (429), server errors (500), timeouts, malformed responses. In n8n, add error handling:
$json.statusCode === 429, then a Wait node (30 seconds), then retry the HTTP Request.Set up the entry points for all three use cases. Each workflow starts with a trigger — understanding webhook architecture is essential.
Trigger 1: HubSpot Contact Created Webhook
HubSpot can send a webhook when a contact is created or updated. In HubSpot: Automation → Workflows → Create Workflow → trigger "Contact enrollment" → "Contact is created" → Action: "Send webhook" → URL: your n8n webhook URL.
// HubSpot sends this payload:
{
"objectId": 12345,
"properties": {
"email": "jane@shopflow.com",
"firstname": "Jane",
"lastname": "Smith",
"company": "ShopFlow",
"website": "https://shopflow.com"
},
"subscriptionType": "contact.creation"
}
Trigger 2: Support Ticket Webhook (Generic)
The client uses a custom support widget that sends HTTP POST requests. This is the simplest trigger — a raw webhook.
// Support widget sends:
{
"ticket_id": "TK-4521",
"subject": "Can't export CSV from dashboard",
"body": "Hi, I'm trying to export my Q3 data as CSV but the button is greyed out. I'm on the Pro plan. Browser: Chrome 126. This is urgent — I need this for a board meeting tomorrow.",
"customer_email": "mike@retailco.com",
"customer_name": "Mike Chen",
"plan": "pro",
"submitted_at": "2026-08-15T14:32:00Z"
}
Trigger 3: Slack Slash Command
When a user types /ask How do I set up SSO for a client? in Slack, Slack sends a POST to your n8n webhook.
// Slack sends (form-encoded, not JSON):
{
"command": "/ask",
"text": "How do I set up SSO for a client?",
"user_id": "U0ABC123",
"user_name": "sarah.kim",
"channel_id": "C0DEF456",
"response_url": "https://hooks.slack.com/commands/..."
}
/ask → Request URL: paste your n8n webhook URL → Short Description: "Ask the AI knowledge bot a question" → Save.Build the first deliverable: an automated pipeline that researches, scores, and enriches every new HubSpot contact.
Build the complete n8n workflow: HubSpot webhook → fetch company website → LLM scores the lead → write enrichment data back to HubSpot.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Webhook │───▶│ HubSpot: │───▶│ HTTP Req: │───▶│ HTTP Req: │───▶│ HubSpot: │
│ (trigger) │ │ Get Contact │ │ Fetch URL │ │ OpenAI API │ │ Update │
│ │ │ (full data) │ │ (company │ │ (score + │ │ Contact │
│ │ │ │ │ website) │ │ summarize) │ │ (write back) │
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ IF: website │ │ Google Sheet: │
│ reachable? │ │ Log result │
└─────────────┘ └──────────────┘
Node 1: Webhook (trigger)
objectId (HubSpot contact ID) and basic properties.Node 2: HubSpot — Get Contact
{{ $json.body.objectId }}.email, firstname, lastname, company, website, phone, jobtitle.Node 3: HTTP Request — Fetch Company Website
{{ $json.properties.website }}.Node 4: Code Node — Extract Text from HTML
// Code node — extract text from HTML
const html = $input.first().json.data || '';
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 3000); // Cap at ~3000 chars to control token usage
return [{ json: { websiteText: text, contact: $input.first().json } }];
Node 5: HTTP Request — OpenAI API Call
You are a lead scoring assistant for ClearMetrics, a B2B analytics platform for mid-market e-commerce.
ICP CRITERIA:
- Market segment: Mid-market e-commerce ($5M-$100M revenue)
- Platform: Shopify Plus, BigCommerce, or custom e-commerce
- Pain point: Needs better analytics, attribution, or data visibility
- Decision maker titles: VP Marketing, CMO, Head of Growth, Director of E-commerce
SCORING RUBRIC:
- 80-100: Strong match on 3+ criteria. Immediate outreach.
- 50-79: Partial match. Worth discovery call.
- 20-49: Weak match. Nurture sequence.
- 0-19: Not a fit. Disqualify.
Respond ONLY in valid JSON with these exact keys:
{
"score": integer 0-100,
"fit": "strong" | "partial" | "weak" | "disqualify",
"summary": "2-3 sentence summary for the sales rep. Be specific about why.",
"industry": "detected industry",
"estimated_revenue": "best estimate or 'unknown'",
"next_action": "immediate_call" | "discovery_email" | "nurture" | "disqualify"
}
If website content is empty or unavailable, set score to 0 and fit to "unknown".
Score this lead:\n\nName: {{ $json.contact.properties.firstname }} {{ $json.contact.properties.lastname }}\nCompany: {{ $json.contact.properties.company }}\nTitle: {{ $json.contact.properties.jobtitle }}\nEmail: {{ $json.contact.properties.email }}\n\nCompany website content:\n{{ $json.websiteText }}0.2, max_tokens to 400, response_format to JSON mode.Node 6: HubSpot — Update Contact
{{ $('Webhook').first().json.body.objectId }}.Before this node works, you must create these custom properties in HubSpot: Settings → Properties → Create Property (in the Contact object):
ai_lead_score — Number field (0-100)ai_fit_level — Single-line textai_summary — Multi-line textai_next_action — Single-line textai_enriched_at — Date field// Property mapping in the HubSpot Update node:
ai_lead_score: {{ JSON.parse($json.choices[0].message.content).score }}
ai_fit_level: {{ JSON.parse($json.choices[0].message.content).fit }}
ai_summary: {{ JSON.parse($json.choices[0].message.content).summary }}
ai_next_action: {{ JSON.parse($json.choices[0].message.content).next_action }}
ai_enriched_at: {{ new Date().toISOString() }}
Test the pipeline with diverse lead types, calibrate scoring accuracy, and handle failure modes.
| Test Lead | Expected Score | Why |
|---|---|---|
| Shopify Plus store, $30M revenue, VP Marketing | 85–95 | Perfect ICP match |
| Custom e-commerce, $8M revenue, CEO | 70–85 | Right vertical, smaller, CEO not ideal buyer |
| B2B SaaS company, $50M revenue | 15–30 | Right size, wrong vertical |
| Freelance consultant, no website | 0–10 | Not a company, no data |
| Competitor analytics platform | 0–5 | Disqualify — competitor |
| Website unreachable (404 or timeout) | 0 | Graceful degradation, flag for manual review |
Add production-grade logging so the client can see what's happening and you can debug problems remotely.
Add a Google Sheets node at the end of the workflow that appends a row for every lead processed:
| Column | Value |
|---|---|
| Timestamp | {{ new Date().toISOString() }} |
| Contact ID | {{ $('Webhook').first().json.body.objectId }} |
| Company | {{ $('HubSpot').first().json.properties.company }} |
| Score | {{ JSON.parse($('OpenAI').first().json.choices[0].message.content).score }} |
| Fit | {{ JSON.parse($('OpenAI').first().json.choices[0].message.content).fit }} |
| Tokens Used | {{ $('OpenAI').first().json.usage.total_tokens }} |
| API Cost | {{ (($('OpenAI').first().json.usage.prompt_tokens * 0.4 + $('OpenAI').first().json.usage.completion_tokens * 1.6) / 1000000).toFixed(6) }} |
| Status | success or error |
The Google Sheet becomes your proof of work. When the client asks "Is the AI working?" you can show them a live spreadsheet with every lead processed, every score, every cost. It also lets you demonstrate ROI: "Your AI scored 200 leads this month for $0.32 in API costs, saving your team ~40 hours of manual research." That's how you justify the maintenance retainer.
Build the second deliverable: automated ticket classification and response drafting with human review.
Build the first stage of the triage system: receive a ticket, classify it by category and urgency, and route it.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Webhook │───▶│ HTTP Req: │───▶│ Code Node: │───▶│ Slack: │
│ (ticket │ │ OpenAI API │ │ Format msg │ │ Post to │
│ incoming) │ │ (classify) │ │ for Slack │ │ #support-ai │
└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
You are a support ticket classifier for ClearMetrics, a B2B analytics SaaS.
CATEGORIES (pick exactly one):
- bug: Software defect, broken feature, error message, crash
- feature_request: New functionality, enhancement, integration request
- billing: Invoice, payment, plan change, refund, pricing question
- how_to: Usage question, setup help, configuration, best practices
- account_access: Login issues, SSO, permissions, password reset, MFA
URGENCY LEVELS:
- critical: Production system down, data loss, security issue, revenue impact
- high: Major feature broken, blocking customer's workflow, time-sensitive
- medium: Inconvenience, workaround exists, not time-sensitive
- low: Nice-to-have, general question, feedback
RULES:
- If the customer mentions "urgent", "ASAP", "board meeting", "demo tomorrow", or similar time pressure → upgrade urgency by one level
- If the customer is on Enterprise plan → default urgency to at least "medium"
- Bug + data-related → always "critical" (potential data integrity issue)
Respond ONLY in valid JSON:
{
"category": string,
"urgency": string,
"confidence": float 0-1,
"reasoning": "one sentence explaining classification",
"key_issue": "the core problem in 10 words or less"
}
The Code node formats the classification into a readable Slack message:
const ticket = $('Webhook').first().json.body;
const classification = JSON.parse($('OpenAI').first().json.choices[0].message.content);
const urgencyEmoji = {
critical: '🔴',
high: '🟠',
medium: '🟡',
low: '🟢'
};
const message = `${urgencyEmoji[classification.urgency]} *${classification.urgency.toUpperCase()}* — ${classification.category}
*Ticket:* ${ticket.ticket_id}
*From:* ${ticket.customer_name} (${ticket.customer_email}) — ${ticket.plan} plan
*Subject:* ${ticket.subject}
*Key issue:* ${classification.key_issue}
> ${ticket.body.substring(0, 500)}
_AI confidence: ${(classification.confidence * 100).toFixed(0)}% — ${classification.reasoning}_`;
return [{ json: { text: message, ticket, classification } }];
Add the second stage: retrieve relevant help docs and draft a response for the support rep to review.
Before the AI can draft responses, it needs access to the client's help documentation. We use a simple but effective approach: pre-process docs into a searchable format.
// docs-index.json — stored in n8n as a static file or a Code node
[
{
"id": "csv-export",
"title": "How to Export Data as CSV",
"keywords": ["export", "csv", "download", "data", "report"],
"content": "To export your dashboard data as CSV:\n1. Navigate to the dashboard\n2. Click the '...' menu in the top-right\n3. Select 'Export as CSV'\n\nNote: CSV exports are available on Pro and Enterprise plans. Free plans can export up to 100 rows. If the export button is greyed out, check that your date range is selected and the dashboard has finished loading."
},
{
"id": "sso-setup",
"title": "Setting Up SSO for Your Organization",
"keywords": ["sso", "single sign-on", "saml", "okta", "azure ad", "login"],
"content": "SSO is available on Enterprise plans. To configure:\n1. Go to Settings → Security → SSO\n2. Select your identity provider (Okta, Azure AD, OneLogin)\n3. Enter your SAML endpoint URL and certificate\n4. Map user attributes..."
}
// ... more docs
]
Document retrieval logic (Code node):
// Simple keyword-based retrieval
const ticket = $('Webhook').first().json.body;
const classification = $('Classification').first().json;
const docsIndex = [/* your docs-index.json loaded here */];
// Extract keywords from ticket subject + body
const ticketText = (ticket.subject + ' ' + ticket.body).toLowerCase();
// Score each doc by keyword overlap
const scored = docsIndex.map(doc => {
const matches = doc.keywords.filter(kw => ticketText.includes(kw));
return { ...doc, matchCount: matches.length };
});
// Take top 3 most relevant docs
const relevant = scored
.filter(d => d.matchCount > 0)
.sort((a, b) => b.matchCount - a.matchCount)
.slice(0, 3);
const context = relevant.map(d => `--- ${d.title} ---\n${d.content}`).join('\n\n');
return [{ json: { relevantDocs: context, ticket, classification } }];
You are a support agent for ClearMetrics writing a draft reply to a customer ticket.
RULES:
- Be helpful, professional, and concise
- Reference specific steps from the documentation when available
- If the docs don't cover this issue, acknowledge that and suggest next steps
- Never make up features or capabilities not in the docs
- Never promise timelines for fixes or features
- Sign off as "ClearMetrics Support" (the human rep will personalize before sending)
- Keep responses under 200 words
DOCUMENTATION CONTEXT:
{{ $json.relevantDocs }}
TICKET CLASSIFICATION:
Category: {{ $json.classification.category }}
Urgency: {{ $json.classification.urgency }}
Respond in plain text (not JSON). Write the email body only — no subject line.
This system drafts responses — it never sends them automatically. The draft appears in Slack where a support rep reviews, edits, and sends. This is not optional. The client explicitly said "we do NOT want auto-replies." Even if they hadn't, auto-sending AI-drafted responses to customers is a business risk you should never take in a v1 delivery. Position the human review step as a feature, not a limitation.
Wire the classification and drafting stages together into a single workflow with the complete Slack output.
const ticket = $('Webhook').first().json.body;
const classification = JSON.parse($('Classify').first().json.choices[0].message.content);
const draftResponse = $('Draft').first().json.choices[0].message.content;
const urgencyEmoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
const message = `${urgencyEmoji[classification.urgency]} *NEW TICKET — ${classification.urgency.toUpperCase()}*
*ID:* ${ticket.ticket_id} | *Category:* ${classification.category} | *Plan:* ${ticket.plan}
*From:* ${ticket.customer_name} <${ticket.customer_email}>
*Subject:* ${ticket.subject}
> ${ticket.body}
---
📝 *AI Draft Response:*
${draftResponse}
---
_Classification confidence: ${(classification.confidence * 100).toFixed(0)}% | AI reasoning: ${classification.reasoning}_
_⚠️ Review and edit before sending to customer_`;
return [{ json: { channel: '#support-ai', text: message } }];
Build the third deliverable: a Slack bot that answers team questions using internal documentation.
Process the client's ~50 internal documents into a searchable format that the LLM can use as context.
// knowledge-base.json
[
{
"id": "refund-policy-001",
"source_doc": "Company Policies — Refund and Cancellation",
"section": "Refund Eligibility",
"keywords": ["refund", "cancel", "money back", "billing", "charge"],
"content": "ClearMetrics offers full refunds within 14 days of purchase for annual plans. Monthly plans can be cancelled at any time with no refund for the remaining period. To process a refund: 1. Customer submits request via support. 2. Support verifies the purchase date. 3. If within 14 days, process through Stripe dashboard. Partial refunds are handled case-by-case by the billing team."
},
{
"id": "sso-setup-001",
"source_doc": "IT Handbook — SSO Configuration",
"section": "Setting Up SSO for a New Client",
"keywords": ["sso", "saml", "okta", "azure", "single sign-on", "client onboarding"],
"content": "To set up SSO for a new Enterprise client: 1. Confirm client is on Enterprise plan. 2. Navigate to Admin → Organizations → [Client Name] → Security. 3. Enable SAML SSO. 4. Provide the client with our SAML metadata URL: https://app.clearmetrics.io/saml/metadata. 5. Client provides their IdP metadata XML. 6. Upload the XML and test with a single user. 7. Enable 'Require SSO' only after successful test."
}
// ... 48 more chunks
]
For ~50 documents, keyword matching is fast, free, debuggable, and accurate enough. Vector search adds: an embedding API cost ($0.02/M tokens with text-embedding-3-small), a vector database (Pinecone, Qdrant), and complexity the client must maintain. Save that for v2 if the knowledge base grows past 200+ documents. For now, simple wins. You can always upsell the vector upgrade later — that's a separate $1,000–$2,000 project.
Build the Q&A workflow: Slack slash command → search knowledge base → LLM generates answer with citations → reply in Slack.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Webhook │───▶│ Code Node: │───▶│ HTTP Req: │───▶│ Code Node: │───▶│ HTTP Req: │
│ (Slack │ │ Search docs │ │ OpenAI API │ │ Format │ │ Slack API │
│ /ask cmd) │ │ (keyword │ │ (answer with │ │ Slack reply │ │ (post msg) │
│ │ │ matching) │ │ citations) │ │ │ │ │
└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
You are ClearMetrics' internal knowledge assistant. Team members ask you questions about company policies, processes, and product features.
RULES:
1. Answer ONLY from the documentation provided below. Never make up information.
2. If the answer is in the docs, respond clearly and cite the source document.
3. If the answer is NOT in the docs, say: "I don't have information about that in my knowledge base. You might want to ask [relevant team] or check [relevant system]."
4. Be concise — Slack messages should be scannable, not essays.
5. Use bullet points for multi-step processes.
6. Always end with the source: "📄 Source: [document name]"
DOCUMENTATION:
{{ $json.relevantDocs }}
If no relevant documents were found for this query, respond: "I couldn't find any documentation related to your question. Try rephrasing, or ask in #general for help from the team."
Post the response back to the user using Slack's response_url (provided in the slash command payload):
// Final HTTP Request node — respond to Slack
// Method: POST
// URL: {{ $('Webhook').first().json.body.response_url }}
// Body:
{
"response_type": "in_channel",
"text": "{{ $json.answer }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "💬 *{{ $('Webhook').first().json.body.user_name }}* asked: _{{ $('Webhook').first().json.body.text }}_"
}
},
{
"type": "divider"
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{ $json.answer }}"
}
}
]
}
Ensure the bot answers correctly when it should and says "I don't know" when it should. This is where most AI integrations fail — they hallucinate answers instead of admitting ignorance.
| Query | Expected Behavior | Pass/Fail |
|---|---|---|
| "What's our refund policy?" | Answers from refund-policy doc, cites source | |
| "How do I set up SSO for a client?" | Step-by-step from SSO doc, cites source | |
| "What's the CEO's phone number?" | "I don't have that information" (not in docs) | |
| "What's the meaning of life?" | "I don't have that information" (out of scope) | |
| "Can I get a refund after 30 days?" | References 14-day policy, explains limitation | |
| "How do I set up SSO with OneLogin?" | References SSO doc but notes OneLogin is supported, adapts steps | |
| "What's our competitor's pricing?" | "I don't have that information" (not in docs, sensitive) |
Ask the bot a question that sounds like it could be in the docs but isn't. If the bot invents an answer instead of saying "I don't know," your prompt needs stronger guardrails. Add this to the system prompt: "If you are even slightly unsure whether the information is in the provided documentation, say 'I don't know.' It is always better to admit ignorance than to provide incorrect information."
The difference between a demo and a production system is how it handles failure.
| Failure | Cause | Impact | Handling |
|---|---|---|---|
| API rate limit (429) | Too many requests/min | Workflow fails | Wait 30s → retry → if 2nd fail, alert Slack |
| API server error (500) | OpenAI outage | Workflow fails | Retry once → if fail, log + alert → manual processing |
| Malformed JSON response | Model didn't follow format | Parse error in next node | Try/catch in Code node → fallback to raw text |
| HubSpot webhook timeout | n8n slow to respond | HubSpot retries (duplicates) | Deduplication by objectId in first node |
| Company website unreachable | Domain down, firewall | No enrichment data | Score 0, flag for manual review |
| Token limit exceeded | Very long website/ticket | Truncated or rejected | Truncate input to 3000 chars before sending |
| API key expired/revoked | Key rotation, billing issue | All calls fail | Alert owner immediately via email |
// In any Code node that parses LLM output:
try {
const result = JSON.parse($input.first().json.choices[0].message.content);
return [{ json: { ...result, status: 'success' } }];
} catch (e) {
// LLM didn't return valid JSON — use raw text as fallback
return [{ json: {
score: 0,
fit: 'error',
summary: $input.first().json.choices[0].message.content,
next_action: 'manual_review',
status: 'parse_error',
error: e.message
}}];
}
Your deliverable isn't working code. It's a system the client's team can operate without you.
CLEARMETRICS AI INTEGRATION — RUNBOOK
1. SYSTEM HEALTH CHECK (weekly)
□ Open Google Sheet log — check for any "error" status rows
□ Open n8n → Executions → filter by "Error" — investigate any failures
□ Check OpenAI usage dashboard — verify costs are within expected range
2. COMMON ISSUES
Issue: Lead enrichment stopped working
→ Check: Is the HubSpot workflow still active? (Automation → Workflows)
→ Check: Is the n8n webhook URL still correct?
→ Check: Is the OpenAI API key still valid? (platform.openai.com/api-keys)
Issue: Slack bot not responding
→ Check: Is the n8n workflow active?
→ Check: Is the Slack app still installed? (api.slack.com/apps)
→ Check: Does /ask show an error in Slack? → Check n8n execution log
Issue: Ticket triage classifying wrong category
→ Review recent tickets in the log sheet
→ Adjust the system prompt categories or add examples
→ Test with 5 sample tickets after changes
3. UPDATING THE KNOWLEDGE BASE
→ Add new doc: Create a new entry in knowledge-base.json
→ Include: id, source_doc name, section, keywords, content
→ Test: Ask the bot a question the new doc should answer
→ Verify: Bot cites the new document correctly
4. MONTHLY COST CHECK
→ OpenAI: platform.openai.com/usage → current month → compare to estimate
→ n8n: Check execution count → compare to plan limits
→ Expected: ~$5-10/mo OpenAI, ~$24/mo n8n cloud
5. ESCALATION
For issues you can't resolve: [Your name] — [your email]
Response time: Within 24 hours on business days
Close the project, secure the review, and position yourself for the next one.
Hi [Client name],
All three AI integrations are live and tested. Here's what's been delivered:
1. Lead Enrichment (live)
Every new HubSpot contact is automatically scored and enriched. I've processed your last 10 test leads — results are in the HubSpot custom properties (ai_lead_score, ai_summary) and the logging spreadsheet. Average processing time: 3 seconds per lead. API cost for 200 leads/month: ~$0.32.
2. Ticket Triage (live)
Tickets are classified and draft responses appear in #support-ai within 4 seconds. I've run 15 test tickets covering all 5 categories — classification accuracy is 93% (14/15 correct). The one miss was a billing question phrased as a bug report — I've tuned the prompt to handle that edge case.
3. Knowledge Bot (live)
Type /ask in any Slack channel to query your 50 internal docs. Tested with 20 questions — 18 correct answers with proper citations, 2 correctly said "I don't know." Zero hallucinations.
📎 Attached: Architecture diagram, runbook, prompt documentation, n8n workflow exports (JSON).
🎥 Loom walkthrough (18 min): [link]
Happy to do a 30-min handover call to walk your team through everything. After that, I'm available for the monthly maintenance retainer we discussed ($400/mo) — that covers prompt tuning, knowledge base updates, monitoring, and priority bug fixes.
After the client confirms everything is working (give them 3-5 days to test), send: "Great to hear it's working well! If you have a moment, a review on Upwork would really help — it's the main way new clients find me for this kind of work. No pressure at all."
Do not ask before they've confirmed satisfaction. Do not ask more than once. If they leave a 5-star review, thank them briefly. Your Upwork profile with verified AI integration reviews at $2,800 is worth more than any certification.
After this project, your Upwork profile should highlight:
| Skill Tag | Evidence |
|---|---|
| AI Integration | "Connected OpenAI APIs to HubSpot CRM and Slack for a B2B SaaS — automated lead scoring, support triage, and internal Q&A" |
| n8n Workflow Automation | "Built 3 production n8n workflows processing 2,000+ monthly events" |
| OpenAI API / LLM Integration | "Production prompt engineering for classification, scoring, and document Q&A" |
| HubSpot CRM Automation | "Custom properties, webhook integrations, automated lead enrichment" |
| Project Type | Scope | Price Range | Timeline |
|---|---|---|---|
| Single AI feature | One workflow (e.g., lead scoring only) | $800–$2,000 | 3–5 days |
| Multi-use-case integration | 2–3 AI workflows + documentation | $2,000–$5,000 | 2–3 weeks |
| Full AI ops setup | Multiple workflows + RAG + monitoring + training | $5,000–$15,000 | 4–8 weeks |
| Enterprise AI platform | Custom AI system with vector search, fine-tuning, multi-tenant | $15,000–$50,000+ | 2–6 months |
| Monthly retainer | Maintenance, prompt tuning, KB updates, monitoring | $300–$1,500/mo | Ongoing |
This $2,800 project opens three natural follow-ups:
1. Vector search upgrade ($1,500–$3,000): When the knowledge base grows past 200 docs, migrate from keyword search to embeddings + Pinecone/Qdrant. Same workflow architecture, better retrieval.
2. n8n self-hosting ($500–$1,000): Migrate from n8n cloud to their own Docker instance. Saves $24/mo and gives them full control.
3. Additional use cases ($1,000–$3,000 each): AI-generated onboarding emails, churn prediction, meeting note summarization. Every SaaS team has 5 more things they'd automate once they see the first three working.
— End of AI Integration Module —
Everything you need to know before starting. Total investment, what you'll work with, and exactly how each module changes your Upwork positioning.
Every tool and service across all three modules. No hidden costs.
| Tool / Service | Used In | Cost | Notes |
|---|---|---|---|
| Shopify & E-Commerce | |||
| Shopify Partner Account | M2 | Free | Unlimited dev stores, no card required |
| Shopify Development Store | M2 | Free | Full store, no time limit |
| Shopify Basic Plan (fallback) | M2 | $1–$3 | Only if FB app won't install on dev store. $1/mo for 3 months. |
| Meta & Advertising | |||
| Meta Business Suite | M1, M2 | Free | Business Manager, Pixel, Events Manager |
| Meta Pixel Helper (Chrome) | M1, M2 | Free | Browser extension for auditing |
| Google Tag Manager | M2 | Free | Container for simulating GTM-based installs |
| Automation Platforms | |||
| n8n (self-hosted) | M1, C1 | Free | Docker or Node.js install. Unlimited executions. |
| n8n Cloud (alternative) | M1, C1 | Free trial | 14-day trial, no card. 2,500 executions/mo. |
| Zapier | M1 | Free tier | 100 tasks/mo on free plan — enough for learning |
| ngrok / Cloudflare Tunnel | M1, C1 | Free | Webhook tunneling for self-hosted n8n |
| SaaS & CRM | |||
| Typeform | M1 | Free tier | 10 responses/mo on free plan |
| Calendly | M1 | Free tier | 1 event type, unlimited bookings |
| Stripe (test mode) | M1 | Free | Test mode with fake card numbers, no real charges |
| HubSpot CRM | C1 | Free | Free CRM includes contacts, deals, API access |
| Slack | C1 | Free | Free workspace for bot integration |
| Google Sheets | M1, C1 | Free | Logging, cost tracking, data pipeline |
| AI / LLM APIs | |||
| OpenAI API (GPT-4.1-mini) | C1 | ~$1 | Pay-as-you-go. Entire lab costs ~$1 in tokens. |
| Anthropic API (Claude Haiku) | C1 | ~$1 | Pay-as-you-go. Knowledge bot use case. |
| Dev Tools (already on your computer) | |||
| Chrome DevTools | M1, M2 | Free | Network tab for inspecting HTTP requests |
| Incognito / Private Browsing | M1, M2 | Free | Clean-cookie testing |
| Text editor (any) | All | Free | VS Code, Sublime, Notepad++ — anything |
The $2–$5 is LLM API credits for the AI module. If the Facebook app installs on your free dev store (it usually does), the rest is $0. No subscriptions, no monthly fees, no credit card holds.
The automation engineering landscape on Upwork as of August 2026.
| Category | Upwork Demand | Entry Rate | Senior Rate | Supply |
|---|---|---|---|---|
| n8n Experts | 5,129 client reviews | $15–$25/hr | $80–$150/hr | Growing, still undersupplied |
| API Integration | 23,514 client reviews | $20–$35/hr | $55–$85/hr | Large pool, competitive |
| AI Automation Engineers | +178% YoY growth | $35–$45/hr | $100–$180+/hr | Demand outpacing supply |
| Workflow Automation (Zapier/Make) | 8,838+ client reviews | $10–$20/hr | $40–$80/hr | Largest pool, low barrier |
Each lab gives you a new deliverable skill and unlocks a higher-paying market tier.
What you produce: A complete Shopify tracking audit — diagnostic matrix mapping all 5 installation points, identification of duplicate/conflicting Pixels, fix implementation, and a clean handover document the client can use after you leave.
Tools mastered: Shopify Partner + dev stores, Meta Pixel Helper, Chrome DevTools (Network tab), GTM container auditing, theme.liquid code inspection, Events Manager verification.
Upwork service you can sell: "Shopify Meta Pixel & CAPI Audit — find and fix duplicate tracking, inflated ROAS, and event deduplication failures."
"I know some Shopify and Meta ads"
No portfolio, no deliverable template
Competing on price in general VA pool
Can walk into any Shopify store and produce a professional audit
Diagnostic matrix template ready to reuse
Positioned as a specialist, not a generalist
What you produce: A complete B2B lead-gen tracking system built from scratch — Typeform forms, Calendly scheduling, Stripe payment webhooks, five custom conversion events sent to Meta via both Pixel and Conversions API, with n8n/Zapier automations handling the server-side pipeline.
Tools mastered: n8n (self-hosted + cloud), Zapier, Meta Conversions API, webhook architecture, SHA-256 hashing, event deduplication, Typeform, Calendly, Stripe test mode, Google Sheets as a data layer.
Upwork service you can sell: "Meta CAPI implementation — I build your full conversion tracking pipeline from form fill to purchase, with server-side events and automated deduplication."
Key unlock: You now know n8n and Zapier — the two most in-demand automation platforms on Upwork. This skill transfers to every automation job, not just tracking.
Can audit and fix existing systems
Hourly diagnostic work, $10–$30/hr
No automation or build experience
Can build complete systems from scratch
Fixed-price projects at $300–$400 each
n8n + Zapier skills open the entire automation market
What you produce: Three production AI integrations for a SaaS client — an AI lead enrichment pipeline (HubSpot + LLM scoring), an automated support ticket triage system (webhook + LLM classification + Slack), and an internal knowledge base Q&A bot (Slack command + document retrieval + cited answers). Complete with error handling, cost tracking, runbook, and client handover package.
Tools mastered: OpenAI API (GPT-4.1-mini), Claude API (Haiku), n8n AI workflows, HubSpot CRM API, Slack Bot API, webhook architecture, prompt engineering, structured JSON output, Google Sheets logging, production error handling.
Upwork service you can sell: "AI integration for SaaS — I connect LLMs to your existing CRM, support tools, and Slack with production-grade workflows, error handling, and documentation your team can maintain."
Key unlock: AI integration is the fastest-growing category on Upwork (+178% YoY). Freelancers who bridge the gap between an API key and a production system command the highest rates. You're no longer selling hours — you're selling fixed-price projects with retainers.
Can build automation workflows
$300–$400 fixed-price tracking projects
No AI or LLM experience
Can deliver $2,000–$5,000 AI integration projects
Retainer-eligible: $300–$500/mo ongoing maintenance
Top 5% positioning in fastest-growing Upwork category
| Service | Pricing Model | Rate | Jobs/Month | Monthly Revenue |
|---|---|---|---|---|
| Shopify tracking audits | Hourly | $20–$30/hr | 2–3 | $400–$900 |
| Meta CAPI builds | Fixed price | $300–$400 | 2–3 | $600–$1,200 |
| AI integrations | Fixed + retainer | $2,000–$5,000 | 1 | $2,000–$5,000 |
| Retainer clients (AI) | Monthly | $300–$500 | 2–4 | $600–$2,000 |
| Total (part-time freelancing) | $3,600–$9,100/mo | |||
Rate data sourced from Upwork hiring pages (Aug 2026), Ciphernutz n8n pricing guide, RemoteAITools market analysis, and Upwork's official hourly rate guide. Actual earnings depend on your profile, reviews, proposal quality, and market conditions.
All three labs in recommended learning order, paced for busy professionals doing 1.5–2 hours on weekday evenings and 2–3 hours on weekends.
Start here — learn how tracking works by walking into a broken system, auditing it, and fixing it.
Now build a complete tracking system from scratch — you already know how tracking works from M2.
The capstone — apply your n8n skills from M1 to the highest-value automation work: LLM integrations.