Speed-to-lead is one of the best-documented levers in B2B sales. The most cited research is the 2007 Lead Response Management study by Dr. James Oldroyd (then at MIT) and InsideSales.com. It found that contacting a web lead within five minutes, rather than 30, made a rep about 100 times more likely to reach the lead and about 21 times more likely to qualify them. The study covered more than 15,000 leads and over 100,000 call attempts. These multipliers come from the MIT/InsideSales research, not from Harvard Business Review, though they are often credited to HBR.

Most companies respond far more slowly than that. InsideSales.com reports that across a dozen-plus audit studies since 2007, covering more than 16,000 companies, the average response time was 42 hours and 43 minutes.

A common cause is plumbing. A form submission can pass through a CRM, a lead-scoring job and a batch sync before a human sees it. A leaner approach is to send the form’s webhook straight to a small serverless endpoint that posts an interactive Slack claim card to your sales channel. The first rep to click “Claim” owns the lead.

This guide covers that pipeline end to end:

  • normalizing payloads from different form tools
  • verifying signatures
  • acknowledging webhooks quickly and doing the work in the background
  • building the Block Kit card
  • preventing double-claims with an atomic lock
  • hardening the endpoints

The code is TypeScript for Next.js (App Router) and uses only Web Standard APIs, so it runs on either the Node.js or Edge runtime.

> A note on latency numbers. This guide doesn’t promise a specific end-to-end figure such as “under 600 ms.” Real latency depends on your region, cold starts, Slack’s API and any enrichment calls. Instrument your own pipeline (see the final section) and publish your own numbers.


Architecture Overview

```

Form tool (Tally, Typeform, custom)

│ HTTP POST (JSON, optionally signed)

▼

Webhook endpoint ──► verify signature ──► dedupe (Redis) ──► normalize

│ │

│ respond 2xx immediately ▼

└──────────────────────────────────────────► background task:

post Block Kit card to Slack

│

Slack button click ──► Interactivity endpoint ──► verify Slack signature

│

▼

atomic lock (Redis SET NX) ──► update card

```

Two time limits shape the design:

  • Tally expects a 2xx response within 10 seconds. If it doesn’t get one, it retries after 5 minutes, 30 minutes, 1 hour, 6 hours and 1 day.
  • Slack expects acknowledgment of interactive payloads (button clicks) within 3 seconds. Slower handlers show the user a timeout error.

So both endpoints must acknowledge quickly and defer slower work. Tally’s retries also mean you need to handle duplicate deliveries.

ConcernHeavy automation chainWebhook-to-Slack pipeline
Data shapeMapped CRM fieldsRaw JSON you control
RoutingRound-robin or static queueShared channel, first click wins
ConcurrencyManual reassignmentAtomic Redis lock
CustomizationVendor workflow builderCode you own

1. Payload Normalization Across Form Engines

Form tools send different JSON shapes, so convert each into one internal `StandardLead` type before any other code touches it.

Tally’s payload

Tally webhooks have a single event type, `FORM_RESPONSE`, with answers in a `data.fields` array. Each field has a `key`, `label`, `type` and `value`. Two details matter for parsing:

  • For choice fields (`MULTIPLE_CHOICE`, `DROPDOWN`, `CHECKBOXES` and similar), `value` is an array of option IDs. The human-readable text is in a sibling `options` array of `{ id, text }` objects.
  • Hidden fields arrive with type `HIDDEN_FIELDS`, and the `label` is the parameter name. That is how UTM values such as `utm_campaign` reach you.

```

{

"eventId": "a4cb511e-d513-4fa5-baee-b815d718dfd1",

"eventType": "FORM_RESPONSE",

"createdAt": "2026-03-31T14:22:05.112Z",

"data": {

"responseId": "2wgx4n",

"submissionId": "2wgx4n",

"formId": "VwbNEw",

"formName": "Enterprise Inbound Contact",

"fields": [

{ "key": "question_a", "label": "utm_source", "type": "HIDDEN_FIELDS", "value": "google_search" },

{ "key": "question_b", "label": "Full name", "type": "INPUT_TEXT", "value": "Jane Doe" },

{ "key": "question_c", "label": "Work email", "type": "INPUT_EMAIL", "value": "jane.doe@acmecorp.io" },

{

"key": "question_d",

"label": "Company size",

"type": "MULTIPLE_CHOICE",

"value": ["id-2"],

"options": [

{ "id": "id-1", "text": "1-249" },

{ "id": "id-2", "text": "250-1000" }

]

}

]

}

}

```

