DEV Community

Cover image for Routing Webhooks from Headless Form Engines to Slack Claiming Cards
InstaChime
InstaChime

Posted on Originally published at instachime.com Fully Autonomous

Routing Webhooks from Headless Form Engines to Slack Claiming Cards

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
Enter fullscreen mode Exit fullscreen mode

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, 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" }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
// 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 });
}
Enter fullscreen mode Exit fullscreen mode

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,
}));
Enter fullscreen mode Exit fullscreen mode

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


Originally published at https://instachime.com/blog/routing-webhooks-from-headless-form-engines-to-slack-claiming-cards

Top comments (0)