Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 7 min read

CRM Integration with Deep Link Webhooks

By Tolinku Staff
|
Tolinku cross platform dashboard screenshot for engineering blog posts

Your sales team wants to know which deep link a lead clicked before signing up. Your marketing team wants to see which campaigns drive qualified leads. Your product team wants to understand how referral links map to account creation. All of this data exists in your Tolinku webhooks, but it's not useful until it lands in your CRM.

This guide covers how to connect deep link webhook events to Salesforce, HubSpot, and Pipedrive. The pattern is the same for any CRM: receive the webhook, match the event to a contact or lead, and update the record with attribution data. If you're new to webhooks, start with the webhook setup guide.

Tolinku webhook configuration for event notifications The webhooks page with create form, webhook list, and delivery log.

Not every webhook event needs to go into your CRM. A link.clicked event fires on every single deep link tap, which could be thousands per day. Your CRM doesn't need that volume, and your sales team won't look at raw click records.

The events that matter for CRM integration are:

  • install.tracked: A user installed your app through a deep link. This is a lead creation or lead enrichment event.
  • referral.created: A user signed up through a referral link. This creates a lead with referral attribution.
  • referral.completed: A referred user completed the target action (e.g., first purchase, subscription). This updates the lead's lifecycle stage.
  • deferred_link.claimed: A deferred deep link was resolved after install. This enriches the lead with the original campaign context.

Subscribe only to these events in your webhook configuration. Filtering at the source keeps your pipeline lean.

The Receiver Pattern

Every CRM integration follows the same architecture:

Tolinku Webhook → Receiver (verify signature) → Match/Create Contact → Update CRM

Here's the shared receiver code. The CRM-specific logic plugs into the processEvent function.

import express from 'express';
import crypto from 'crypto';

const app = express();
app.use('/webhooks', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; // whsec_...

app.post('/webhooks/tolinku', async (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).send('Invalid signature');
  }

  res.status(200).send('OK');

  const event = JSON.parse(req.body.toString());
  try {
    await processEvent(event);
  } catch (err) {
    console.error('CRM update failed:', err);
  }
});

See the webhook security guide for detailed signature verification patterns.

HubSpot Integration

HubSpot's API uses a straightforward REST interface for contact management.

Creating or Updating Contacts

const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN!;

async function processEvent(event: any) {
  const { event: eventType, timestamp, data } = event;

  switch (eventType) {
    case 'install.tracked':
      await upsertHubSpotContact(data, {
        lifecycle_stage: 'lead',
        first_touch_campaign: data.campaign || '',
        first_touch_platform: data.platform || '',
        install_date: timestamp,
      });
      break;

    case 'referral.created':
      await upsertHubSpotContact(data, {
        lifecycle_stage: 'lead',
        referral_source: data.referrer_token || '',
        referral_date: timestamp,
      });
      break;

    case 'referral.completed':
      await upsertHubSpotContact(data, {
        lifecycle_stage: 'customer',
        conversion_date: timestamp,
      });
      break;
  }
}