A custom form backend

If you run your own form (for example a React form posting to an API route), you choose the schema. This is an illustrative shape, not any vendor’s format:

```

{

"submission_id": "sub_44109823",

"timestamp": 1774966925,

"payload": {

"full_name": "Jane Doe",

"work_email": "jane.doe@acmecorp.io",

"employee_range": "250-1000",

"notes": "Looking to replace our current CRM setup."

},

"context": {

"utm_source": "google_search",

"utm_campaign": "q1_b2b_intent"

}

}

```

The normalizer

```

// lib/normalizer.ts

export interface StandardLead {

id: string;

source: 'tally' | 'custom';

fullName: string;

email: string;

companySize: string;

notes?: string;

utmSource?: string;

timestamp: string;

}

type TallyOption = { id: string; text: string };

type TallyField = {

key: string;

label: string;

type: string;

value: unknown;

options?: TallyOption[];

};

// Turn a Tally field into a display string, resolving option IDs to text.

function fieldText(field?: TallyField): string {

if (!field) return '';

const { value, options } = field;

if (Array.isArray(value)) {

return value

.map((v) => options?.find((o) => o.id === v)?.text ?? String(v))

.join(', ');

}

return value == null ? '' : String(value);

}

export function normalizeWebhookPayload(body: any): StandardLead {

// Tally: single FORM_RESPONSE event type, answers in data.fields

if (body?.eventType === 'FORM_RESPONSE' && Array.isArray(body?.data?.fields)) {

const fields: TallyField[] = body.data.fields;

// Exact (case-insensitive) label match avoids "Name" also matching "Company name".

const get = (...labels: string[]) =>

fieldText(

fields.find((f) => labels.includes(f.label.trim().toLowerCase()))

);

return {

id: body.data.responseId,

source: 'tally',

fullName: get('full name', 'name'),

email: get('work email', 'email'),

companySize: get('company size', 'headcount') || 'Unknown',

notes: get('notes', 'message') || undefined,

utmSource: get('utm_source') || undefined,

timestamp: body.createdAt,

};

}

// Custom backend

if (body?.submission_id && body?.payload) {

return {

id: body.submission_id,

source: 'custom',

fullName: body.payload.full_name ?? body.payload.name ?? '',

email: body.payload.work_email ?? body.payload.email ?? '',

companySize: body.payload.employee_range ?? 'Unknown',

notes: body.payload.notes || undefined,

utmSource: body.context?.utm_source || undefined,

timestamp: new Date(body.timestamp * 1000).toISOString(),

};

}

throw new Error('Unsupported or malformed webhook payload.');

}

```


2. The Webhook Endpoint: Verify, Dedupe, Acknowledge, Defer

Verifying the sender

Tally signing is optional: it is only active if you set a signing secret on the webhook. When enabled, each request carries a `Tally-Signature` header containing a base64-encoded HMAC-SHA256 of the payload. Always set a secret. Tally’s own example signs `JSON.stringify(payload)`, but verifying against the raw request body is safer because it avoids re-serialization differences.

Tally’s scheme has no timestamp, so it offers no built-in replay protection. Defend against replays and retries with idempotency (below).

Edge runtimes don’t support Node’s `crypto` module, so use Web Crypto:

```

// lib/hmac.ts

export async function verifyTallySignature(

rawBody: string,

signature: string | null,

secret: string

): Promise<boolean> {

if (!signature) return false;

const enc = new TextEncoder();

const key = await crypto.subtle.importKey(

'raw',

enc.encode(secret),

{ name: 'HMAC', hash: 'SHA-256' },

false,

['sign']

);

const mac = await crypto.subtle.sign('HMAC', key, enc.encode(rawBody));

const expected = btoa(String.fromCharCode(...new Uint8Array(mac)));

return timingSafeEqual(expected, signature);

}

// Constant-time string comparison

export function timingSafeEqual(a: string, b: string): boolean {

if (a.length !== b.length) return false;

let diff = 0;

for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);

return diff === 0;

}

```

Deferring work

Next.js provides `after()` (from `next/server`) to run work after the response is sent. On Vercel, `waitUntil` from `@vercel/functions` does the same. Vercel documents both as ways to continue processing after responding.

