Guide

Test Intercom webhooks locally ngrok, localhost, webhook replay

Verify Intercom requests correctly on localhost using the exact headers and signing inputs used in production. Then iterate faster with durable capture + replay (or avoid inbound entirely by consuming from a queue).

No credit card required. See pricing.

TL;DR

  • If you must receive webhooks on localhost, use a tunnel (ngrok/cloudflared) with a stable HTTPS URL.
  • Verify authenticity over the raw request body (bytes), before JSON parsing or any side effects.
  • For Intercom, check `x-hub-signature` and implement the exact signing input used by the strategy.
  • Persist one real payload + headers as a fixture so you can replay it deterministically while debugging.
  • Add a webhook replay workflow (saved fixtures → processing) so regressions are easy to reproduce.
  • For faster iteration, avoid inbound entirely: receive in Hooque and pull messages locally from a consumer queue.

Anti-patterns

  • Parsing or re-stringifying JSON before verification (you will verify different bytes).
  • Hardcoding ephemeral tunnel URLs into provider settings.
  • Logging secrets/signature headers or storing unsanitized fixtures with PII.

Want an end-to-end example? See CRM webhooks.

Core concepts

Local webhook development fails for two predictable reasons: inbound traffic is fragile and signature verification is byte-sensitive.

Signed bytes

Most strategies verify a signature over the raw request body. Verifying a parsed object (or re-stringified JSON) will fail.

Exact inputs

This strategy uses HMAC-SHA1 over the raw body; expected header value "sha1=<hex>". Start by confirming the required headers are present.

Replay loop

When verification fails, capture one real request (raw body + headers), sanitize it, and replay deterministically until it passes.

Required headers (from the ingest strategy)

Confirm these are present before debugging signature math.

x-hub-signature

If you keep getting false negatives, see Webhook security (intercom).

Normal flow vs With Hooque flow

Two valid local workflows: accept inbound via a tunnel, or move inbound to a managed receiver and pull locally.

Normal local flow

  1. Provider emits an event
  2. Tunnel (ngrok/cloudflared) forwards to your laptop
  3. Local endpoint verifies auth over raw bytes (headers + signing string)
  4. Handler does side effects (DB/external APIs)
  5. Any slowness or 5xx → retries and duplicates
  6. If your laptop sleeps or your server is offline, deliveries fail and you miss real-time debugging

With Hooque

  1. Provider → Hooque ingest (verification at ingest time)
  2. Payload lands in a durable queue
  3. Developer pulls locally (REST next-message) or streams via SSE
  4. Developer acks/nacks/rejects explicitly
  5. Webhook replay/redelivery via UI after fixes
  6. Ingest stays online when your laptop is offline; events queue until you resume

Local dev checklist

Use this checklist when you are setting up tunnels, verifying signatures, and building a replay workflow.

- [ ] Choose approach:
  - [ ] Tunnel inbound (ngrok/cloudflared) OR
  - [ ] Managed receiver + local consumer (recommended for iteration)
- [ ] Use HTTPS and a stable URL (avoid frequent URL churn)
- [ ] Capture raw payload bytes + relevant headers (especially `x-hub-signature`)
- [ ] Verify the signature/token over the same input as production (HMAC-SHA1 over the raw body; expected header value "sha1=<hex>")
- [ ] Save one real payload + headers as a replay fixture
- [ ] Return 2xx/202 quickly; do side effects asynchronously
- [ ] Store a dedupe key (provider event id / delivery id if available) to tolerate retries/duplicates
- [ ] Add a webhook replay path (fixtures → processing) and run it in CI
- [ ] Keep secrets out of logs and sanitize fixtures before sharing

Reference implementation

Two minimal pieces: local verification (strategy-specific) and a local consumer loop (pull + Ack/Nack/Reject).

A) Minimal local verification

Header names and signing strings are derived from the ingest strategy. Do not invent provider details.

Node

Express raw body + verification

// Minimal receiver: verify the signature over the raw request body (bytes)
// npm i express
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/webhook",
  express.raw({ type: "*/*", limit: "2mb" }),
  (req, res) => {
    const secret = process.env.WEBHOOK_SECRET ?? "replace_me";
    const signature = req.header("x-hub-signature") ?? "";
    const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");

    if (!signature) return res.status(401).send("missing signature header");

    const payload = rawBody.toString("utf8");
    const digest = crypto.createHmac("sha1", secret).update(payload, "utf8").digest("hex");
    const expected = "sha1=" + digest;

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length) return res.status(401).send("bad signature");
    if (!crypto.timingSafeEqual(a, b)) return res.status(401).send("bad signature");

    // Authentic. Ack fast.
    res.sendStatus(202);
  }
);

