Horizon

Test DocuSign Connect webhooks locally

Receive DocuSign Connect events on localhost with a Horizon tunnel, and verify the HMAC signature in a Next.js route handler.

Receive DocuSign Connect events on your laptop while you build, with a public HTTPS URL DocuSign can reach.

Horizon has no DocuSign integration. Connect sends webhooks to a public URL, and Horizon provides that URL.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A DocuSign developer (demo) account with administrator access to Connect
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

When you turn on HMAC for a Connect configuration, DocuSign computes an HMAC-SHA256 (a keyed hash) of the raw request body with your secret key. It Base64-encodes the result and sends it in X-DocuSign-Signature-1. If you have several active keys, DocuSign sends one header per key: X-DocuSign-Signature-2, and so on. A delivery is valid when any one of them matches your key.

Create a route handler. Read the raw body with request.text(). Don't parse it first, because the signature covers the exact bytes.

app/api/webhooks/docusign/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const SIGNATURE_HEADER_PREFIX = "x-docusign-signature-";

export async function POST(request: Request) {
  const secret = process.env.DOCUSIGN_CONNECT_HMAC_KEY;
  if (!secret) {
    return new Response("Missing DOCUSIGN_CONNECT_HMAC_KEY", { status: 500 });
  }

  const body = await request.text();
  const expected = createHmac("sha256", secret).update(body).digest();

  const signatures = [...request.headers]
    .filter(([name]) => name.startsWith(SIGNATURE_HEADER_PREFIX))
    .map(([, value]) => Buffer.from(value, "base64"));

  const isValid = signatures.some(
    (signature) =>
      signature.length === expected.length && timingSafeEqual(signature, expected),
  );

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const payload = JSON.parse(body);
  console.log(`Received DocuSign event: ${payload.event}`);

  return new Response("ok", { status: 200 });
}

Reply with 200 quickly. DocuSign logs a failure for any other status.

You get the key in a later step. Add it to .env.local once you have it:

.env.local
DOCUSIGN_CONNECT_HMAC_KEY=replace-with-your-connect-key

Start the app:

npm run dev

Start a tunnel

In a second terminal, open a tunnel to port 3000 on a subdomain you reserved, with -s:

hrzn tunnel http://localhost:3000 -s my-docusign-app
Output
HORIZON: Tunnel connected
  URL          https://my-docusign-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-docusign-app

Your public URL is https://my-docusign-app.hrzn.run. Without -s the subdomain is random and changes on every run, so your Connect configuration would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

Create a Connect key

  1. Sign in to DocuSign and open Settings.
  2. Under Integrations, select Connect.
  3. Select Connect Keys.
  4. Select Add Secret Key.
  5. Copy the key into .env.local as DOCUSIGN_CONNECT_HMAC_KEY, then restart npm run dev.

DocuSign shows the key value only when you create it. Copy it now.

Add the Connect configuration

Your endpoint URL is the tunnel URL plus the route path: https://my-docusign-app.hrzn.run/api/webhooks/docusign.

  1. On the Connect page, select Add Configuration, then Custom.
  2. Enter a name for the configuration.
  3. Enter the endpoint URL as URL to Publish.
  4. Select Include HMAC Signature.
  5. Select the envelope events you want, and choose JSON as the data format. The handler parses JSON.
  6. Save the configuration.

Trigger an event

Send an envelope from your demo account and complete it. DocuSign then posts the matching event to your URL.

To resend an event, open Settings, Connect, then Publish. DocuSign lists envelopes with recent events there, and an admin can republish them. This works for deliveries that failed, or for envelopes you want to send again.

Check it works

After the envelope event fires, the Horizon terminal prints one line:

Output
  POST    200  /api/webhooks/docusign

Your app terminal prints:

Output
Received DocuSign event: envelope-completed

To see DocuSign's side, open Settings, Connect, then Logs. It shows the most recent 100 logs.

Troubleshooting

The signature doesn't match

The Horizon line shows [401]. Check these in order:

  • DOCUSIGN_CONNECT_HMAC_KEY matches the key you created under Connect Keys. Restart npm run dev after you edit .env.local.
  • The handler hashes the raw body. DocuSign says to treat the payload as bytes. Reading it as a parsed object or re-serializing it makes the hash differ.
  • The signature is Base64, not hex. Compare decoded bytes, as the handler does.
  • Include HMAC Signature is selected on the configuration. Without it, DocuSign sends no signature headers.

Only one of two keys works

When you have several active keys, DocuSign sends one signature header per key. The handler above checks every X-DocuSign-Signature-N header. A handler that reads only X-DocuSign-Signature-1 fails when you rotate keys.

DocuSign reports failures and nothing arrives

  • Check that the Horizon terminal and npm run dev are both running.
  • Check that URL to Publish ends with /api/webhooks/docusign.
  • Open Settings, Connect, then Logs to read the error DocuSign recorded.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-docusign-app and update URL to Publish if it differs.

Next steps

On this page