Skip to main content

Sending after-call events to GA4

Send completed Speed To Lead call results to Google Analytics 4 using webhooks and Measurement Protocol.

Send completed Speed To Lead™ call results to GA4 through your own server. This requires developer setup. For website-tag setup and callback-order events, see Integrating with GTM & GA4.

Step 1: Save the visitor’s GA4 identifiers

Open Speed To Lead™ → Widgets → your widget → Code snippets → Add snippet. Enable Snippet active, choose js code, set Url Pattern to .*, and paste this code without <script> tags. Replace %GA4_MEASUREMENT_ID% with your website tag’s G-... ID. Save and reload your website.

(function () {
const measurementId = "%GA4_MEASUREMENT_ID%";
let captureSequence = 0;

function readGa4Value(field) {
return new Promise(function (resolve) {
const timer = setTimeout(function () { resolve(null); }, 3000);
window.dataLayer = window.dataLayer || [];
const send = typeof window.gtag === "function" ? window.gtag : function () {
window.dataLayer.push(arguments);
};
send("get", measurementId, field, function (value) {
clearTimeout(timer);
resolve(value);
});
});
}

window.brightcallCaptureGa4Ids = async function () {
const sequence = ++captureSequence;
const widget = window.leadCM;
if (!widget || typeof widget.dispatchCustomEvent !== "function") return;
const visitId = widget.visit_id;
let clientId = null;
let sessionId = null;

// Your website sets this flag from its analytics consent logic.
if (window.brightcallAnalyticsAllowed === true) {
[clientId, sessionId] = await Promise.all([
readGa4Value("client_id"),
readGa4Value("session_id")
]);
}

if (window.leadCM?.visit_id !== visitId || sequence !== captureSequence) return;
const ready = window.brightcallAnalyticsAllowed === true &&
typeof clientId === "string" && /^\d+\.\d+$/.test(clientId) &&
/^[1-9]\d*$/.test(String(sessionId));

// Empty strings clear previously saved IDs when consent/IDs are unavailable.
window.leadCM.dispatchCustomEvent("CUSTOM_PARAMS", {
lc_param_ga4_client_id: ready ? clientId : "",
lc_param_ga4_session_id: ready ? String(sessionId) : ""
});
};

window.leadCM.init_callback_array.push(function () {
void window.brightcallCaptureGa4Ids();
});
})();

From your website’s consent integration, run this only when analytics is permitted and the Google tag is ready:

// Run from your website after analytics collection is permitted and the Google tag is configured.
window.brightcallAnalyticsAllowed = true;
if (typeof window.brightcallCaptureGa4Ids === "function") {
window.brightcallCaptureGa4Ids();
}

On consent withdrawal, set the flag to false and call the capture function to clear IDs. Call it again after delayed tag loading or a new GA4 session, before requesting a callback. Without both identifiers, the server example skips the event.

Step 2: Set up your server

In GA4, open Admin → Data streams → your web stream → Measurement Protocol API secrets and create a secret. Set server environment variables GA4_MEASUREMENT_ID and GA4_API_SECRET for that same stream. Keep the secret server-side.

Create an HTTPS JSON receiver and run this function in a Node.js 22+ worker. The receiver and queue are not included. Verify the source/widget, store events before acknowledging, apply consent rules, and deduplicate by widget, call ID and event name. Forward only the fields below.

export async function sendAnsweredCallToGa4(
payload,
{ validationOnly = true, debugMode = false } = {}
) {
if (payload?.type !== "end_call") return { skipped: "not_end_call" };

const minTalkSeconds = 30; // Change this threshold for your conversion definition.
const duration = payload.talk_duration_sec;
if (payload.call_status !== "answered" ||
typeof duration !== "number" || !Number.isFinite(duration) ||
duration < minTalkSeconds) {
return { skipped: "not_a_qualifying_call" };
}

const custom = payload.lead?.custom_params || {};
const clientId = custom.lc_param_ga4_client_id;
const sessionId = custom.lc_param_ga4_session_id;
if (typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId) ||
typeof sessionId !== "string" || !/^[1-9]\d*$/.test(sessionId)) {
return { skipped: "missing_ga4_identifiers" };
}

const endedMs = Date.parse(payload.time_ended_iso_string);
const now = Date.now();
if (!Number.isFinite(endedMs) || endedMs > now ||
now - endedMs > 72 * 60 * 60 * 1000) {
return { skipped: "invalid_or_too_old_call_time" };
}

const measurementId = process.env.GA4_MEASUREMENT_ID;
const apiSecret = process.env.GA4_API_SECRET;
if (!/^G-[A-Z0-9]+$/.test(measurementId || "") || !apiSecret) {
throw new Error("Set GA4_MEASUREMENT_ID and GA4_API_SECRET on the server.");
}

const params = {
session_id: sessionId,
call_status: "answered",
talk_duration_sec: duration
};
if (debugMode) params.debug_mode = 1;

// Forward only these fields, not the full Brightcall webhook.
const body = {
client_id: clientId,
timestamp_micros: endedMs * 1000,
events: [{ name: "brightcall_call_answered", params }]
};
if (validationOnly) body.validation_behavior = "ENFORCE_RECOMMENDATIONS";

const path = validationOnly ? "/debug/mp/collect" : "/mp/collect";
const url = new URL(path, "https://www.google-analytics.com");
url.searchParams.set("measurement_id", measurementId);
url.searchParams.set("api_secret", apiSecret);

const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000)
});
if (!response.ok) throw new Error("GA4 HTTP status: " + response.status);

if (validationOnly) {
const result = await response.json();
if (!Array.isArray(result.validationMessages) || result.validationMessages.length) {
throw new Error("GA4 validation failed: " + JSON.stringify(result.validationMessages));
}
return { validated: true };
}

// This confirms HTTP receipt, not inclusion in GA4 reports.
return { transportAccepted: true };
}

This example sends brightcall_call_answered for answered calls lasting at least 30 seconds. Adjust minTalkSeconds if needed. Original-session attribution requires the original session_id, delivery within 24 hours of session start, and a timestamp within that session. Delayed callbacks may not retain the original campaign.

Step 3: Connect the widget webhook

Open Integrations → API and Webhooks. Enter your receiver in API URL, enable Send a webhook in post json in new format. and save. If Use global API url for webhooks if provided... is enabled, an existing account-level URL takes precedence. To use the widget’s API URL, turn off Use global API url for webhooks if provided.... Extend an existing receiver rather than overwrite another integration. For GA4 forwarding, process end_call events only.

Check the result

Use the same test GA4 stream for the webpage, snippet and server. First run sendAnsweredCallToGa4(payload) for validation only. An empty validationMessages array confirms payload validation, not event collection. Then use { validationOnly: false, debugMode: true } and check GA4 DebugView. For production, use { validationOnly: false }. Confirm collection in GA4: HTTP success alone is insufficient.

Need help? Contact [email protected].

Did this answer your question?