async function upsertHubSpotContact(
  data: any,
  properties: Record<string, string>
) {
  // Search for existing contact by email or create new
  const response = await fetch(
    'https://api.hubapi.com/crm/v3/objects/contacts',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
      },
      body: JSON.stringify({
        properties: {
          ...properties,
          deep_link_token: data.token || '',
          deep_link_prefix: data.prefix || '',
        },
      }),
    }
  );

  if (response.status === 409) {
    // Contact already exists; update instead
    const existing = await response.json();
    const contactId = existing.message.match(/ID: (\d+)/)?.[1];
    if (contactId) {
      await fetch(
        `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
        {
          method: 'PATCH',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
          },
          body: JSON.stringify({ properties }),
        }
      );
    }
  }
}

Custom Properties

Before this works, you need to create custom contact properties in HubSpot:

  1. Go to Settings > Properties > Contact Properties
  2. Create these custom properties:
    • first_touch_campaign (Single-line text)
    • first_touch_platform (Single-line text)
    • install_date (Date picker)
    • referral_source (Single-line text)
    • referral_date (Date picker)
    • conversion_date (Date picker)
    • deep_link_token (Single-line text)
    • deep_link_prefix (Single-line text)

    Group them under a "Deep Link Attribution" property group so your sales team can find them easily on the contact record.

    Salesforce Integration

    Salesforce's REST API requires OAuth authentication and uses a slightly different upsert pattern.

    Authentication

    Salesforce uses an OAuth 2.0 flow. For server-to-server integration, use the JWT Bearer flow or a Connected App with client credentials.

    async function getSalesforceToken(): Promise<string> {
      const response = await fetch(
        `${process.env.SF_INSTANCE_URL}/services/oauth2/token`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body: new URLSearchParams({
            grant_type: 'client_credentials',
            client_id: process.env.SF_CLIENT_ID!,
            client_secret: process.env.SF_CLIENT_SECRET!,
          }),
        }
      );
      const data = await response.json();
      return data.access_token;
    }
    

    Creating Leads

    async function processEvent(event: any) {
      const { event: eventType, timestamp, data } = event;
      const token = await getSalesforceToken();
      const baseUrl = process.env.SF_INSTANCE_URL;
    
      if (eventType === 'install.tracked' || eventType === 'referral.created') {
        await fetch(`${baseUrl}/services/data/v59.0/sobjects/Lead`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${token}`,
          },
          body: JSON.stringify({
            FirstName: 'Deep Link',
            LastName: `Lead ${data.token || 'Unknown'}`,
            Company: 'Unknown',
            LeadSource: 'Deep Link',
            Deep_Link_Campaign__c: data.campaign || '',
            Deep_Link_Platform__c: data.platform || '',
            Deep_Link_Token__c: data.token || '',
            Install_Date__c: timestamp,
            Status: 'New',
          }),
        });
      }
    
      if (eventType === 'referral.completed') {
        // Find and update the existing lead
        const search = await fetch(
          `${baseUrl}/services/data/v59.0/query?q=${encodeURIComponent(
            `SELECT Id FROM Lead WHERE Deep_Link_Token__c = '${data.token}' LIMIT 1`
          )}`,
          {
            headers: { 'Authorization': `Bearer ${token}` },
          }
        );
        const result = await search.json();
        if (result.records?.length > 0) {
          await fetch(
            `${baseUrl}/services/data/v59.0/sobjects/Lead/${result.records[0].Id}`,
            {
              method: 'PATCH',
              headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${token}`,
              },
              body: JSON.stringify({
                Status: 'Converted',
                Conversion_Date__c: timestamp,
              }),
            }
          );
        }
      }
    }
    

    Custom Fields

    Create these custom fields on the Lead object in Salesforce Setup:

    • Deep_Link_Campaign__c (Text)
    • Deep_Link_Platform__c (Text)
    • Deep_Link_Token__c (Text, External ID)
    • Install_Date__c (DateTime)
    • Conversion_Date__c (DateTime)

    Making Deep_Link_Token__c an External ID enables upsert operations, which simplifies the create-or-update pattern.

    Pipedrive Integration

    Pipedrive's API is simpler than Salesforce and works well for smaller sales teams.

    const PIPEDRIVE_TOKEN = process.env.PIPEDRIVE_API_TOKEN!;
    const PIPEDRIVE_URL = `https://${process.env.PIPEDRIVE_DOMAIN}.pipedrive.com/api/v1`;
    
    async function processEvent(event: any) {
      const { event: eventType, timestamp, data } = event;
    
      if (eventType === 'install.tracked' || eventType === 'referral.created') {
        // Create a person in Pipedrive
        const person = await fetch(`${PIPEDRIVE_URL}/persons?api_token=${PIPEDRIVE_TOKEN}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            name: `Deep Link Lead (${data.token || 'unknown'})`,
            // Custom fields (use your Pipedrive field keys)
            '1a2b3c': data.campaign || '',  // Campaign custom field
            '4d5e6f': data.platform || '',  // Platform custom field
          }),
        });
    
        const personData = await person.json();
    
        // Optionally create a deal
        await fetch(`${PIPEDRIVE_URL}/deals?api_token=${PIPEDRIVE_TOKEN}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            title: `Referral: ${data.token || 'Unknown'}`,
            person_id: personData.data.id,
            stage_id: 1, // First stage of your pipeline
          }),
        });
      }
    }
    

    Matching Webhook Events to CRM Records

    The biggest challenge in CRM integration is identity. A link.clicked or install.tracked event identifies a device and a campaign, not a person. You can't create a meaningful CRM record from an IP address and a platform string alone.

    There are three strategies for bridging this gap:

    1. Match on Referral Token

    If the deep link is a referral link, the data object includes a token that maps to a specific referral. Your app's backend knows which user created that referral, so you can look up the referrer and create a CRM record with full attribution.

    2. Match on Downstream Registration

    The most common pattern: when a user installs the app and registers, your app's backend creates the CRM record and attaches the deep link attribution data that was captured at install time. The webhook provides real-time notification that an install happened; the app backend provides the user identity.

    3. Enrichment After the Fact

    Create a "stub" CRM record from the webhook event (with campaign, platform, and token), then enrich it later when the user registers. Your app sends a second API call to the CRM with the user's email and name, matching on the deep link token.

    No-Code Alternative: Zapier

    If you don't want to build and host a receiver, Zapier can connect Tolinku webhooks to HubSpot, Salesforce, and Pipedrive without code. The tradeoff: no signature verification and limited transformation logic. For low-volume, non-sensitive integrations, Zapier is a reasonable starting point.

    Handling Retries and Duplicates

    Tolinku retries failed webhook deliveries (3 retries at 1 minute, 5 minutes, and 30 minutes). Your CRM integration must handle duplicate events gracefully.

    For HubSpot: Use the contact deduplication built into the API. Upserting by email or a custom unique field prevents duplicates.

    For Salesforce: Use External IDs on the Lead or Contact object. The upsert endpoint creates a new record or updates an existing one based on the External ID value.

    For Pipedrive: Search for existing persons by custom field before creating. Pipedrive doesn't have a native upsert operation.

    As a general practice, generate a deduplication key from the webhook payload (a hash of the event content) and check for its existence before creating records. See the webhook retry logic guide for more on idempotent processing.

    What Your Sales Team Sees

    After integration, a CRM record for a referral-driven lead might show:

    • Lead Source: Deep Link
    • Campaign: instagram-story-may
    • Platform: iOS
    • Link Token: summer-promo
    • Install Date: 2026-05-21 09:14:00
    • Referral Source: user-abc123
    • Conversion Date: 2026-05-21 09:32:00

    This gives the sales team immediate context: the lead came from an Instagram campaign, installed on iOS, and converted within 18 minutes of install. That's the kind of attribution data that changes how you follow up.

    Configure your webhooks in your Tolinku Appspace and start routing deep link attribution data to where your team already works.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.