app.listen(3000, () => console.log("listening on :3000"));

Python

FastAPI raw body + verification

# Minimal receiver: verify the signature over the raw request body (bytes)
# pip install fastapi uvicorn
import hashlib
import hmac
import os
from fastapi import FastAPI, Request, Response

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request) -> Response:
    secret = os.getenv("WEBHOOK_SECRET", "replace_me").encode("utf-8")
    signature = request.headers.get("x-hub-signature", "")
    raw_body = await request.body()

    if not signature:
        return Response("missing signature header", status_code=401)

    payload = raw_body
    digest = hmac.new(secret, payload, hashlib.sha1).hexdigest()
    expected = "sha1=" + digest

    if not hmac.compare_digest(expected, signature):
        return Response("bad signature", status_code=401)

    # Authentic. Ack fast.
    return Response(status_code=202)

# Run: uvicorn app:app --reload --port 3000

B) Minimal Hooque consumer loop (local)

Pull events locally, then explicitly Ack (done), Nack (retry later), or Reject (permanent failure / DLQ).

Node

GET next + X-Hooque-Meta + Ack/Nack/Reject

// Minimal local consumer loop (REST pull):
// 1) GET .../next
// 2) parse X-Hooque-Meta (ackUrl/nackUrl/rejectUrl)
// 3) POST ackUrl on success, otherwise nack/reject
//
// Node 18+ (fetch built-in)
const QUEUE_NEXT_URL =
  process.env.HOOQUE_QUEUE_NEXT_URL ??
  "https://app.hooque.io/queues/cons_dev_webhooks/next";
const TOKEN = process.env.HOOQUE_TOKEN ?? "hq_tok_replace_me";

const headers = { Authorization: `Bearer ${TOKEN}` };

async function processLocally(payload) {
  // TODO: call your local code paths here
  console.log("received event:", payload?.type ?? payload?.id ?? "event");
}

while (true) {
  const resp = await fetch(QUEUE_NEXT_URL, { headers });

  if (resp.status === 204) break; // queue is empty
  if (!resp.ok) throw new Error(`Hooque next() failed: ${resp.status}`);

  const payload = await resp.json();
  const meta = JSON.parse(resp.headers.get("X-Hooque-Meta") ?? "{}");

  try {
    await processLocally(payload);
    await fetch(meta.ackUrl, { method: "POST", headers });
  } catch (err) {
    const reason = String(err);
    const isPermanent = err?.name === "ValidationError";
    const url = isPermanent ? meta.rejectUrl : meta.nackUrl;

    await fetch(url, {
      method: "POST",
      headers: { ...headers, "Content-Type": "application/json" },
      body: JSON.stringify({ reason }),
    });
  }
}

Python

GET next + X-Hooque-Meta + Ack/Nack/Reject

# Minimal local consumer loop (REST pull):
# 1) GET .../next
# 2) parse X-Hooque-Meta (ackUrl/nackUrl/rejectUrl)
# 3) POST ackUrl on success, otherwise nack/reject
import json
import os
import requests

QUEUE_NEXT_URL = os.getenv(
    "HOOQUE_QUEUE_NEXT_URL",
    "https://app.hooque.io/queues/cons_dev_webhooks/next",
)
TOKEN = os.getenv("HOOQUE_TOKEN", "hq_tok_replace_me")
headers = {"Authorization": f"Bearer {TOKEN}"}

def process_locally(payload: dict) -> None:
    print("received event:", payload.get("type") or payload.get("id") or "event")
    # TODO: call your local code paths here

while True:
    resp = requests.get(QUEUE_NEXT_URL, headers=headers, timeout=30)

    if resp.status_code == 204:
        break
    if resp.status_code >= 400:
        raise RuntimeError(f"Hooque next() failed: {resp.status_code} {resp.text}")

    payload = resp.json()
    meta = json.loads(resp.headers.get("X-Hooque-Meta", "{}"))

    try:
        process_locally(payload)
        requests.post(meta["ackUrl"], headers=headers, timeout=30)
    except Exception as err:
        reason = str(err)
        is_permanent = err.__class__.__name__ == "ValidationError"
        url = meta["rejectUrl"] if is_permanent else meta["nackUrl"]
        requests.post(
            url,
            headers={**headers, "Content-Type": "application/json"},
            json={"reason": reason},
            timeout=30,
        )