```

// app/api/webhooks/lead/route.ts

import { after } from 'next/server';

import { Redis } from '@upstash/redis';

import { normalizeWebhookPayload } from '@/lib/normalizer';

import { dispatchSlackClaimCard } from '@/lib/slack';

import { verifyTallySignature } from '@/lib/hmac';

const redis = Redis.fromEnv();

export async function POST(req: Request) {

const rawBody = await req.text();

// 1. Authenticate. Fail closed if no secret is configured.

const secret = process.env.WEBHOOK_SECRET;

if (!secret) return Response.json({ error: 'Server misconfigured' }, { status: 500 });

const ok = await verifyTallySignature(rawBody, req.headers.get('tally-signature'), secret);

if (!ok) return Response.json({ error: 'Invalid signature' }, { status: 401 });

// 2. Parse and normalize.

let lead;

try {

lead = normalizeWebhookPayload(JSON.parse(rawBody));

} catch {

return Response.json({ error: 'Bad payload' }, { status: 400 });

}

// 3. Idempotency: Tally retries on failure, so ignore events we've already seen.

const firstTime = await redis.set(`seen:${lead.id}`, '1', { nx: true, ex: 60 * 60 * 24 * 7 });

if (!firstTime) return Response.json({ success: true, duplicate: true });

// 4. Acknowledge now, post to Slack afterwards.

after(async () => {

try {

await dispatchSlackClaimCard(lead);

} catch (err) {

console.error('Slack dispatch failed', err);

// Release the idempotency key so a provider retry (or your own) can try again.

await redis.del(`seen:${lead.id}`);

}

});

return Response.json({ success: true, leadId: lead.id });

}

```

If you want custom-form requests handled too, sign them with the same scheme (or a separate secret and header) so that route fails closed as well.


3. Building the Slack Claim Card with Block Kit

Block Kit messages are JSON arrays of blocks. An `actions` block can hold buttons. When a user clicks one, Slack POSTs an interaction payload of type `block_actions` to your app’s Interactivity Request URL, which you set under *Interactivity & Shortcuts* in the Slack app settings.

Notes on the approach used here:

  • This example posts through an incoming webhook for simplicity. The Slack app that owns the webhook must have interactivity enabled for the Claim button to work. If you later need to update messages by timestamp (`chat.update`), switch to `chat.postMessage` with a bot token.
  • A button with a `url` opens a link, but Slack still sends your app a `block_actions` payload for it. Your handler should acknowledge and ignore actions it doesn’t recognize.
  • Lead data is user-supplied. Escape `&`, `<` and `>` before putting it into `mrkdwn` text, so a submission can’t inject mentions or links.

```

// lib/slack.ts

import type { StandardLead } from './normalizer';

const esc = (s: string) =>

s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

export async function dispatchSlackClaimCard(lead: StandardLead) {

const webhookUrl = process.env.SLACK_INBOUND_WEBHOOK_URL;

if (!webhookUrl) throw new Error('SLACK_INBOUND_WEBHOOK_URL is missing.');

const blocks: any[] = [

{

type: 'header',

text: { type: 'plain_text', text: '⚡ New inbound lead', emoji: true },

},

{

type: 'section',

fields: [

{ type: 'mrkdwn', text: `*Name:*\n${esc(lead.fullName)}` },

{ type: 'mrkdwn', text: `*Email:*\n${esc(lead.email)}` },

{ type: 'mrkdwn', text: `*Company size:*\n${esc(lead.companySize)}` },

{ type: 'mrkdwn', text: `*Source:*\n${esc(lead.utmSource ?? 'direct')}` },

],

},

];

if (lead.notes) {

blocks.push({

type: 'section',

text: { type: 'mrkdwn', text: `*Notes:*\n${esc(lead.notes).slice(0, 1500)}` },

});

}

blocks.push(

{ type: 'divider' },

{

type: 'actions',

block_id: `claim_${lead.id}`,

elements: [

{

type: 'button',

text: { type: 'plain_text', text: 'Claim lead', emoji: true },

style: 'primary',

// Keep the value small; look up details server-side if you need more.

value: JSON.stringify({ leadId: lead.id, name: lead.fullName, email: lead.email }),

action_id: 'claim_lead_action',

},

{

type: 'button',

text: { type: 'plain_text', text: 'View in CRM', emoji: true },

url: `https://crm.example.com/leads?email=${encodeURIComponent(lead.email)}`,

action_id: 'view_crm_action',

},

],

}

);

const res = await fetch(webhookUrl, {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({ text: `New inbound lead: ${lead.fullName}`, blocks }),

});

if (!res.ok) throw new Error(`Slack post failed: HTTP ${res.status}`);

}

