Team-Connect Engineering Guide · Updated 3 May 2026

Webhook Integration

A practical engineer's guide to webhook integration — how to receive events safely, verify HMAC signatures, handle retries and idempotency, test locally with ngrok, and avoid the silent production failures that cause webhook events to vanish without trace.

Covers HMAC verification + idempotency · Node.js + Python code · Includes retry strategies + DLQs
Jump to a section

01What is a Webhook?

A webhook is an HTTP callback — an automated POST request from one server to another, triggered by some event in the source system. Instead of your application repeatedly polling an API to check whether something happened, the source system pushes events to a URL you provide as soon as they occur. Webhooks are the standard pattern for event-driven integrations: payment notifications, call completions, message status updates, CI build results, calendar invites, and similar.

The push model

The fundamental insight of webhooks is reversing direction. A regular API call has your application asking a remote server "anything new?" repeatedly. With webhooks, you give the remote server your URL once, and from then on it tells you when things happen. No polling, no missed events, no wasted API calls. The remote server initiates the HTTP request; your application is the server responding.

A simple analogy: webhooks are like phone calls, polling is like checking voicemail

If you wanted to know when a friend got home, you could either (a) phone them every five minutes asking "are you home yet?" — that is polling — or (b) ask them to call you when they arrive — that is webhooks. Option (a) wastes calls and creates lag. Option (b) is event-driven and efficient. The same logic applies to integration architecture.

Where webhooks fit in modern integrations

Almost every developer-facing platform in 2026 ships webhooks for state-change events:

  • Payments — Stripe, PayPal, Adyen send payment.succeeded, refund.created, dispute.opened when these happen.
  • Messaging — Twilio, MessageBird, Telnyx send message.delivered, message.failed, inbound SMS bodies.
  • Voice — Team-Connect, Twilio, Vonage send call.started, call.ended, recording.completed.
  • Source control — GitHub, GitLab, Bitbucket send push, pull_request.opened, release.published.
  • Productivity — Slack, Linear, Notion, Calendly send notifications when relevant events occur.
  • CI/CD — GitHub Actions, CircleCI, Buildkite send workflow.completed with results.
The right mental model: webhooks are how a SaaS system tells your system about things that happened. If you find yourself building a polling loop against any modern API, check whether webhooks are available first — they almost always are, and the integration will be faster, cheaper and more reliable.

02Webhooks vs Polling vs WebSocket vs SSE

Webhooks are one of four common patterns for getting events from one system to another. Each has distinct strengths and the right choice depends on the relationship between the systems and the kind of events you are passing.

The four event-passing patterns

PatternDirectionConnectionLatencyBest for
PollingClient → serverRepeated short HTTP requestsAverage half the polling intervalFallback when nothing else works; legacy integrations
WebhooksServer → server (push)One-shot HTTP POST per eventSeconds (network + processing)Server-to-server event notification, async work
WebSocketBidirectionalLong-lived TCP connectionSub-100msReal-time interactive UIs, gaming, live media (see WebRTC)
Server-Sent Events (SSE)Server → clientLong-lived HTTP streamSub-100msServer-to-browser one-way streaming, simpler than WebSocket

When to use webhooks specifically

Webhooks are the right answer when all four of these apply:

  • Events are infrequent — not constant streaming. Hundreds or thousands per minute is fine; millions per second is not.
  • Latency tolerance is seconds — not milliseconds. Webhook delivery includes network round-trips and possibly retries.
  • The receiver has a publicly reachable URL — webhooks need an HTTPS endpoint the sender can POST to.
  • The receiver is a server, not a browser — browsers cannot accept incoming HTTP requests. Use WebSocket or SSE for browser-side event reception.

When NOT to use webhooks

  • You need sub-second latency — webhooks add at least the HTTP round-trip plus your queue processing time. Use WebSocket or message queues instead.
  • The event volume is enormous — one HTTP request per event does not scale to millions per second. Use Kafka, NATS, or another event bus.
  • The receiver cannot host a public endpoint — mobile apps, browser tabs, and many corporate networks cannot accept inbound HTTP. Use long-polling, WebSocket-from-client, push notifications (FCM/APNS) or SSE instead.
  • You need bidirectional communication — webhooks are one-way (sender to receiver). For request/response or interactive flows use a regular API.

The "webhooks for state change, polling for state read" pattern

A common practice in production integrations is to use webhooks to be notified that something changed, and then make a regular API call to fetch the current state. This is more reliable than trusting the webhook payload alone because (a) you always have an authoritative read, (b) you survive missed webhooks (state can be reconstructed from API), and (c) the webhook payload can be minimal (just an ID), which is faster to send and verify.

Stripe explicitly recommends this pattern: their webhook payloads contain the resource ID; your handler then calls the Stripe API to retrieve the canonical resource state. The 200ms round-trip is worth the reliability.

