All docs

Local Development & Tunnels

You're building a webhook handler and you want to point a real schedule at it — but it's running on localhost:3000, and SendItWhenever refuses to fire there. This page explains why, and the tunnel pattern that lets you debug against your own machine in a couple of minutes.

Why localhost can't be a target

SendItWhenever makes outbound HTTP requests on your behalf, so it defends hard against SSRF. A targetUrl must be a public https:// URL, and immediately before firing we re-resolve the hostname and reject anything that points at a private or reserved address:

  • localhost, 127.0.0.1, ::1
  • Private networks (10.x, 172.16–31.x, 192.168.x)
  • Link-local and cloud metadata (169.254.x)

This is a hard invariant — there is no "dev mode" flag that relaxes it. Your laptop simply isn't reachable from the public internet, so the relay has nowhere to send the request.

The fix isn't to weaken the check. It's to give your local handler a real public URL with a tunnel.

The tunnel pattern

A tunnel exposes a process running on your machine at a public HTTPS URL, forwarding incoming requests back to your local port. You register that public URL as the targetUrl; SendItWhenever sees an ordinary public endpoint and fires normally. The tunnel — not SendItWhenever — is what bridges back to localhost.

SendItWhenever  ──https──▶  abcd-1234.ngrok-free.app  ──▶  localhost:3000
   (public relay)              (public tunnel URL)          (your handler)

1. Run your handler

Start your webhook endpoint locally as usual:

# whatever your app is — this just needs to be listening on a port
node server.js   # listening on http://localhost:3000

2. Open a tunnel

Use any tunnel that hands you a public HTTPS URL. Two common choices:

# ngrok — https://ngrok.com
ngrok http 3000

# or Cloudflare Tunnel — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
cloudflared tunnel --url http://localhost:3000

Each prints a public URL, e.g. https://abcd-1234.ngrok-free.app. That URL is HTTPS on port 443 and resolves to a public address, so it passes every SSRF check.

3. Schedule against the tunnel URL

Point your schedule at the tunnel's public URL plus your handler's path:

import { SendIt } from "@sendithq/sdk";

const sendit = new SendIt("sw_live_xxx");

await sendit.schedule({
  url: "https://abcd-1234.ngrok-free.app/webhooks/incoming",
  in: "30s",
  payload: { hello: "from my laptop" },
});

Thirty seconds later the request lands on localhost:3000/webhooks/incoming, signed exactly as production traffic would be. Watch your server logs (and the tunnel's request inspector, e.g. ngrok's at http://localhost:4040) to see it arrive.

Verify the signature while you're at it

Every webhook we send — local or not — carries an X-SendIt-Signature header. Local development is the perfect time to wire up verification so it's already correct before you deploy:

import express from "express";
import { SendIt } from "@sendithq/sdk";

const sendit = new SendIt("sw_live_xxx", {
  signingSecret: process.env.SENDIT_SIGNING_SECRET, // from your dashboard
});

const app = express();

app.post("/webhooks/incoming", express.raw({ type: "*/*" }), (req, res) => {
  const ok = sendit.verifySignature({
    headers: req.headers,
    rawBody: req.body, // the raw bytes — a Buffer, not parsed JSON
  });
  if (!ok) return res.status(401).end();
  // ... handle the event
  res.sendStatus(200);
});

See Verifying Webhooks for the full details and secret rotation.

Tips & gotchas

  • The tunnel must stay open. If you close it before the fire time, the delivery fails and retries with backoff; if it's still down when retries are exhausted, the schedule goes to the dead-letter queue (dead). For quick tests, use short delays like in: "30s".
  • Rotating URLs. Free ngrok URLs change each restart. If you scheduled something minutes ahead and then restarted the tunnel, the old URL no longer resolves to you. Reserve a static domain (paid tunnels) or just keep the tunnel running for the test.
  • Return 2xx fast. A schedule is succeeded only when your endpoint returns a 2xx. Slow handlers may hit the delivery timeout and be retried.
  • This is for debugging, not production. Tunnels are great for seeing real signed traffic hit your code. For production, deploy your handler to a real public URL.

One command: sendit listen

The SDK ships a small CLI that wraps the whole loop — it runs a local receiver, opens a tunnel for you, prints the public URL, and streams every incoming webhook to your terminal with its signature already verified. It comes with @sendithq/sdk, so if the package is installed you can run it with npx:

npx sendit listen --secret "$SENDIT_SIGNING_SECRET"

That:

  1. Starts a receiver on http://localhost:3000 (change with --port).
  2. Opens a tunnel — it uses ngrok if it's installed, otherwise cloudflared — and prints the public URL.
  3. Logs each delivery as it lands, marked verified, BAD SIGNATURE, or no signature header.

Take the printed public URL, append your handler's path, and use it as the schedule's targetUrl:

sendit listen — receiver on http://localhost:3000
  starting ngrok…

✓ public URL: https://abcd-1234.ngrok-free.app
  point a schedule's targetUrl at https://abcd-1234.ngrok-free.app/<your-path>

✓ #1  POST /webhooks/incoming  ·  verified
   content-type: application/json
   body: {"hello":"from my laptop"}

If you don't pass --secret (or set SENDIT_SIGNING_SECRET), the receiver still logs requests but can't check signatures — it prints unverified instead.

Options

Flag Default Description
-p, --port <n> 3000 Local port the receiver binds to.
-s, --secret <s> $SENDIT_SIGNING_SECRET Signing secret used to verify incoming webhooks.
-t, --tunnel <kind> auto auto (ngrok→cloudflared), ngrok, cloudflared, or none.
--tolerance <n> 300 Signature timestamp tolerance, in seconds.

Use --tunnel none when you already run your own tunnel — sendit listen then just receives and verifies on the chosen port, and you point your tunnel at it.

The CLI is a convenience over the tunnel pattern above; it does not change the SSRF rules. The tunnel is what provides the public URL — localhost is still never a valid targetUrl.