---
title: "Logging 150M+ link clicks: how Dub built webhook event logs"
excerpt: "Dub webhooks event logs show exactly what happened and when. Debug integrations faster with complete event visibility."
authors: "Alasdair Brown"
categories: "I Built This!"
createdOn: "2025-01-23 00:00:00"
publishedOn: "2025-01-23 00:00:00"
updatedOn: "2025-04-24 00:00:00"
status: "published"
---

<p><a href="https://dub.co"><u>Dub</u></a> is an open source link management and conversion tracking platform. Dub’s real-time analytics for link tracking are <a href="https://www.tinybird.co/case-studies/dub"><u>built with Tinybird</u></a>, and Dub’s new <a href="https://dub.co/blog/introducing-webhooks"><u>webhooks feature</u></a> also uses Tinybird behind the scenes of the Event Logs view.&nbsp;</p><p>Dub recently celebrated its 1-year anniversary, and Steven Tey (Dub founder &amp; CEO) shared a view of the scale of data they are working with, and which is handled by Tinybird:</p><figure class="kg-card kg-embed-card"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Today marks exactly 1 year since <a href="https://twitter.com/dubdotco?ref_src=twsrc%5Etfw">@dubdotco</a> started as a company.<br><br>Since then, we've reached 900+ paying customers, 1M+ links created, and 150M+ clicks tracked.<br><br>To celebrate that, we're launching a new About page to showcase our company vision, values, team, and investors 👇 <a href="https://t.co/6WVvEb77nk">pic.twitter.com/6WVvEb77nk</a></p>— Steven Tey (@steventey) <a href="https://twitter.com/steventey/status/1879934704675344552?ref_src=twsrc%5Etfw">January 16, 2025</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></figure><p>Dub provides an incredible cloud service (we're customers 😄), but the product is also <a href="https://github.com/dubinc/dub"><u>fully open source</u></a>.</p><p>So, let’s tour the code and see how Dub built its Event Logs view with Tinybird.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXdSSRgyDgVesCjn1P1qziog2Cyg7SE2lQfGlL_yDO9TIsJyW96h2R3LU_CG4CHi-kotpjdKCcAOKrN08GfLcNFtUctJQ5q3qZ4BOB9cIKwN1CsPEzgMIDZSIcVOrTb6wMltNHhMTw?key=M3j20dbiF1aJhNbGCJ5CWxWL" class="kg-image" alt="" loading="lazy" width="1004" height="392"><figcaption><span style="white-space: pre-wrap;">The Dub webhook Event Logs</span></figcaption></figure><h2 id="storing-data">Storing data</h2><p>Logs for Dub’s Webhooks are stored in a <a href="https://github.com/dubinc/dub/blob/main/packages/tinybird/datasources/dub_webhook_events.datasource"><u>Tinybird data source called </u><code>dub_webhook_events</code></a>.&nbsp;</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAKLAgAAAAAAAABBKUqGk9nLKvqs8EDTB0I4uLNHrVZrDsBj8PFTPYDiaxwGYEh-t1sxHCiDv59aNXXGuh2pi8vVUgMWm7YTvJ8Br-CDyYBOHTUXmL0tzI9lEY-oXlsGrvWSM5PmkKPnlXy2RetAVwaQT12MLc3jU1-MFn8iDhsj8FRxPRqKL_oLG1jrSFvKL4soLvHMjujaGnjx3qfPniXoa6ZvC8MvejpZInKTOTbkcfiYh_kcnUb5DZ59iS5WVBY8idQ9TWEVeG8-Vdos0OsyC2SWqMaH5zEd81ZLt5WnVpA4hjxdvct7oGxmsc3pxJHS03A5UDQ2y0svnine5vrAmszapmPkW76Ju1nJ2GTCuxFbPIe0PcBg7p2pCbqiw6GXCh-UR8Tdi9ob6Rq9kUxcIQddWtIZkpGQxNZDp9cAVrisO__EC9kc/embed"></iframe>
<!--kg-card-end: html-->
<p>The main webhook payload is stored as JSON in a String column called <code>event</code>. Other fields like <code>http_status</code> and <code>webhook_id</code> are kept as top-level columns to allow for efficient filtering.</p><p>The sorting key is <code>webhook_id,timestamp</code>. This means that, when webhooks are stored, they are sorted by these attributes in order, first by <code>webhook_id</code>, then <code>timestamp</code>. </p><p>Coupled with the <code>MergeTree</code>  table engine, this means that we have a time-ordered append log of events per webhook. The <code>webhook_id</code> groups individual events by the webhook configuration to which they belong, so when you <strong>Create webhook</strong> in the Dub UI, any events delivered by that webhook will have the same <code>webhook_id</code> and be stored sequentially. </p><p>In particular, this <code>webhook_id</code> is a component of the sorting key, and we’ll see why this is a smart design choice further down when we look at how data is read.</p><p>Dub uses the <a href="https://github.com/chronark/zod-bird"><u><code>zod-bird</code></u></a> client to interact with Tinybird’s APIs. The neatly succinct <a href="https://github.com/dubinc/dub/blob/main/apps/web/lib/tinybird/record-webhook-event.ts"><u><code>recordWebhookEvent</code></u></a> function wraps the <code>zod-bird</code> call to <a href="https://www.tinybird.co/docs/get-data-in/ingest-apis/events-api"><u>Tinybird’s Events API</u></a>, which pushes JSONL formatted payloads to Tinybird via a HTTP <code>POST</code> request. The <code>recordWebhookEvent</code> function takes two inputs, the name of the data source described above (<code>dub_webhooks_events</code>) and the <code>event</code> payload. </p><p>Behind the scenes, this is making a <code>POST</code> request to Tinybird to a URL like <code>https://api.tinybird.co/v0/events?name=dub_webhook_events</code>, with the event in the request payload. When Tinybird receives that <code>POST</code> request, the event payload is written into the named data source.</p><h2 id="reading-data">Reading data</h2><p>Reading the data happens in a few parts: exposing the data in Tinybird, fetching data in Dub, and displaying data to the user in the app.</p><p>To expose webhook log data in Tinybird, Dub uses a <a href="https://github.com/dubinc/dub/blob/main/packages/tinybird/pipes/get_webhook_events.pipe"><u>Tinybird pipe called <code>get_webhook_events</code></u></a>. A pipe is an SQL query that is published as a REST API, which executes the query when the API is called. </p><p>This particular pipe is quite straightforward, as Dub is exposing an ordered event log view to their user, so there’s no complex logic needed. The query selects all fields using a <code>SELECT *</code>, orders by <code>timestamp</code> and limits the returned rows to 100.</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAI7AQAAAAAAAABBKUqGk9nLKvqTkrWkRr5z8rTrpTfij3vzVVXn_8sNLms8oXJxyDEhD4qzy7GwBBy9yGcfSunPel3ba1StL782xSVCSBCfZGyCYzQLpiDs7V4cMDzvBY9_5hIVEjTIoMiVWl_lJCczxceTz2_ftmCpU58HX8V9Jasf5DiwTl2U9IQyjEm-Bo2saGsqW91mKyK09kSS3Mqln2QIlIuNOW0yliVMTyPRAGVKZjmd0780pe6atryrQU8yKPwZ7L1ErfirCCRHtAqmUWiS80pSU9KQPvO2qBW1tMII__0X_XnhoY_AGZp7K8zmTinw0lit_ZDNL4XSCOURiFS3UBEf_8f4wAA/embed"></iframe>
<!--kg-card-end: html-->
<p>The <code>WHERE</code> clause filters the response to a given <code>webhook_id</code>, so that all events returned belong to a single webhook. </p><p>The <code>WHERE</code> clause also uses Tinybird’s templating syntax to accept a dynamic parameter <code>webhookId</code>. When this Pipe is called, an HTTP GET request is made to a URL like <code>https://api.tinybird.co/v0/pipes/get_webhook_events.json</code> and the <code>webhookId</code> can be appended to the URL as a search param like <code>?webhookId=123</code>. That <code>123</code> value is passed through to the SQL query when the request is made to create a dynamic filter.</p><p>Above, I mentioned that including the <code>webhook_id</code> in the sorting key was a smart design choice, and it is because of the filter in this pipe. When you execute this pipe, you only want to receive the data for the given <code>webhook_id</code>. And, if you want the response to be fast, you don’t want the database to waste time reading a bunch of data for a different <code>webhook_id</code> that you don’t care about. The sorting key lets you control how data is stored when it is <em>written</em> so that you can optimize for how it is <em>read</em>. Because this pipe only wants to get data for one <code>webhook_id</code>, it makes sense to write all events for one <code>webhook_id</code> together so that you can very quickly scan through it.</p><p>To fetch the data, Dub defines a function <a href="https://github.com/dubinc/dub/blob/main/apps/web/lib/tinybird/get-webhook-events.ts"><code>getWebhookEvents</code><u>‎</u></a>.&nbsp;</p>
<!--kg-card-begin: html-->
<iframe width="100%" src="https://snippets.tinybird.co/XQAAAAIHAQAAAAAAAABBKUqGk9nLKveDjFmdZranhMCE_g72rrdMWAajTTpZRTTmKAJPyHEkf50mkzRCyUtIF07r-_RqCB9V3V4cprV-2EYuzYN9lx_-3NpLn0-LsxfWUuZdCcG4Zk5khEqnazzjEN5pMPYLzVePcjLq7rCoDnWKQAPXIJP0YYIjPOZ6XV8xSGM-9aQqllya-kjkv49aDxv0TQD34xC4EUv3WLoCrbV1qcckPfAh_aEp0axjQIR6ed1O7eif3ENsgfhoqsn7rk7v_40coAA/embed"></iframe>
<!--kg-card-end: html-->
<p>Again, this is a wrapper over the <code>zod-bird</code> client’s call to a Tinybird pipe (<code>buildPipe</code>). This function takes the name of the pipe (<code>get_webhook_events</code>) and the <code>webhookId</code> parameter. </p><p>You might have guessed from the name, but the goal of <code>zod-bird</code> is to provide type safety for Tinybird pipes using <a href="https://zod.dev/"><u><code>zod</code></u></a>. Because of that, the final parameter to the <code>getWebhookEvents</code> function is a <code>zod</code> schema <a href="https://github.com/dubinc/dub/blob/eb0d2944116d48ddbd2ceb70896eff2b9d08643e/apps/web/lib/zod/schemas/webhooks.ts#L37"><u><code>webhooksEventSchemaTB</code></u></a> to validate the response from the Tinybird pipe. The result of this call is a type-safe object that resembles the same structure as the data source in which the events are stored.</p><p>Finally, the log of events is displayed to the user in the Dub UI. The structure of the Webhook details page is defined in <a href="https://github.com/dubinc/dub/blob/main/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/settings/webhooks/%5BwebhookId%5D/page-client.tsx"><u><code>dub/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/webhooks/[webhookId]/page-client.tsx</code></u></a>, which calls the API to retrieve the events and passes them to the&nbsp; <a href="https://github.com/dubinc/dub/blob/main/apps/web/ui/webhooks/webhook-events.tsx"><u><code>WebhookEventsList</code></u></a> component. </p><p>The <code>WebhookEventsList</code> component maps each event into individual <code>WebhookEvent</code> components, which handle displaying the icon, HTTP response, event type, and timestamp in the list view of the Event Logs screen.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXexNbZT__tHjiQGBDZWkTJ62p4dQRnh5gaeSaGs_kb_93HQqPVhRppDymlhMwjjNbEHj5aZJIt0XFtWJwKc4y4D00r2FXsCI4jUAM-nT5RP0RhCaUerAeNfpIp7AxBllWOmeEbLCA?key=M3j20dbiF1aJhNbGCJ5CWxWL" class="kg-image" alt="" loading="lazy" width="1004" height="392"><figcaption><span style="white-space: pre-wrap;">The UI, which renders data from a Tinybird API</span></figcaption></figure><h2 id="connect-dub-webhooks-to-tinybird">Connect Dub webhooks to Tinybird</h2><p>Dub webhooks can serve multiple integration use cases, giving you the power to create custom workflows, trigger automated functions, or build your own analytics layers.</p><p>By <a href="https://www.tinybird.co/docs/get-data-in/guides/ingest-from-dub" rel="noreferrer"><u>sending your Dub webhooks to Tinybird</u></a>, you can connect your Dub analytics to the rest of your dev tools data stack.</p>
