server-track-labby Marc Stevens

Send B2B form and CRM conversions server-side: HubSpot, Typeform and webhooks to Meta CAPI

By Marc Stevens · October 14, 2026

In B2B SaaS, the conversions that matter rarely happen on a thank-you page. A demo request lands in HubSpot. A Typeform response comes in. Two weeks later sales marks the deal qualified, then opportunity, then closed won. The browser Pixel sees the first click at best. Your form tool and CRM see everything, on their servers. This guide shows how to forward those events to Meta's Conversions API with one small webhook relay and one endpoint: POST /api/v1/track.

Status, honestly: Meta delivery is live. Google delivery is coming soon. The Google adapter is stubbed, so Google previews show up in the playground but nothing is sent to Google Ads yet.

New to server-side events? Start with server-side vs browser pixel tracking. If you also fire the Pixel on the form page, read Meta CAPI deduplication with event_id first.

The funnel and the event names

You send B2B names; server-track-lab maps them to Meta event names (from our src/eventMap.ts):

Funnel momentYou send event_nameMeta receives
Form submitted (HubSpot, Typeform, any webhook)leadLead
Demo bookeddemo_bookedSchedule
Sales-qualified (MQL/SQL)qualifiedLead, with custom_data.event_stage: "qualified"
Opportunity createdopportunityqualified_opportunity (custom event)
Closed wonclosed_wonPurchase (needs value + currency)

For the form lead, use "action_source": "website" and the form page URL. For CRM stage changes nobody did in a browser, use "action_source": "system_generated", one of the values on Meta's server event parameters page.

Step 1: the raw request

Everything below is just a way to produce this request. Field names match our validator exactly; unknown top-level fields are rejected with a 422.

curl https://server-track-lab.pages.dev/api/v1/track \
  -H "Authorization: Bearer $SST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "lead",
    "event_id": "typeform_a3a12ec67a1365927098a606107fac15",
    "timestamp": "2026-10-14T13:17:02Z",
    "action_source": "website",
    "event_source_url": "https://acme.example/contact-sales",
    "user": {
      "email": "laura@example.com",
      "external_id": "crm_contact_1246965"
    },
    "properties": { "source": "typeform", "form_id": "lT4Z3j" }
  }'

A later stage, from the CRM, with value:

curl https://server-track-lab.pages.dev/api/v1/track \
  -H "Authorization: Bearer $SST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "closed_won",
    "event_id": "hs_deal_1246965:closed_won",
    "timestamp": "2026-10-14T15:02:11Z",
    "action_source": "system_generated",
    "value": 4800,
    "currency": "CAD",
    "user": { "email": "laura@example.com", "external_id": "crm_contact_1246965" }
  }'

Raw emails are fine: server-track-lab normalizes and SHA-256 hashes them before sending, as Meta's customer information parameters require. At least one identifier (email, phone, external_id, fbp, fbc, …) is required.

Step 2: deterministic event_id

Webhooks retry. If your relay times out, the sender sends again. Build event_id from the source record so a retry produces the same value:

Allowed characters are letters, digits and . _ : -, up to 128. Repeats within 72 hours return "status": "deduplicated" and are not sent or billed again. Don't use the webhook delivery's own ID (Typeform's top-level event_id, HubSpot's eventId) if the sender might mint a new one on redelivery; the record ID is safer.

Step 3: watch the 7-day limit on late stages

Meta's server event docs say event_time can be up to 7 days before you send the event, and if any event_time in a request is older than that, Meta returns an error for the whole request and processes no events. For CRM stages:

Step 4: the relay (Cloudflare Worker)

This Worker takes either a Typeform webhook or a HubSpot deal-property-change webhook and POSTs the mapped body to /api/v1/track. Set secrets SST_API_KEY and (for HubSpot) HUBSPOT_TOKEN. Field names on the incoming side come from the official docs:

// relay.js — Cloudflare Worker. Verify signatures before production (see below).
const TRACK = "https://server-track-lab.pages.dev/api/v1/track";

// MAPPING PLACEHOLDER: HubSpot dealstage values are internal stage IDs that
// differ per portal and pipeline. Replace the keys with YOUR stage IDs.
const HUBSPOT_STAGE_TO_EVENT = {
  "YOUR_QUALIFIED_STAGE_ID": "qualified",
  "YOUR_OPPORTUNITY_STAGE_ID": "opportunity",
  "closedwon": "closed_won", // check this ID in your pipeline settings
};