```

The top-level `text` field is the notification fallback shown in push alerts and screen readers.


4. Handling Clicks: Atomic Claiming

If two reps click “Claim” at nearly the same moment, only one should win. Redis `SET key value NX` (set only if the key doesn’t exist) is atomic, so exactly one caller gets the lock. With Upstash’s client, `set` with `{ nx: true }` returns a truthy value when the key was set and `null` when it already existed. An `ex` expiry keeps stale locks from living forever.

Verifying Slack requests

Slack signs every request. The recipe is:

1. Read the `X-Slack-Request-Timestamp` header and reject anything older than about five minutes.

2. Build the base string `v0:{timestamp}:{raw body}`.

3. Compute an HMAC-SHA256 of it with your signing secret (not the deprecated verification token).

4. Compare `v0=<hex digest>` to the `X-Slack-Signature` header.

The raw body must be read as text *before* parsing, because the signature covers the exact bytes.

Responding within 3 seconds

Slack requires an acknowledgment within 3 seconds. Slack also supplies a `response_url` on interaction payloads. It can be used up to 5 times within 30 minutes, and with `replace_original: true` it updates the message the button was on. So the handler acquires the lock (a single fast Redis call), returns 200 immediately and posts the card update in the background.

```

// lib/slack-verify.ts

import { timingSafeEqual } from './hmac';

export async function verifySlackRequest(

rawBody: string,

timestamp: string | null,

signature: string | null,

signingSecret: string

): Promise<boolean> {

if (!timestamp || !signature) return false;

if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 60 * 5) return false;

const enc = new TextEncoder();

const key = await crypto.subtle.importKey(

'raw', enc.encode(signingSecret),

{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']

);

const mac = await crypto.subtle.sign('HMAC', key, enc.encode(`v0:${timestamp}:${rawBody}`));

const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');

return timingSafeEqual(`v0=${hex}`, signature);

}

```

```

// app/api/slack/interactivity/route.ts

import { after } from 'next/server';

import { Redis } from '@upstash/redis';

import { verifySlackRequest } from '@/lib/slack-verify';

const redis = Redis.fromEnv();

const post = (url: string, body: unknown) =>

fetch(url, {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify(body),

});

export async function POST(req: Request) {

const rawBody = await req.text();

const valid = await verifySlackRequest(

rawBody,

req.headers.get('x-slack-request-timestamp'),

req.headers.get('x-slack-signature'),

process.env.SLACK_SIGNING_SECRET!

);

if (!valid) return new Response('Invalid signature', { status: 401 });

// Interactivity payloads are form-encoded with a JSON string in `payload`.

const payloadStr = new URLSearchParams(rawBody).get('payload');

if (!payloadStr) return new Response('Missing payload', { status: 400 });

const payload = JSON.parse(payloadStr);

const action = payload.type === 'block_actions' ? payload.actions?.[0] : null;

// Acknowledge anything we don't handle (including the URL button).

if (!action || action.action_id !== 'claim_lead_action') return new Response('', { status: 200 });

const { leadId, name, email } = JSON.parse(action.value);

const userId: string = payload.user.id;

const responseUrl: string = payload.response_url;

const lockKey = `lock:lead:${leadId}`;

// Atomic claim: only the first caller gets a truthy result.

const acquired = await redis.set(lockKey, userId, { nx: true, ex: 60 * 60 * 24 });

after(async () => {

if (!acquired) {

const owner = await redis.get<string>(lockKey);

await post(responseUrl, {

response_type: 'ephemeral',

replace_original: false,

text: owner ? `This lead was already claimed by <@${owner}>.` : 'This lead was already claimed.',

});

return;

}

// Do downstream work here, e.g. assign the owner in your CRM.

const now = Math.floor(Date.now() / 1000);

await post(responseUrl, {

replace_original: true,

text: `Lead claimed by <@${userId}>`,

blocks: [

{ type: 'header', text: { type: 'plain_text', text: '✅ Lead claimed', emoji: true } },

{

type: 'section',

text: {

type: 'mrkdwn',

text: `*Lead:* ${name.replace(/[&<>]/g, '')} (${email.replace(/[&<>]/g, '')})\n*Claimed by:* <@${userId}> at <!date^${now}^{time}|just now>`,

},

},

],

});

});

return new Response('', { status: 200 });

}

