Custom Webhook best practices
Custom webhooks let you send any event from any system into FunnelTrack. That covers CRM stage changes, backend cron jobs, checkouts on platforms without a native integration, or anywhere your business data lives. This guide covers how to design a payload that matches to the right contact, avoids duplicates, and reaches every ad platform with strong quality signals.
When to use a custom webhook
Native integrations first when we have them. Custom webhooks fill every other gap.
Check the sources list first. If we already integrate with your source (Jotform, ClickFunnels 2.0, HighLevel, Calendly, CallRail, Elementor, Gravity Forms, Typeform, Unbounce, Shopify, WooCommerce, Stripe), use that path. Native integrations handle payload shape, session stitching, and field mapping for you.
Reach for a custom webhook when:
- •Your source system isn't natively supported (many CRMs, billing systems, custom apps)
- •You need to send CRM events like opportunity stage changes, account created, deal won, or subscription upgraded
- •You're sending backend events like batch imports, offline conversion uploads, or scheduled cron
- •You want granular control over which specific event names fire and when
You don't have to code the webhook yourself. Any automation platform that can POST JSON works. In Zapier, use the Webhooks by Zapier → POST action. In Make (formerly Integromat), use the HTTP module. In n8n, use the HTTP Request node.
Point the action at your FunnelTrack webhook URL, set the payload format to JSON, and map the fields described in this guide from your trigger app (HubSpot deal, Salesforce opportunity, Airtable record, Google Sheet row, whatever). This is the fastest path for non-technical setups because your CRM already has all the identity fields ready to map.
Web vs offline
Which type your event is decides how it gets sent to ad platforms. Getting it wrong lowers match quality.
Every event is either web or offline. The difference isn't where the event was fired from. It's whether the visitor was in a browser session on your site at the moment they converted.
Web events happen in a browser session. The visitor is on your site (or a linked site you control) when the event fires. Examples:
- •On-site account creation (
account_created) - •Checkout completed in the browser
- •Form submission on a page where the pixel is loaded
Offline events happen outside a browser session. Examples:
- •CRM stage changes like
deal_wonor opportunity moved to Closed Won - •Phone-based sales, in-person transactions, POS closes
- •Batch imports from a system of record (legacy database, spreadsheet backfill)
- •Subscription rebills, refunds, lifecycle updates
You don't include a web/offline flag in the payload itself. When you create a custom webhook event in FunnelTrack, the Create Webhook dialog has an Action Source toggle where you pick Web or Offline. That choice sets the action_source FunnelTrack forwards to every destination for events sent to this URL.
If a single trigger needs to fire as both (rare), create two separate webhooks — one Web, one Offline — and have your sender POST to the appropriate URL depending on context.
Required fields on every payload
The minimum for a webhook FunnelTrack can process.
| Field | Required | What it does |
|---|---|---|
| event_name | Required | A human-readable, past-tense name that describes what happened. This shows up in the Pipeline report, usage log, and Milestone cards. Prefer account_created over signup_v2. Describe the trigger, not the downstream destination. |
| event_id | Required | A stable unique identifier used for deduplication. Use your source system's natural key: order ID, opportunity ID, subscription ID, invoice ID. If a retry sends the same payload twice with the same event_id, FunnelTrack sees the duplicate and only fires one downstream conversion. |
| event_time | Required | When the event actually happened, not when the webhook fires. Ad platforms weight recency and enforce time windows based on the click date. Sending a stale timestamp as "now" misattributes to the wrong clicks. See the Timestamp format section below. |
| user_data | Required | A nested object with the customer's identity fields. See the User data section below. At minimum, send email or phone. Without one of these, the event has no way to match to a person. |
| value | If applicable | The monetary value of the conversion, as a number. No currency symbol, no thousands separators. 4200 or 4200.00, never "$4,200.00". Include for revenue events. Skip for non-monetary events like account_created. |
| currency | If value set | ISO 4217 code such as USD, EUR, GBP, or CAD. Required whenever value is present. Without it, ad platforms reject the value or default it to their account default, which may not match yours. |
User data
More identity signals mean better match quality on every ad platform. Send raw values. FunnelTrack hashes them for you.
| Field | Required | What it does |
|---|---|---|
| Recommended | Highest match rate on every platform. Send raw (lowercase and trimmed is fine, unhashed). | |
| phone | Recommended | Second-highest match rate. E.164 format works best (+14155550198) but any format works. FunnelTrack normalizes before hashing. |
| first_name | Optional | Raw plaintext. Improves Meta match quality. |
| last_name | Optional | Raw plaintext. Improves Meta match quality. |
| city | Optional | Raw plaintext. Feeds Enhanced Conversions. |
| region | Optional | State or province code (AL, CA, ON) or full name. Either works. |
| postal_code | Optional | ZIP or postal code, raw. |
| country | Optional | ISO 3166-1 alpha-2 code (US, CA, GB) is preferred. Full names are accepted and normalized. |
| external_id | Optional | Your source system's own contact or user ID: CRM contact ID, account ID, Shopify customer ID, subscriber ID. Feeds Meta's external_id field so the ad platform can match the same person across devices even without cookies. |
Send raw plaintext identity fields over HTTPS to your FunnelTrack webhook URL. FunnelTrack strips them out, hashes with SHA-256 using the exact normalization each destination expects, and forwards only the hash downstream. If you pre-hash, we can't re-normalize, so match quality drops.
FunnelTrack never logs or stores plaintext identity data. The hashing step happens before storage.
Web events: matching to the visitor's session
How to attach the visitor's pre-conversion pageview data (landing page, click IDs, UTMs) to your webhook event.
For a web event, include the visitor's session identifiers so FunnelTrack can match the webhook to their earlier pageviews. The strongest signals are FunnelTrack's own cookies.
| Field | Required | What it does |
|---|---|---|
| web_data.ft_uid | Web only | FunnelTrack's 400-day contact identifier (cookie name _ft_uidon the customer's domain). Best match. Resolves directly to the contact. |
| web_data.ft_sid | Web only | FunnelTrack's 30-minute session identifier (cookie name _ft_sid). Resolves to the exact pre-conversion session with its landing page, referrer, and captured click IDs. |
| web_data.ip_address | Web only | Visitor IPv4 or IPv6. Used for fingerprint-based fallback matching if cookies didn't come through, and forwarded to Meta in user_data. |
| web_data.user_agent | Web only | The visitor's browser User-Agent string. Paired with ip_address for the fingerprint fallback. Forwarded to Meta. |
| web_data.page_url | Web only | The URL where the conversion happened (checkout success page, signup completion page). Feeds Meta's event_source_url. |
| web_data.page_referrer | Optional | The URL that referred the visitor to the conversion page. |
How to read _ft_uid and _ft_sid
There are three ways to grab these cookies depending on where your webhook fires from. Pick whichever matches your setup.
A. Client-side JavaScript (form embeds, on-page fetch)
If your webhook fires from browser JavaScript, read the cookies directly:
function getCookie(name) {
const m = document.cookie.match('(^|;)\\s*' + name + '=([^;]+)');
return m ? decodeURIComponent(m.pop()) : '';
}
fetch('https://your-webhook-proxy.example.com/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event_name: 'account_created',
event_id: 'USR-8472',
// ... other fields ...
web_data: {
ft_uid: getCookie('_ft_uid'),
ft_sid: getCookie('_ft_sid'),
page_url: window.location.href
}
})
});B. Server-side on the same domain (WordPress, Rails, Django, Node)
If your webhook fires from your backend on a page load (thank-you page render, post-checkout webhook fired server-side), read the cookies from the inbound request:
// PHP (WordPress, custom PHP)
$ft_uid = $_COOKIE['_ft_uid'] ?? '';
$ft_sid = $_COOKIE['_ft_sid'] ?? '';# Python (Django, Flask)
ft_uid = request.COOKIES.get('_ft_uid', '')
ft_sid = request.COOKIES.get('_ft_sid', '')// Node.js (Express, Next.js API route)
const ft_uid = req.cookies['_ft_uid'] || '';
const ft_sid = req.cookies['_ft_sid'] || '';C. URL params on cross-domain handoffs
When a visitor moves from your main site to a separate domain (for example store.example.com to checkout.example.com), cookies don't travel with them. The FunnelTrack pixel detects cross-domain links to your configured domain list and appends _ft and _ft_uid as URL parameters. On the destination page, parse them:
const url = new URL(window.location.href);
// Prefer URL params (fresh cross-domain handoff), fall back to cookies.
const ft_uid = url.searchParams.get('_ft_uid') || getCookie('_ft_uid');
// _ft param format is: <sid>.<timestamp>
const ft = url.searchParams.get('_ft');
const ft_sid = ft ? ft.split('.')[0] : getCookie('_ft_sid');Offline events: matching by identity
No browser cookies to lean on. Matching runs entirely on the identity fields you send.
For an offline event, FunnelTrack matches the incoming event to an existing contact via user_data. Email and phone do most of the work. When a match hits, the contact's prior session context (landing page, first-touch source, captured Meta and Google click IDs) gets attached to the outbound dispatch automatically. You send identity. You get attribution enrichment for free.
Optional but powerful: ft_uid if you stored it
If your source system (CRM, custom app) captured the FunnelTrack visitor's _ft_uid at lead-form time, include it in the payload when you later fire an offline conversion for that same person (deal won, contract signed, subscription upgraded):
{
"event_name": "deal_won",
"user_data": {
"email": "marcus.hayes@example.com",
"phone": "+14155550198",
"ft_uid": "b5c6d7e8-1a2b-4c3d-9e5f-6a7b8c9d0e1f.2f3e4d5c6b7a8f9e"
}
}This is the strongest offline match. Better than email or phone alone (both can miss on typos, work-vs-personal email switches, or number changes). If you're building an offline pipeline from a CRM you control, plan to capture _ft_uid at lead-form time and store it as a custom field on the contact record. Every offline conversion webhook after that carries a perfect join back to the original web session.
Originating click IDs for Google Ads Enhanced Conversions
If your source system captured click IDs at lead-form time (gclid, fbclid, wbraid, gbraid, msclkid, ttclid), send them with the offline event:
{
"event_name": "deal_won",
"user_data": { "email": "marcus.hayes@example.com" },
"click_ids": {
"gclid": "Cj0KCQjw...",
"fbclid": "IwAR3..."
}
}Google Ads Enhanced Conversions for Leads matches offline uploads to clicks primarily via gclid. If you don't send the originating click ID, Google falls back to identity-hash matching, which has a lower match rate.
- Meta CAPI: offline conversion match window is about 62 days from the original click
- Google Ads: Enhanced Conversions for Leads accepts uploads within about 90 days of the click
- TikTok Events API: about 28 days
Always send the real event_timefor when the conversion actually happened, not when the webhook fires. Batch-uploading last week's deals timestamped as "now" puts them outside the correct attribution windows.
Timestamp format
Send event_time as a Unix timestamp in seconds (integer, UTC). Every language has a one-line function for this.
{
"event_time": 1785276396
}Webhook format
Protocol basics your sender must respect.
- •HTTP method:
POSTonly. GET, PUT, PATCH, DELETE are rejected. - •Content-Type:
application/jsonis recommended. We also acceptapplication/x-www-form-urlencodedfor legacy senders. Bracket-notation nested keys (user_data[email]) get flattened server-side. - •Encoding: UTF-8. Non-UTF-8 encodings will be rejected.
- •HTTPS only.Every FunnelTrack webhook URL is HTTPS. Don't downgrade to HTTP.
- •Payload size:under 100 KB is ideal. 1 MB is the hard cap. Don't send base64-encoded receipts, images, or full call transcripts.
- •Response: we respond with
200 OKon accepted,400on malformed,401on bad API key,413on oversized payload, and429on rate-limited. Retry on429and5xxonly. - •Retry policy: if you retry, use the same
event_id. FunnelTrack sees the duplicate and won't create a second conversion.
Custom parameters
Any extra key-value pairs you want forwarded to destination event params.
Anything under a top-level custom_paramsobject becomes available for field mapping to destination event parameters (Meta's custom_data, GA4 event params, TikTok properties). Use it for:
- •Lead scores, deal stages, subscription tiers, or any other attribute the ad platform can bid on
- •Internal categorization your team uses (sales rep, source campaign, funnel step)
- •Product or plan identifiers for retargeting audience membership
{
"custom_params": {
"plan_tier": "pro",
"lead_score": 87,
"sales_rep": "j_ortega",
"referral_code": "PARTNER-24"
}
}Filter conditions
Fire only when the payload matches criteria you set.
Configure filter conditions on the event card so the webhook only creates events when the payload matches. Common patterns:
- •Stage filter:
stage == "Closed Won". Only fire on wins, not every stage change. - •Value floor:
value > 500. Only fire for meaningful conversions. - •Type filter:
product_type != "internal_test". Exclude non-customer transactions.
Filters run before dedup, dispatch, and usage counting. Filtered events show up in the usage log as filtered (not failed or received) so you can see which criteria are firing.
Testing checklist
Before flipping a new webhook live.
- Send a test payload. Use
curl, Postman, or your source system's test fire. Verify a200 OKresponse. - Check the inbound row in Usage Log. Confirm the payload arrived and shows the correct event name.
- Verify field mapping. Click into the log row. Confirm identity fields, revenue, and event parameters are populated the way you expected.
- Enable destinations one at a time. Flip Meta on first. Send another test. Verify it appears in Events Manager Test Events. Then GA4. Then Google Ads. Never enable all at once for a new event.
- Confirm dedup works. Send the exact same payload twice. The second call should show as
dedupedin the usage log, not counted as a second conversion. - Confirm filters work.Send a payload that shouldn't match your filter conditions. Verify it lands as
filteredand doesn't reach any destination.
Security
Your webhook URL is a credential.
- •Treat the webhook URL as a secret.It contains an API key that authenticates every submission. Don't paste it in public docs, screenshots, or shared spreadsheets.
- •HTTPS only.Every FunnelTrack webhook URL is HTTPS. Don't downgrade to HTTP.
- •Rotate if leaked. Regenerate the endpoint from the Webhooks settings page. The old URL immediately stops accepting submissions.
- •Filter as soft auth.If you're worried about spurious submissions, add a filter like
api_source == "our_crm"and only accept payloads from your intended sender.
Full example: web event (account_created)
A user creates an account on your site. Fired from the browser after the signup form succeeds.
{
"event_name": "account_created",
"event_id": "USR-8472",
"event_time": 1785276396,
"user_data": {
"email": "sarah.chen@example.com",
"phone": "+12065550142",
"first_name": "Sarah",
"last_name": "Chen",
"city": "Portland",
"region": "OR",
"postal_code": "97205",
"country": "US",
"external_id": "CRM-77321"
},
"web_data": {
"ft_uid": "a3f2b8c1-9d4e-4f56-8a7b-1c2d3e4f5060.7e8f9a0b1c2d3e4f",
"ft_sid": "4c8b1a2f-5d6e-4f7a-8b9c-0d1e2f3a4b5c",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
"page_url": "https://example.com/signup/confirm"
},
"custom_params": {
"plan_tier": "pro",
"signup_source": "google_ads"
}
}Full example: offline event (deal_won)
A sales rep marks an opportunity as Closed Won in the CRM. Fired from the CRM's webhook automation or a Zap.
{
"event_name": "deal_won",
"event_id": "OPP-38472",
"event_time": 1785276396,
"value": 4200.00,
"currency": "USD",
"user_data": {
"email": "marcus.hayes@example.com",
"phone": "+14155550198",
"first_name": "Marcus",
"last_name": "Hayes",
"city": "Austin",
"region": "TX",
"postal_code": "78701",
"country": "US",
"external_id": "CRM-42196",
"ft_uid": "b5c6d7e8-1a2b-4c3d-9e5f-6a7b8c9d0e1f.2f3e4d5c6b7a8f9e"
},
"click_ids": {
"gclid": "Cj0KCQjw...",
"fbclid": "IwAR3..."
},
"custom_params": {
"opportunity_stage": "Closed Won",
"sales_rep": "j_ortega",
"deal_source": "inbound_lead"
}
}_ft_uidwhen the prospect originally filled out the lead form (weeks or months ago), stored it as a custom field on the contact, and now sends it back on the offline conversion. That's the strongest offline join possible. The deal_won event connects directly to the original web contact and inherits their full attribution history.Ready to set up your first custom webhook?
Open the Webhooks section of your FunnelTrack workspace to create an endpoint, then follow this guide to design your payload.
Configure your Custom Webhooks →Need a hand?
If anything in this guide didn't match what you're seeing in your source system or FunnelTrack, open a support ticket in your dashboard and we'll get you sorted.