async function track(env, body) {
  const r = await fetch(TRACK, {
    method: "POST",
    headers: { "Authorization": `Bearer ${env.SST_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return r.status; // 200 accepted / deduplicated; 422 = check field names
}

function fromTypeform(p) {
  const fr = p.form_response;
  const email = (fr.answers || []).find((a) => a.type === "email")?.email;
  return {
    event_name: "lead",
    event_id: `typeform_${fr.token}`,
    timestamp: fr.submitted_at,
    action_source: "website",
    event_source_url: "https://acme.example/contact-sales", // your form page
    user: { email, external_id: fr.hidden?.user_id }, // hidden field only if you set one
    properties: { source: "typeform", form_id: fr.form_id },
  };
}

async function fromHubSpotDeal(e, env) {
  const stage = HUBSPOT_STAGE_TO_EVENT[e.propertyValue];
  if (e.propertyName !== "dealstage" || !stage) return null;
  // Deal webhooks carry no email. Look up the deal's contact and amount with
  // the HubSpot CRM API (MAPPING PLACEHOLDER: your lookup + field choice).
  const deal = await lookupDeal(e.objectId, env); // returns { email, amount, currency }
  const body = {
    event_name: stage,
    event_id: `hs_deal_${e.objectId}:${stage}`,
    timestamp: new Date(e.occurredAt).toISOString(),
    action_source: "system_generated",
    user: { email: deal.email, external_id: `hs_deal_${e.objectId}` },
    properties: { source: "hubspot" },
  };
  if (stage === "closed_won") { body.value = Number(deal.amount); body.currency = deal.currency || "CAD"; }
  return body;
}

async function lookupDeal(dealId, env) {
  // MAPPING PLACEHOLDER — implement with HubSpot's CRM objects + associations APIs.
  throw new Error("implement lookupDeal");
}

export default {
  async fetch(req, env) {
    if (req.method !== "POST") return new Response("ok");
    const p = await req.json();
    const url = new URL(req.url);
    if (url.pathname === "/typeform" && p.event_type === "form_response") {
      return new Response(String(await track(env, fromTypeform(p))));
    }
    if (url.pathname === "/hubspot" && Array.isArray(p)) {
      for (const e of p) {
        const body = await fromHubSpotDeal(e, env);
        if (body) await track(env, body);
      }
      return new Response("ok");
    }
    return new Response("ignored", { status: 202 });
  },
};

Point Typeform's webhook at https://your-relay.workers.dev/typeform and your HubSpot app's deal.propertyChange subscription (property dealstage) at /hubspot. The same pattern works in Node/Express: parse JSON, map, fetch the same body.

What about HubSpot form submissions?

HubSpot's webhook subscriptions are about CRM objects (contacts, deals and so on), not form submissions directly. Two options: subscribe to contact.creation and send lead with event_id: "hs_contact_" + objectId (you'll need a CRM lookup for the email, as above), or use a HubSpot workflow's webhook action. We haven't pinned the workflow webhook's exact payload fields against HubSpot's docs, so treat its mapping as a placeholder: inspect a real delivery in your relay logs and map the email and record ID yourself.

Verify the sender

Don't accept anonymous POSTs in production. Typeform signs payloads in the Typeform-Signature header (Secure your webhooks). HubSpot signs requests with X-HubSpot-Signature-v3 (Validating requests). Check them with HMAC SHA-256 before calling track.

Test before you go live

  1. Paste a mapped body into the playground. It validates the fields and shows the exact Meta payload, including the mapped event name, without storing anything.
  2. Add "test": true to send with your dashboard's Test Events code, and check Meta's Test Events tool (Verifying Your Setup).
  3. Replay the same webhook. The second call should return deduplicated.

FAQ

Why send form leads server-side instead of only with the Meta Pixel?

Form tools and CRMs already know about the lead on their servers. Sending from a webhook does not depend on the visitor's browser, ad blockers or cookies, and it lets you send later CRM stages that never happen in a browser at all.

Which Meta events do qualified, opportunity and closed_won become?

In server-track-lab, lead and qualified both map to Meta's Lead event (qualified adds event_stage: qualified to custom_data), opportunity maps to the custom event qualified_opportunity, and closed_won maps to Purchase and requires value and currency.

What happens if a deal closes more than 7 days after the lead?

That is fine as long as event_time is the time the stage changed and you send it within 7 days of that change. Meta's docs say event_time can be up to 7 days before you send it, and an older event_time makes Meta reject the whole request.

How do I stop webhook retries from double counting?

Build event_id from the source record and the stage, for example typeform_ plus the response token, or hs_deal_ plus the deal ID and stage. A retry produces the same event_id, so server-track-lab returns deduplicated for 72 hours and Meta can also deduplicate on event_id.

Does this send to Google Ads too?

Not yet. Meta delivery is live. Google delivery is coming soon; the Google adapter is stubbed, so Google events are accepted and previewed but not delivered.

Try it

Preview a mapped lead in the playground, no signup needed. Then get a free API key: 1,000 events a month free, then CA$2 per 1,000 events. Coming from another tool? See migrating from Stape or Cometly.