Combine patterns when it makes sense: a typical voice AI integration uses webhooks for call lifecycle events (started, ended, recording ready), WebRTC for real-time browser audio, regular API calls for configuration and reporting, and message queues for high-volume internal events. There is no rule that you can only pick one.

03Anatomy of a Webhook

A webhook is just an HTTP POST request — but the conventions around URL, headers, body and response shape have stabilised across most providers. Knowing the anatomy lets you read any webhook documentation faster and spot the bits that matter for security and reliability.

The URL

You provide the URL to the source system. It must be HTTPS in production. Some providers append a path component for the event type; most send all events to the same URL and let you discriminate from the body. Keep webhook URLs hard to guess (include a long random component) so that even if your HMAC verification has a bug, the attacker cannot easily target the endpoint.

The HTTP method

Always POST. Some legacy systems use PUT; do not let any system push webhooks via GET (GETs are not supposed to have side effects, and many caches will replay them).

Headers

Typical webhook headers from a Stripe-style sender

POST /webhooks/team-connect HTTP/1.1
Host: api.your-service.example.com
User-Agent: TeamConnect-Webhooks/1.0
Content-Type: application/json
Content-Length: 1247
X-TeamConnect-Event: call.completed
X-TeamConnect-Delivery: 8e3a4b2c-5f1d-4789-a01e-9d8c7b6f5a4e
X-TeamConnect-Timestamp: 1714723200
X-TeamConnect-Signature: sha256=4f8d2a9b1c...e7a5d

The headers you care about:

  • Content-Type — almost always application/json in 2026. Older systems may use application/x-www-form-urlencoded.
  • X-Event / X-EventType — what event this is. Sometimes named X-Hub-Event, X-GitHub-Event etc.
  • X-Delivery-ID / X-Event-ID — unique ID per delivery, used for idempotency. Critically important; store this and dedupe on it.
  • X-Timestamp — when the event was sent (Unix seconds). Used for replay protection.
  • X-Signature — HMAC of the body keyed with your shared secret. The single most important header.

The body

JSON, structured around an event envelope plus an event-specific payload:

A typical webhook body

{
  "id": "evt_8e3a4b2c5f1d4789a01e9d8c7b6f5a4e",
  "type": "call.completed",
  "created_at": "2026-05-03T14:00:00.000Z",
  "api_version": "2026-04-01",
  "data": {
    "call_id": "call_19hf82j3k4l",
    "from": "+441625555123",
    "to": "+441625555456",
    "duration_seconds": 187,
    "completed_at": "2026-05-03T14:00:00.000Z",
    "recording_url": "https://api.team-connect.co.uk/recordings/abc123.wav"
  }
}

Common envelope fields across providers: a unique event ID, a type (dotted convention, e.g. resource.action), a timestamp, and a data block containing the actual event details. Many providers also include an API version field so you can pin your handler to a specific schema.

The response

Your handler responds with an HTTP status code and (optionally) a body that is usually ignored:

  • 200 OK or 204 No Content — "I received this and accepted it". The sender will not retry.
  • 4xx — "this event is invalid and I cannot process it" (bad signature, malformed body). Sender should not retry.
  • 5xx — "I had a transient problem, please retry". Sender will retry with backoff.
  • Timeout (no response within 5-10 seconds) — treated as 5xx. Will be retried.
Respond fast, work later: the response time from your webhook handler should be under a second. If you have heavy processing to do (trigger notifications, update databases, run analytics), accept the webhook with 200, queue the work, and process it asynchronously. Holding the webhook connection open while you do downstream work is the most common cause of webhook timeouts and unwanted retries.

04Setting Up a Webhook Receiver

A webhook receiver is just an HTTP endpoint that accepts POST. The interesting bits are not the framework code — they are the order of operations: verify signature first, parse JSON second, dedupe third, enqueue fourth, respond fifth, do work last.

The standard handler shape

  1. Read the raw request body as bytes (not parsed JSON). HMAC is computed over the bytes; if you parse and re-serialise first, the signature check will fail because formatting differs.
  2. Verify the HMAC signature against the body bytes using your shared secret. If invalid, return 401 immediately.
  3. Verify the timestamp is recent (within last few minutes). If too old, reject — this prevents replay attacks where someone captures an old webhook and resends it.
  4. Parse the JSON body now that you trust it.
  5. Check the event ID against your processed-events store. If you have already seen this ID, return 200 immediately without re-processing.
  6. Enqueue the event for asynchronous processing.
  7. Mark the event ID as processed.
  8. Return 200 as quickly as possible.

Working Node.js example (Express)

Node.js webhook receiver with HMAC verification

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

const SECRET = process.env.TEAMCONNECT_WEBHOOK_SECRET;

// IMPORTANT: capture raw body bytes for HMAC verification
app.use('/webhooks/team-connect',
  express.raw({ type: 'application/json' })
);

