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.
| Concern | Heavy automation chain | Webhook-to-Slack pipeline |
|---|---|---|
| Data shape | Mapped CRM fields | Raw JSON you control |
| Routing | Round-robin or static queue | Shared channel, first click wins |
| Concurrency | Manual reassignment | Atomic Redis lock |
| Customization | Vendor workflow builder | Code 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,CHECKBOXESand similar),valueis an array of option IDs. The human-readable text is in a siblingoptionsarray of{ id, text }objects. - Hidden fields arrive with type
HIDDEN_FIELDS, and thelabelis the parameter name. That is how UTM values such asutm_campaignreach 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 tochat.postMessagewith a bot token. - A button with a
urlopens a link, but Slack still sends your app ablock_actionspayload for it. Your handler should acknowledge and ignore actions it doesn’t recognize. - Lead data is user-supplied. Escape
&,<and>before putting it intomrkdwntext, so a submission can’t inject mentions or links.
// lib/slack.ts import type { StandardLead } from './normalizer'; const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 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...