Common failure modes

Most “it fails locally” issues are header mismatch, raw-body mismatch, or timestamp window problems.

401 / missing signature header

Likely causes

  • Provider is not configured to sign requests for this endpoint.
  • You are reading the wrong header name (case/typo/incorrect environment).
  • A proxy/tunnel is stripping or renaming headers.

Next checks

  • Log only header names (not values) and confirm `x-hub-signature` are present.
  • Verify your tunnel forwards headers unchanged.
  • Make sure you are testing the same provider environment (test vs prod).

Signature mismatch locally, but works elsewhere

Likely causes

  • You verified parsed JSON instead of raw body bytes.
  • Whitespace/newline changes (body parser differences) changed the signed bytes.
  • Wrong secret/public key (environment mismatch).

Next checks

  • Capture the raw body bytes and verify against that exact byte sequence.
  • Ensure your handler uses raw-body middleware before any parsing.
  • Rotate or re-copy the secret and confirm you are using the correct one.

How Hooque helps

Hooque changes local development from “debug inbound tunnels” to “consume verified messages and replay deterministically.”

  • Verify at ingest: only verified Intercom events enter your queue.
  • Local consumer loop: pull events to localhost and Ack/Nack/Reject explicitly (no inbound required for many workflows).
  • Durable capture + inspection: keep the exact payload and metadata that failed, then replay after a fix.
  • Strategy-specific docs: security and retries & backoff.
  • See a real workflow: CRM webhooks. Compare plans on pricing.

FAQ

Common questions that come up when you are trying to verify signatures and test webhook delivery on localhost.

How do I receive Intercom webhooks on localhost?

General: Use a tunnel (ngrok/cloudflared) to forward a stable HTTPS URL to your local server, then point the provider’s webhook URL at that tunnel endpoint. How Hooque helps: You can avoid inbound exposure entirely by receiving in Hooque and consuming locally via REST pull or SSE stream (great for fast iteration).

Why does verification fail locally even with the right secret?

General: Most false negatives come from verifying different bytes than the provider signed (JSON parsing, whitespace changes, encoding) or from reading the wrong header name. How Hooque helps: Hooque verifies at ingest using the strategy inputs and stores the received payload for inspection and webhook replay, so you can debug without re-triggering the provider.

Do I need the raw request body to verify signatures?

General: Yes in most schemes. If the provider signs raw bytes, verifying a parsed object or re-stringified JSON will fail. Capture the raw bytes before any middleware mutates them. How Hooque helps: Hooque verifies signatures at ingest time and can deliver verified payloads to your consumer queue, so your local worker can focus on processing.

What headers should I log while debugging?

General: Log header names and a request ID if present, but avoid logging secrets. For this strategy, `x-hub-signature` are the first place to check. How Hooque helps: Hooque stores the delivery metadata alongside the payload and gives you an inspectable history, reducing the need for verbose logging in your local tunnel.

How do I safely do webhook replay during local development?

General: Store sanitized fixtures (raw body + required headers) and replay them through your processing code. Make processing idempotent so repeated replays are safe. How Hooque helps: Hooque provides durable storage and controlled redelivery, so you can replay a specific failed delivery after a fix and track outcomes (Ack/Nack/Reject).

ngrok vs Cloudflare Tunnel: which should I use?

General: Pick the one that gives you a stable HTTPS URL and forwards headers and raw bodies reliably. The best option is the one your team can keep consistent across dev machines. How Hooque helps: If tunnels are flaky or slow, Hooque’s “receive once, consume locally” workflow removes inbound tunnels from your hot loop.

What status code should I return while debugging?

General: Return 2xx/202 quickly after successful verification, then process asynchronously. Avoid returning 5xx for known-bad payloads or you will trigger retries and duplicates. How Hooque helps: With explicit Ack/Nack/Reject, you can separate “accepted” from “processed”, and replay without relying on provider retry behavior.

Start processing webhooks reliably

Capture every webhook, persist instantly, and consume via REST or SSE with full Ack/Nack/Reject control.

Start for free

No credit card required