app.post('/webhooks/team-connect', async (req, res) => {
  const rawBody = req.body; // Buffer because of express.raw

  // 1. Signature check
  const signatureHeader = req.get('X-TeamConnect-Signature') || '';
  const signature = signatureHeader.replace('sha256=', '');
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(rawBody)
    .digest('hex');

  // Timing-safe compare to prevent timing attacks
  if (!crypto.timingSafeEqual(
        Buffer.from(signature, 'hex'),
        Buffer.from(expected, 'hex')
      )) {
    return res.status(401).send('Bad signature');
  }

  // 2. Timestamp check (replay protection)
  const ts = parseInt(req.get('X-TeamConnect-Timestamp') || '0', 10);
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > 300) {  // 5 minute window
    return res.status(401).send('Stale timestamp');
  }

  // 3. Parse JSON now that we trust the body
  const event = JSON.parse(rawBody.toString('utf8'));

  // 4. Idempotency check
  if (await alreadyProcessed(event.id)) {
    return res.status(200).send('Already processed');
  }

  // 5. Enqueue for async processing
  await queue.publish('team-connect-events', event);

  // 6. Mark as processed and respond
  await markProcessed(event.id);
  res.status(200).send('OK');
});

app.listen(3000);

Working Python example (FastAPI)

Python webhook receiver with HMAC verification

import hmac
import hashlib
import time
import os
from fastapi import FastAPI, Request, HTTPException, Header

app = FastAPI()
SECRET = os.environ["TEAMCONNECT_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/team-connect")
async def handle_webhook(
    request: Request,
    x_teamconnect_signature: str = Header(...),
    x_teamconnect_timestamp: str = Header(...),
):
    raw_body = await request.body()  # raw bytes, not parsed JSON

    # 1. Signature verification
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    received = x_teamconnect_signature.removeprefix("sha256=")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(401, "Bad signature")

    # 2. Timestamp / replay protection
    try:
        ts = int(x_teamconnect_timestamp)
    except ValueError:
        raise HTTPException(401, "Bad timestamp")
    if abs(time.time() - ts) > 300:
        raise HTTPException(401, "Stale timestamp")

    # 3. Parse JSON only now
    import json
    event = json.loads(raw_body.decode("utf-8"))

    # 4. Idempotency
    if await already_processed(event["id"]):
        return {"status": "already_processed"}

    # 5. Enqueue async work
    await queue.publish("team-connect-events", event)

    # 6. Mark + respond
    await mark_processed(event["id"])
    return {"status": "ok"}

Why "raw body" matters so much

Most web frameworks helpfully parse JSON into a dictionary or object before your handler sees it. This is wrong for webhook handlers, because HMAC is computed over the exact byte sequence the sender sent. If your framework re-serialises the parsed JSON to verify the HMAC, the formatting will differ (key ordering, whitespace, decimal precision) and the signature check will fail. Always capture the raw body bytes before any parsing or middleware touches them.

In Express this means express.raw({ type: 'application/json' }) mounted at the webhook path before any JSON middleware. In FastAPI it means await request.body() before accessing request.json(). In Flask it means request.get_data(). In .NET it means reading the request stream before MVC binds the model.

The framework gotcha that breaks webhooks: body-parsing middleware mounted globally will silently consume the request body before your webhook handler runs. Always mount your raw-body middleware before any global JSON parser, and scope it to the webhook path only. The number of webhook integrations that fail signature verification because of a misordered Express middleware stack is staggering.

05HMAC Signature Verification

The single most important security control for webhooks is HMAC signature verification. It is the difference between accepting events from the real sender and accepting events from anyone who can find your webhook URL. Never run a webhook handler in production without it.

How HMAC verification works

Both you and the sender share a secret — a random string only the two of you know, given to you when you registered the webhook. For every event the sender wants to send, it:

  1. Computes HMAC-SHA256(secret, raw_body) — a one-way hash of the body, keyed with the shared secret.
  2. Includes that hash in a header (X-Signature, X-Hub-Signature-256, etc.) along with the body.

When you receive the webhook, you do the same computation on your side using the same secret over the body bytes you received. If your computed hash matches the header, three things must be true: (a) the request came from someone who knows the secret, (b) the body was not tampered with in transit, and (c) by extension, you can trust the body content.

Why HMAC and not just a shared API key

An API key in a header proves "the sender knows the key" but does not prove the body was not modified afterwards by an attacker. HMAC binds the secret to the body content — you cannot tamper with the body without invalidating the signature unless you also know the secret. This means the secret never has to be sent across the wire (it is only used as a key); the signature itself is safe to send because it cannot be reused for a different body.

Timing-safe comparison: critical and non-obvious

