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

Webhook Setup Guide: Receiving Deep Link Events

By Tolinku Staff
|
Tolinku webhooks integrations dashboard screenshot for engineering blog posts

Webhooks let your server receive real-time notifications when something happens with your deep links. Instead of polling an API to check for new clicks, installs, or conversions, the deep linking platform pushes that data to you the moment it occurs.

This guide walks through setting up a webhook endpoint from scratch, subscribing to events in Tolinku, handling payloads, and testing everything locally before going to production.

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

What Webhooks Are (and Why They Matter for Deep Linking)

A webhook is an HTTP POST request that a server sends to your endpoint when an event occurs. Think of it as a reverse API call: instead of your app asking "did anything happen?", the platform tells you "this just happened."

For deep linking, webhooks solve a critical data flow problem. Deep link clicks happen on a server you don't control. A user taps a link, gets redirected through the deep linking service, and lands in your app. Without webhooks, your backend has no idea this happened until the app itself reports it. With webhooks, your server is notified the instant the link is clicked, before the app even opens.

Common deep link webhook events include:

  • link.clicked: A user clicked a deep link
  • deferred_link.claimed: The app claimed a deferred deep link after install
  • install.tracked: The SDK reported a new app install
  • referral.created: A new referral link was generated
  • referral.completed: A referred user reached the reward milestone

These events let you trigger downstream actions: update a CRM record, fire a Slack notification, log to your analytics warehouse, or kick off an onboarding workflow. For a deeper look at how webhooks fit into the broader integration picture, see Webhooks and Integrations for Deep Linking.

Step 1: Create a Webhook Endpoint

Your webhook endpoint is just a regular HTTP route that accepts POST requests and returns a 200 status code. Here is a minimal example using Express and Node.js:

const express = require('express');
const app = express();

// Important: use raw body parsing for signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));

app.post('/webhooks/tolinku', (req, res) => {
  const payload = JSON.parse(req.body);

  console.log('Received webhook event:', payload.event);
  console.log('Event data:', JSON.stringify(payload.data, null, 2));

  // Always respond with 200 quickly
  res.status(200).json({ received: true });
});

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