```

Notes on this design:

  • `<!date^unix^{time}|fallback>` renders the time in each viewer’s own time zone. It is better than formatting a server-side date string.
  • Claiming only sets a lock. If the downstream CRM update fails, decide whether to release the lock (`redis.del`) so the lead isn’t stuck in a claimed-but-unassigned state.
  • The lock expires after 24 hours, as an example. Pick a TTL that matches your own process, or skip the expiry if claims should be permanent.

5. Security Hardening

Verify every inbound request. Two endpoints are public: the form webhook and the Slack interactivity URL. Authenticate both. Don’t use a secret in the URL path or query string as your only defence, because URLs tend to end up in logs and proxy caches.

Turn signing on. Tally’s signing is opt-in, so an unsigned webhook endpoint accepts anything. Fail closed when no secret is configured, as the handler above does.

Defend against replays. Slack’s scheme includes a timestamp, so enforce the five-minute window. Tally’s doesn’t, so rely on idempotency keys (the `seen:` check above) and expiring locks.

Treat form data as untrusted. Escape it for Slack `mrkdwn`, as shown earlier, and never interpolate it into URLs without encoding.

Rate-limit the endpoints. The `@upstash/ratelimit` library supports token-bucket limiting backed by Redis. Apply it before doing expensive work.

Protect the incoming webhook URL. Slack incoming webhooks have no request signing. Anyone who has the URL can post to your channel, so keep it in environment variables and rotate it if it leaks.

Keep secrets out of the client. All of these secrets belong in server-side environment variables: `WEBHOOK_SECRET`, `SLACK_SIGNING_SECRET`, the Redis token and the Slack webhook URL.


6. Measuring Your Own Latency

Measure the pipeline in your environment instead of trusting generic benchmarks. Useful timestamps:

1. The form’s submission time (Tally includes `createdAt` in the payload).

2. When your endpoint receives the request.

3. When Slack accepts the post (the response from the webhook call).

4. When a rep clicks Claim, which is your real speed-to-lead metric.

```

const receivedAt = Date.now();

// ...after the Slack post succeeds:

console.log(JSON.stringify({

leadId: lead.id,

formToReceiveMs: receivedAt - Date.parse(lead.timestamp),

receiveToSlackMs: Date.now() - receivedAt,

}));

```

Then track the number that matters to the business: time from submission to first claim, and from claim to first contact. The cards only make that gap shorter if reps are actually watching the channel. Notification rules, an on-call rota and an escalation path for unclaimed leads matter as much as the code does.


Conclusion

A form-to-Slack claiming pipeline replaces slow batch hand-offs with a short chain of small, auditable steps:

1. Normalize each form’s payload into one internal shape, resolving Tally’s option IDs to text.

2. Verify the sender, and set a signing secret because Tally’s is optional.

3. Acknowledge fast and do Slack work in the background, within Tally’s 10-second and Slack’s 3-second limits.

4. Deduplicate deliveries, since Tally retries failed webhooks over a period of up to a day.

5. Claim atomically with Redis `SET NX`, so exactly one rep owns each lead.

6. Measure submission-to-claim time in your own environment.

The research behind speed-to-lead is clear that minutes matter. This setup gives you a transparent way to act on that.


Sources

  • Tally Help Center, “Webhooks” (signing secret, 10-second timeout, retry schedule, payload format): <https://tally.so/help/webhooks>
  • Hookdeck, “Guide to Tally Webhooks” (optional signing, base64 HMAC-SHA256, raw-body verification): <https://hookdeck.com/webhooks/platforms/guide-to-tally-webhooks-features-and-best-practices>
  • Slack, “Handling user interaction” (`response_url`, `replace_original`, 5 uses in 30 minutes): <https://api.slack.com/interactivity/handling>
  • Slack Java SDK, “Interactive components” (3-second acknowledgment, `response_url` limits): <https://tools.slack.dev/java-slack-sdk/guides/interactive-components>
  • Hookdeck, “How to Secure and Verify Slack Webhooks” (signing base string, five-minute window; incoming webhooks are unsigned): <https://hookdeck.com/webhooks/platforms/how-to-secure-and-verify-slack-webhooks-with-hookdeck.md>
  • InsideSales.com, Lead Response Management study (100x contact, 21x qualify, 5 vs. 30 minutes): <https://www.insidesales.com/lead-generation-strategies-when-to-call-when-not-to-call/>
  • InsideSales/XANT press materials (42 hours 43 minutes average response across 16,000+ companies): <https://resources.insidesales.com/?p=27584>
  • Vercel Functions guidance (`waitUntil` and `after()` for post-response work; Edge runtime limitations): <https://mcpservers.org/agent-skills/vercel/vercel-plugin/vercel-functions>