When comparing the received signature to your computed signature, never use ordinary string equality (== in most languages). Standard string comparison short-circuits on the first mismatched character, which means an attacker can use the response time to learn the correct signature one character at a time. This is a real attack against unverified production systems. Instead, always use a constant-time comparison function:

  • Node.js: crypto.timingSafeEqual(a, b)
  • Python: hmac.compare_digest(a, b)
  • Ruby: Rack::Utils.secure_compare(a, b)
  • Go: hmac.Equal(a, b) from the standard library
  • Java: MessageDigest.isEqual(a, b)

If you cannot find your language's built-in, write one that always processes both inputs fully regardless of where the first mismatch occurred. The performance cost is negligible; the security benefit is real.

Replay protection via timestamp

HMAC alone does not prevent replay attacks — an attacker who captures a valid webhook can resend the entire request later (same signature, same body, same headers) and your handler will accept it as genuine. To prevent this:

  1. The sender includes a timestamp header (X-Timestamp, in Unix seconds).
  2. The sender's HMAC is computed over timestamp + body (concatenated), not just body alone — some providers do this, others rely on the receiver's check below.
  3. You verify the timestamp is recent (within last 5 minutes is the typical window) and reject anything older.

Note: if the sender does not include the timestamp in the HMAC computation, an attacker could rewrite just the timestamp header to make a captured request look fresh. So the most secure pattern is HMAC over timestamp.body, which Stripe and others use. If your sender only HMACs the body, the 5-minute window is your replay protection — not perfect, but adequate against most opportunistic replay.

Other layers of webhook security

HMAC is the foundation. On top of that:

  • HTTPS only — never accept webhooks over plain HTTP. Even if the body is HMAC'd, headers and metadata leak.
  • Rate limiting — limit requests per IP per second to prevent abuse if your webhook URL leaks.
  • Length limits — cap webhook body size (1-10 MB depending on provider). Reject oversize requests immediately.
  • IP allowlisting — if the sender publishes a stable IP range, you can require requests come from those IPs as defence in depth. Not a substitute for HMAC.
  • Audit logging — log every received webhook (event ID, timestamp, signature verification result) so you can investigate issues later.
  • Secret rotation — webhook secrets should be rotated periodically. Most providers support multiple active secrets during rotation so you can update receivers gradually.
  • mTLS — for very high-security scenarios (banking, healthcare), require client certificate authentication. Defence beyond HMAC.
The single biggest webhook security failure: skipping signature verification because "it works without it" during development, then deploying that code to production. Webhook URLs are not secret — they appear in API logs, in code repositories, in error reports, in screenshots. An unsigned webhook handler that does anything consequential (creates orders, sends emails, charges cards, releases recordings) is a single discovery away from being exploited. Verify signatures from day one.

06Idempotency and Deduplication

Webhook providers retry events when they cannot get a 2xx response, which means your handler will sometimes receive duplicates. If you blindly process every webhook, duplicates will cause double-charges, double-emails, double-bookings, and other corruption. Webhook handlers must be idempotent: handling the same event twice has the same effect as handling it once.

Why duplicates happen even when "everything is fine"

Five common reasons your handler will see duplicates:

  1. Network timeout on the response — you successfully processed the event and returned 200, but the TCP connection dropped before the sender saw your response. Sender retries.
  2. Slow processing — your handler took longer than the sender's timeout (typically 5-10 seconds). Sender treats it as a failure and retries.
  3. Sender retries past the timeout — your handler did finish, but by the time you returned 200, the sender had already started a retry.
  4. Crash during processing — your handler accepted the event but crashed before completing. Sender retries because you never returned 200.
  5. Sender bugs — rare, but providers occasionally send the same event twice due to internal failures.

The idempotency pattern: dedupe on event ID

Every webhook event has a unique ID in the payload (or a delivery ID in the headers). Use it as a deduplication key:

  1. When a webhook arrives, extract the event ID.
  2. Check whether you have already processed an event with that ID.
  3. If yes, return 200 immediately without re-processing.
  4. If no, process the event and record the ID as processed.

Idempotent webhook handler with database-backed dedupe

async function handleWebhook(event) {
  // Atomic insert-or-fail: returns true if newly inserted, false if already existed
  const isNew = await db.processedEvents.insertIgnore({
    event_id: event.id,
    received_at: new Date(),
    event_type: event.type
  });

  if (!isNew) {
    // We've seen this event before. Acknowledge and return.
    return { status: 'duplicate' };
  }

  // First time seeing this event - do the actual work
  try {
    await processBusinessLogic(event);
  } catch (err) {
    // Roll back the dedupe record so it can be retried
    await db.processedEvents.delete({ event_id: event.id });
    throw err;
  }

  return { status: 'processed' };
}

Where to store processed event IDs

StorageProsConsUse when
Database tableDurable, queryable, easy to debugAdds DB load per webhookMost production systems — the default choice
Redis with TTLFast, scales well, automatic cleanupVolatile if Redis fails (mitigate with persistence)High-volume webhooks where DB load matters
Existing business recordNo extra storageOnly works if every event maps to a unique business actionWhen events one-to-one with creates/updates
In-memory cacheFastestLost on restart; multiple instances see different stateNever alone — only as L1 cache in front of durable storage