A few things to note here:

  1. Use express.raw() instead of express.json() for the webhook route. You need the raw request body to verify the webhook signature later. Parsing JSON from the raw buffer is trivial; reconstructing the exact raw body from a parsed object is not.

    Respond with 200 immediately. The deep linking platform expects a quick response. If your endpoint takes too long (typically over 5 seconds), the delivery is marked as failed and the platform will retry. Do your heavy processing asynchronously after sending the response.

    Use a dedicated path. Separating your webhook route (e.g., /webhooks/tolinku) from your regular API routes makes it easier to apply different middleware, logging, and security rules.

    Step 2: Subscribe to Events

    Once your endpoint is deployed and publicly accessible, register it with Tolinku. You can do this through the Tolinku dashboard or the API.

    In the dashboard, navigate to your Appspace, open the Webhooks section, and fill in the create form:

    • Name: A human-readable label (e.g., "Production event handler")
    • URL: Your publicly accessible endpoint URL (e.g., https://api.yourapp.com/webhooks/tolinku)
    • Events: Select which event types you want to receive

    To create a webhook via the API, send a POST request to the webhooks endpoint:

    const response = await fetch('https://api.tolinku.com/v1/webhooks', {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer tolk_sec_your_secret_key',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: 'Production event handler',
        url: 'https://api.yourapp.com/webhooks/tolinku',
        events: ['link.clicked', 'install.tracked', 'referral.completed'],
      }),
    });
    
    const webhook = await response.json();
    console.log('Webhook created:', webhook.id);
    console.log('Signing secret:', webhook.signingSecret);
    

    Save the signing secret. You will need it to verify that incoming requests actually come from Tolinku and not from a malicious third party. Store it as an environment variable, never in your source code.

    Step 3: Handle Webhook Payloads

    Every webhook delivery follows the same envelope structure. Here is what a typical link.clicked event payload looks like:

    {
      "event": "link.clicked",
      "timestamp": "2026-05-18T14:32:00.000Z",
      "data": {
        "prefix": "promo",
        "token": "summer-sale",
        "hostname": "links.yourapp.com",
        "ip": "203.0.113.42",
        "platform": "ios",
        "device_type": "mobile",
        "campaign": "summer2026"
      }
    }
    

    A production-ready handler should route events to different processing functions:

    app.post('/webhooks/tolinku', (req, res) => {
      const payload = JSON.parse(req.body);
    
      // Respond immediately
      res.status(200).json({ received: true });
    
      // Process asynchronously
      processWebhookEvent(payload).catch((err) => {
        console.error('Webhook processing failed:', err);
      });
    });
    
    async function processWebhookEvent(payload) {
      switch (payload.event) {
        case 'link.clicked':
          await handleLinkClick(payload.data);
          break;
        case 'install.tracked':
          await handleInstall(payload.data);
          break;
        case 'referral.completed':
          await handleReferralCompleted(payload.data);
          break;
        default:
          console.log('Unhandled event type:', payload.event);
      }
    }
    
    async function handleLinkClick(data) {
      // Log to your analytics warehouse
      await analyticsDB.insert({
        event: 'deep_link_click',
        prefix: data.prefix,
        token: data.token,
        platform: data.platform,
        campaign: data.campaign,
        timestamp: new Date(),
      });
    }
    
    async function handleInstall(data) {
      // Notify the sales team about a new install
      await slack.send('#new-installs', {
        text: `New install on ${data.platform} from campaign: ${data.campaign}`,
      });
    }
    
    async function handleReferralCompleted(data) {
      // Issue reward to the referrer
      await rewardService.issueReward(data.referrer_id, {
        referral_code: data.referral_code,
        referred_user: data.referred_user_id,
      });
    }
    

    For a full list of available event types and their payload schemas, see the Webhook Event Types documentation.

    Step 4: Verify Webhook Signatures

    Never trust an incoming webhook request without verifying its signature. Tolinku signs every delivery using HMAC-SHA256 with the signing secret you received when creating the webhook. The signature is sent in the X-Webhook-Signature header.

    const crypto = require('crypto');
    
    function verifyWebhookSignature(rawBody, signature, secret) {
      const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');
    
      // Use timingSafeEqual to prevent timing attacks
      const sig = Buffer.from(signature, 'hex');
      const exp = Buffer.from(expected, 'hex');
    
      if (sig.length !== exp.length) return false;
      return crypto.timingSafeEqual(sig, exp);
    }
    
    app.post('/webhooks/tolinku', (req, res) => {
      const signature = req.headers['x-webhook-signature'];
      const secret = process.env.TOLINKU_WEBHOOK_SECRET;
    
      if (!verifyWebhookSignature(req.body, signature, secret)) {
        console.warn('Invalid webhook signature');
        return res.status(401).json({ error: 'Invalid signature' });
      }
    
      const payload = JSON.parse(req.body);
      res.status(200).json({ received: true });
    
      processWebhookEvent(payload).catch(console.error);
    });
    

    This is why Step 1 used express.raw() instead of express.json(). You need the original bytes of the request body to compute the HMAC. If Express parses the JSON first, you lose the exact byte sequence and the signature will never match.

    For more on webhook security best practices, including replay attack prevention, see Webhook Signing and Verification.

    Step 5: Test Webhooks Locally

    You can not receive webhooks on localhost because Tolinku's servers cannot reach your local machine. There are two approaches for local testing.

    Using a Tunnel

    Tools like ngrok or Cloudflare Tunnel expose your local server to the internet:

    # Start your local server
    node server.js
    
    # In another terminal, start the tunnel
    ngrok http 3000
    

    ngrok gives you a public URL like https://abc123.ngrok.io. Use that as your webhook URL in the Tolinku dashboard. Every request that hits the ngrok URL gets forwarded to your local server.

    Using the Tolinku Test Delivery

    The Tolinku dashboard includes a test delivery feature that sends a sample event to your endpoint. This is useful for verifying that your endpoint is reachable and that signature verification works correctly. See the Webhook Testing and Deliveries guide for details.

    Common Webhook Patterns

    Idempotency

    Webhook deliveries can be retried if your endpoint is temporarily unavailable or returns a non-2xx status code. This means you may receive the same event more than once. Your handler must be idempotent: processing the same event twice should produce the same result as processing it once.

    The simplest approach is to track event IDs you have already processed:

    const crypto = require('crypto');
    const processedHashes = new Set(); // Use Redis or a database in production
    
    async function processWebhookEvent(payload) {
      // Hash the payload for deduplication (the envelope does not include a unique ID)
      const hash = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
    
      if (processedHashes.has(hash)) {
        console.log('Duplicate event, skipping');
        return;
      }
    
      processedHashes.add(hash);
    
      switch (payload.event) {
        case 'link.clicked':
          await handleLinkClick(payload.data);
          break;
        // ... other handlers
      }
    }
    

    In production, replace the in-memory Set with Redis using SETNX and a TTL. A 24-hour TTL is usually sufficient since the retry window (1 minute, 5 minutes, 30 minutes) is much shorter than that.

    Error Handling and Retries

    If your webhook handler throws an error, you still want to return 200 to the deep linking platform (so it does not keep retrying), but you should log the failure and queue the event for reprocessing:

    app.post('/webhooks/tolinku', (req, res) => {
      // Verify signature first (omitted for brevity)
      const payload = JSON.parse(req.body);
    
      // Always return 200
      res.status(200).json({ received: true });
    
      processWebhookEvent(payload).catch(async (err) => {
        console.error('Processing failed for event:', payload.event, err);
    
        // Queue for retry using your preferred job queue
        await retryQueue.add('webhook-retry', {
          payload,
          attempt: 1,
          maxAttempts: 3,
        });
      });
    });
    

    This pattern gives you control over your own retry logic. You can use BullMQ, a database-backed job queue, or any other mechanism that fits your stack.

    Filtering Events Server-Side

    Even if you subscribe to multiple event types, you may want to ignore certain events based on their data. For example, only processing clicks from a specific campaign:

    async function handleLinkClick(data) {
      // Only process clicks from paid campaigns
      if (!data.params?.campaign?.startsWith('paid_')) {
        return;
      }
    
      await analyticsDB.insert({
        event: 'paid_campaign_click',
        campaign: data.params.campaign,
        platform: data.platform,
        country: data.country,
        timestamp: new Date(),
      });
    }
    

    Monitoring Webhook Deliveries

    Once your webhooks are live, monitor their health through the Tolinku dashboard. The webhooks list shows each endpoint's delivery status, making it easy to spot failures.

    Watch for these warning signs:

    • Repeated failures: Your endpoint may be down or returning errors. Check your server logs.
    • High latency: If your endpoint takes too long to respond, consider offloading processing to a background queue.
    • Signature mismatches: Usually means your signing secret is wrong or your body parsing middleware is modifying the raw request body before verification.

    Putting It All Together

    Here is a complete, production-ready webhook server that combines all the patterns from this guide:

    const express = require('express');
    const crypto = require('crypto');
    const app = express();
    
    app.use('/webhooks', express.raw({ type: 'application/json' }));
    
    function verifySignature(rawBody, signature, secret) {
      const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');
      const sig = Buffer.from(signature, 'hex');
      const exp = Buffer.from(expected, 'hex');
      if (sig.length !== exp.length) return false;
      return crypto.timingSafeEqual(sig, exp);
    }
    
    app.post('/webhooks/tolinku', (req, res) => {
      const signature = req.headers['x-webhook-signature'];
      const secret = process.env.TOLINKU_WEBHOOK_SECRET;
    
      if (!verifySignature(req.body, signature, secret)) {
        return res.status(401).json({ error: 'Invalid signature' });
      }
    
      const payload = JSON.parse(req.body);
      res.status(200).json({ received: true });
    
      processEvent(payload).catch((err) => {
        console.error('Failed to process event:', payload.id, err);
      });
    });
    
    const processed = new Set();
    
    async function processEvent(payload) {
      if (processed.has(payload.id)) return;
      processed.add(payload.id);
    
      switch (payload.event) {
        case 'link.clicked':
          console.log('Click on', payload.data.prefix + '/' + payload.data.token, 'from', payload.data.platform);
          break;
        case 'install.tracked':
          console.log('Install on', payload.data.platform, 'campaign:', payload.data.campaign);
          break;
        case 'referral.completed':
          console.log('Referral', payload.data.referral_code, 'completed');
          break;
        default:
          console.log('Unknown event:', payload.event);
      }
    }
    
    app.listen(process.env.PORT || 3000);
    

    Next Steps

    With your webhook endpoint running, you can build on this foundation:

    Webhooks turn your deep links from one-way redirects into a two-way data channel. Every click, install, and conversion becomes an actionable event in your backend, and you can respond to it in real time.

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.