How long to remember processed events

Long enough that the webhook sender will not still be retrying. Most providers stop retrying within 24-72 hours. 7 days is a safe default — longer than any retry window, short enough that the dedupe store does not grow forever. Add a scheduled cleanup that deletes records older than your retention window.

Native idempotency in business logic

If your underlying business operation is naturally idempotent (creating a record with a unique business key, sending an email with a deterministic ID, updating a record to a target state), you may not need a separate dedupe table. The database constraint or natural uniqueness handles it for you. Use this pattern when possible — it removes a moving part.

Naturally idempotent: insert with conflict-do-nothing

// PostgreSQL: ON CONFLICT DO NOTHING
INSERT INTO call_records (call_id, customer_id, duration, completed_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (call_id) DO NOTHING;

If call_id is the unique key from the webhook payload, this insert is naturally idempotent — the second time the webhook fires for the same call, the database silently no-ops.

Test idempotency by replaying: the most reliable way to confirm your handler is idempotent is to replay the same webhook 10 times in a row. Capture a real webhook, then run curl -X POST ... & for i in {1..10}; do ...; done. If your downstream state is the same after 10 calls as it would be after 1, you are idempotent. If not, find the operation that double-fires and either dedupe it or make it naturally idempotent.

07Retries and Dead Letter Queues

The webhook contract is asymmetric: senders are obligated to retry on failure, but you control what counts as failure (your status code) and what happens after retries are exhausted. Getting this layer right is the difference between an integration that survives transient outages and one that silently loses events.

Sender retry behaviour, by provider

ProviderTotal retry durationSchedule
Stripe3 daysImmediate, 5min, 30min, then exponential up to 24h intervals
GitHub8 hoursUp to 8 retries with exponential backoff
Twilio24 hoursUp to 11 retries; backoff capped at 6h
Slack1 hour3 retries: 1min, 5min, 30min
Linear24 hoursExponential backoff
SendGrid24 hoursExponential backoff with jitter
Team-Connect24 hoursImmediate, 30s, 2min, 10min, 30min, 1h, 2h, 4h, 8h, 16h

The status code contract for retries

Use HTTP status codes to communicate intent to the sender:

  • 200 / 204 — "I have received this and will not need a retry." Sender stops.
  • 4xx (especially 400, 401, 403, 422) — "This event is permanently bad, do not retry." Sender stops. Use for: signature failures, schema-incompatible bodies, expired credentials.
  • 5xx (500, 502, 503, 504) — "Transient failure, please try again." Sender retries with backoff. Use for: database temporarily unavailable, downstream service down, rate-limited internally.
  • Timeout / connection refused — treated as 5xx. Sender retries.

Choose deliberately. If your handler crashes during processing, you usually want a retry — let the exception become a 500. If your handler receives a malformed payload (missing required field, unknown event type), you usually do not want infinite retries — return 400 and log it.

Receiver-side retry: the inner queue

Sender retries are necessarily slow (minutes to hours between attempts). For transient downstream failures, you want fast internal retries: webhook arrives, you queue it, the queue worker tries to process, fails, retries within seconds. Most webhook architectures look like this:

HTTP webhook  ->  HMAC verify  ->  enqueue to internal queue  ->  return 200
                                              |
                                              v
                                    queue worker (with retries)
                                              |
                                              v
                                    business logic + downstream calls

This decouples the webhook acknowledgement (must be fast) from the actual work (can be slow). The internal queue handles short-term retries with finer-grained backoff than the webhook sender. Failed jobs land in a dead letter queue (DLQ) for inspection.

Dead letter queues

A DLQ is a holding pen for events that have failed processing repeatedly. Move events there after exhausting your internal retry budget (typically 3-10 attempts). DLQ contents need:

  • The original event in full, untransformed.
  • The failure history — what error each attempt produced, when.
  • Alerting — non-trivial DLQ accumulation should page someone. A DLQ that grows without anyone noticing means events are being lost.
  • A replay mechanism — once the underlying issue is fixed, you need to be able to push DLQ events back into the main queue. Build a tool for this; do not rely on writing ad-hoc SQL at 3am.

Common queue choices

  • AWS SQS + Lambda — managed, dirt cheap, native DLQ support. Default for AWS-native stacks.
  • Google Cloud Tasks / Cloud Pub/Sub — same shape on GCP.
  • BullMQ (Redis) — Node.js-native, fast, great dev experience. Good for moderate volumes.
  • RabbitMQ — mature, language-agnostic, full AMQP semantics. The classic enterprise choice.
  • Kafka — for very high volumes; overkill for typical webhook receivers.
  • Temporal / Inngest — durable workflow engines; useful if your "process this webhook" workflow is itself complex with multiple steps.
The ratio that tells you your retry strategy is right: events that succeed first try / events that succeed eventually / events in DLQ. In healthy systems this is roughly 99.5 / 0.49 / 0.01 — almost everything works first try, a tiny fraction needs retries due to transient downstream issues, a vanishing fraction lands in DLQ for human attention. If your DLQ ratio is above 1%, something is broken upstream. If you are retrying more than 5% of events, your downstream system is unstable and the webhook handler is just hiding it.

08Testing Webhooks Locally

Webhooks are tricky to test because senders need a public URL but you want to develop on your laptop. The standard solution is a tunnelling tool that exposes your localhost on a temporary public URL, plus replay tools for repeated testing.

The tunnelling tools

ToolPricingBest for
ngrokFree tier with random URL; paid for stable subdomainsThe default. Mature, fast, has a request inspector.
Cloudflare TunnelFree for casual use; bundled with CloudflareIf you already use Cloudflare; supports custom domains.
localtunnelFree, open-sourceQuick demos; less stable than commercial alternatives.
Tailscale FunnelFree for personal useIf your dev environment is already in Tailscale.
frp / chiselOpen-source, self-hostedYou want a tunnel under your own control.

Quick start with ngrok

Expose localhost:3000 on a temporary public URL

# Install ngrok (one-time)
brew install ngrok        # macOS
# or download from ngrok.com

# Start your webhook receiver locally
node server.js            # listening on localhost:3000

# In another terminal, open the tunnel
ngrok http 3000

# ngrok prints something like:
# Forwarding   https://abc123.ngrok-free.app -> http://localhost:3000

# Configure the webhook source to send to:
# https://abc123.ngrok-free.app/webhooks/team-connect

ngrok also runs a local web inspector at http://localhost:4040 showing the full request/response of every webhook delivery. This is invaluable when debugging signature verification and payload schema issues — you can see exactly what the sender sent, byte for byte.

Replay-based testing

Tunnelling lets you test against the real sender, but iterating that way is slow. For tighter feedback, capture a real webhook payload once, then replay it locally as many times as you need.

Capture a real webhook payload to a file

# With ngrok inspector running, copy a real webhook request body
# Save it as fixture.json

# Replay it from your terminal as many times as you need
curl -X POST http://localhost:3000/webhooks/team-connect \
  -H "Content-Type: application/json" \
  -H "X-TeamConnect-Event: call.completed" \
  -H "X-TeamConnect-Delivery: test-$(uuidgen)" \
  -H "X-TeamConnect-Timestamp: $(date +%s)" \
  -H "X-TeamConnect-Signature: sha256=$(openssl dgst -sha256 -hmac \
    "$WEBHOOK_SECRET" -binary < fixture.json | xxd -p -c 256)" \
  --data-binary @fixture.json

Once you have a replay command working, drop it into a shell script and use it whenever you change handler code. The whole feedback loop drops to seconds. For idempotency testing, run the replay 10 times in a loop and verify your downstream state matches.

Provider-specific test tools

Most providers ship some form of in-dashboard test tool:

  • Stripe CLIstripe listen --forward-to localhost:3000/webhook creates a Stripe-backed tunnel and lets you trigger every event type from the command line.
  • GitHub webhook redelivery — from the repo settings → webhooks page, you can resend any past delivery for debugging.
  • Slack event replay — the events API dashboard shows recent deliveries and lets you replay them.
  • Twilio Console — "test the webhook" button for each application.
  • Team-Connect dashboard — from Webhooks → Test you can fire any event type at your URL with a real signature for any of your registered endpoints.

webhook.site for quick payload inspection

If you just want to see what a webhook payload looks like without writing a handler, webhook.site gives you a free disposable URL that captures every request to it and shows the full payload. Useful for early exploration when you are evaluating a new provider's webhook format.

Build the replay script as part of your project, not a one-off. Check it into your repo, document it in the README, parameterise the event type. Three months from now when production webhooks fail and you need to reproduce locally, the replay script will save your evening. Future-you will thank present-you.

09Team-Connect Webhook Events

Team-Connect emits webhooks for the call, SMS and agent lifecycle events that integrations care about. Subscribe to the ones you need from the dashboard at Settings → Webhooks; events you do not subscribe to are not sent. All webhooks are HMAC-signed (SHA-256) with the secret displayed when you create the webhook endpoint.

Call events

Event typeWhen it firesUse it for
call.startedInbound or outbound call connectedReal-time CRM screen-pop, attendance tracking
call.answeredCaller is on the line with an agentBegin call recording, start session timer
call.endedCall completed (any reason)Update call records, trigger post-call workflows
call.missedInbound call was not answeredOutbound callback queue, missed-call SMS auto-reply
call.voicemailCaller left a voicemailTranscription and forwarding, ticket creation
call.recording.completedCall recording is processed and readyStorage, transcription pipeline, compliance archival
call.transcript.completedCall transcript ready (uses our voice recognition)Sentiment analysis, summary generation, search indexing

SMS events

Event typeWhen it firesUse it for
sms.receivedInbound SMS arrivesAuto-responders, conversation logging, agent routing
sms.sentOutbound SMS dispatched to carrierConversation logging, send-rate dashboards
sms.deliveredCarrier confirmed deliveryDelivery analytics, retry decisions
sms.failedSMS could not be deliveredBounce handling, contact list cleaning, alternative-channel fallback

Agent and AI events

Event typeWhen it firesUse it for
agent.action.completedThe AI agent finished an action mid-call (booking, lookup, transfer)Audit logs, business workflow triggers
agent.handoffAI handed the call to a humanCRM screen-pop, agent notification, context transfer
agent.errorAI agent encountered an unrecoverable error during a callAlerting, post-mortem investigation

The Team-Connect signature scheme

Every webhook arrives with these headers:

  • X-TeamConnect-Event — the event type (call.completed etc).
  • X-TeamConnect-Delivery — unique delivery ID. Use for idempotency.
  • X-TeamConnect-Timestamp — Unix seconds of dispatch.
  • X-TeamConnect-Signaturesha256=<hex> where the hex is HMAC-SHA256 of the request body using your endpoint's secret.

The verification code is exactly what is shown in section 05. The shared secret is displayed once when you create the webhook in the dashboard; copy it to your environment variable storage immediately, you cannot retrieve it later.

Event delivery and retries at Team-Connect

  • Retries continue for up to 24 hours: immediate, 30s, 2min, 10min, 30min, 1h, 2h, 4h, 8h, 16h.
  • Any 2xx response stops retries. Any non-2xx or timeout (10 seconds) triggers the next retry.
  • After all retries exhausted, the event is moved to a dead letter store you can inspect and manually replay from the dashboard.
  • Webhook delivery is best-effort within those windows; for guaranteed processing, use the dedupe pattern in section 06 and reconcile against our REST API on a schedule.
Subscribe narrowly: if you only need call.ended, do not subscribe to all call events. Less webhook traffic means less signature verification, less dedupe storage, less queue work, fewer surprises when we add new event types in future. The webhook subscription is a filter; use it.

10Common Webhook Issues and Troubleshooting

Webhook failures cluster into about seven recurring patterns. Triage in this order.

Signature verification fails on every request

Almost always one of three things: (1) you are computing HMAC over parsed-and-reserialised JSON instead of the raw body bytes — check that your framework is not parsing the body before you read it; (2) wrong secret — double-check the secret in your environment matches the one shown in the sender's dashboard, including any trailing whitespace; (3) wrong algorithm or hex encoding — make sure you are using SHA-256 (not SHA-1 or MD5) and outputting hex (not base64 unless the sender uses base64). Compare bytes against a known-good reference implementation.

Signature works in dev but fails in production

Usually a body-parser middleware ordering issue specific to production builds. The middleware stack differs between dev and production (compression, body parsing, request logging) and one of those layers is consuming the body before your verification code sees it. Confirm by logging the raw body length in production — if it is zero or different from the request's Content-Length, your body is being consumed by something earlier in the pipeline.

Webhooks arrive but processing never completes

Your handler accepted the event with 200 (so the sender is happy), but downstream work is silently failing. Check your internal queue/DLQ — events are probably accumulating there. Common downstream failures: database constraint violations on missing fields you assumed would always be present, downstream service rate-limiting your retries, or background workers crashed and not restarted. Always alert on DLQ growth.

Some events are missing entirely

Webhook providers do not guarantee delivery indefinitely — if your endpoint is down for longer than the sender's retry window (typically 24-72 hours), events are lost forever. Mitigations: (1) never let webhook handlers go offline for that long, monitor uptime aggressively; (2) reconcile against the sender's REST API on a regular schedule (every 15 minutes for high-value events) to catch anything missing; (3) for critical workflows, do not rely on webhooks alone — use them as a fast-path notification, with a slower API poll as the source of truth.

Sender keeps retrying even though my handler returns 200

You are probably returning 200 too late — after the sender's timeout (5-10 seconds). Check your handler's response time at p95 and p99; if it is approaching the timeout, you have done too much work synchronously. Move the actual processing to an async queue and return 200 within milliseconds of receiving the event.

Schema changed in production and broke my handler

The sender added a new optional field, deprecated an old one, or changed the meaning of a value, and your handler crashed. Mitigations: (1) treat unknown fields as ignorable rather than parse-error; (2) always check for field presence before accessing; (3) subscribe to the sender's changelog or version-pin your webhooks to a specific API version if the sender supports it; (4) wrap the entire handler in a try/catch that logs the full payload on any uncaught error so you can debug schema surprises after the fact.

Replay attack — the same valid webhook keeps arriving

Two cases: legitimate retries (sender did not see your 200) or actual replay attack (someone captured a request and is resending it). Idempotency by event ID handles both safely. If you suspect attack rather than retry, check whether the timestamp header matches the time you would expect for current activity — old timestamps indicate replay rather than retry.

Audit your webhook log regularly: at least weekly, sample 50 random webhook deliveries from your handler logs and check (a) signatures verified successfully, (b) body parsed cleanly, (c) downstream processing succeeded, (d) response time was healthy. Quality issues silently drift — new event types, schema additions, sender's TLS cert renewals, your own framework upgrades all introduce subtle regressions. Teams that catch these early have the audit habit; teams that learn the hard way do not.

Webhook Integration FAQs

The questions our customers ask most often when integrating Team-Connect webhooks into their systems.

What is a webhook?

A webhook is an HTTP callback — an automated POST request from one server to another, triggered by some event in the source system. Instead of your application repeatedly polling an API to check whether something happened, the source system pushes events to a URL you provide as soon as they occur. Webhooks are the standard pattern for event-driven integrations: payment notifications, call completions, message status updates, CI build results, and similar.

What is the difference between a webhook and an API call?

Direction. A regular API call is your application asking a remote server for data (you initiate the request, the server responds). A webhook is a remote server asking your application to do something (the remote server initiates, your endpoint responds). Both use HTTP, but the webhook flow is server-to-server push instead of client-to-server pull. Most APIs use both: you call the API to set things up, the API calls your webhook URL when events happen.

How do I verify a webhook is genuine?

Use HMAC signature verification. The sender computes an HMAC (typically HMAC-SHA256) of the raw request body using a shared secret only the two of you know, and includes the signature in a header (commonly X-Signature, X-Hub-Signature-256, or similar). Your handler recomputes the HMAC over the received body using the same secret, and uses a timing-safe comparison to confirm the signatures match. If they do not match, the request did not come from the genuine sender (or someone tampered with it) - reject it. Never trust a webhook based on source IP alone; never accept unsigned webhooks in production.

What is webhook idempotency?

Idempotency means handling the same event twice has the same effect as handling it once. Webhook providers retry events when they cannot get a 2xx response, which means your handler will sometimes receive duplicates. If you blindly process every webhook, duplicates will cause double-charges, double-emails, double-bookings or other corruption. The fix: every webhook event has a unique event ID; store processed event IDs in a database and skip events you have already seen. This makes your handler safe to retry, which is exactly what webhook providers will do when your endpoint is briefly unavailable.

How do webhooks handle retries?

Most webhook providers retry on any non-2xx HTTP response or timeout. The retry schedule is typically exponential backoff over hours or days: a few seconds, then minutes, then hours, until eventually giving up after 24-72 hours. Stripe retries for 3 days, GitHub for 8 hours, Twilio for 24 hours. As a receiver, your job is to (a) return 2xx within a few seconds for events you successfully accepted, (b) return 5xx for transient failures so you get retries, and (c) be idempotent so retries do not cause duplicates. Returning 4xx tells the sender the event was malformed and should not be retried.

How do I test webhooks locally?

Use a tunnelling tool like ngrok or Cloudflare Tunnel to expose your localhost to the public internet on a temporary URL. Configure the webhook source to send to that URL. ngrok also provides a request inspector at localhost:4040 showing the full payload of every incoming request — invaluable for debugging signature verification and payload schema. For replay-style testing, capture a real webhook payload once with ngrok, then replay it from your terminal using curl or HTTPie until your handler is correct. Many providers (Stripe, GitHub, Slack) also offer a built-in 'send test event' button in their dashboard.

What HTTP status code should a webhook handler return?

Return 200 OK or 204 No Content for events you successfully received and accepted — even if you have not finished processing them yet. The response body is usually ignored by the sender. Return 4xx for permanently invalid events (signature failure, malformed payload) — these will not be retried. Return 5xx for transient failures (database temporarily down, downstream service unavailable) — these will be retried. Critically, do not block the webhook response on slow downstream work: accept the event with 200, queue the work, do it asynchronously. Webhook senders typically time out after 5-10 seconds.

Are webhooks secure?

Webhooks can be secure if implemented correctly. The minimum requirements are: HTTPS (never accept webhooks over plain HTTP), HMAC signature verification on every request using a strong shared secret, timestamp validation to prevent replay attacks (reject events older than a few minutes), and rate limiting to prevent abuse. Optionally add IP allowlisting (only useful if the sender publishes a stable IP range), mTLS for very high-security scenarios, and audit logging of all webhook receipts. The single biggest production failure mode is skipping signature verification because "it works without it" — never run that way.

Continue Reading

Webhooks are how Team-Connect tells your system about events. To go deeper into the rest of the integration surface:

API Documentation → Voice Recognition Setup → TTS Configuration → SIP Protocol Basics → WebRTC Integration → AI Receptionist →