Horizon

Test Coinbase webhooks locally

Receive Coinbase Business checkout webhook events on localhost with a Horizon tunnel, and verify the X-Hook0-Signature header.

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

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

This guide covers the checkout webhooks of Coinbase Business. You create the subscription with the CDP CLI, not in a dashboard.

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 CDP API key (key ID and secret), saved as cdp_api_key.json
  • The CDP CLI, configured once with cdp env live --key-file ./cdp_api_key.json
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Each webhook carries an X-Hook0-Signature header. The header holds three fields: t is the timestamp, h is a space-separated list of header names that are part of the signature, and v1 is the signature. The signed string is {t}.{h}.{header values}.{raw body}, where the header values are the values of the headers listed in h, joined with full stops. Coinbase hashes it with HMAC-SHA-256 (a keyed hash), keyed with your subscription secret, and hex encodes the result.

Coinbase documents no SDK helper for this, so the handler does the check with node:crypto. It reads the raw body with request.text(), compares with timingSafeEqual and rejects webhooks older than 5 minutes, as Coinbase's sample code does.

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

const MAX_AGE_SECONDS = 5 * 60;

function readField(parts: string[], name: string) {
  const prefix = `${name}=`;
  return parts.find((part) => part.startsWith(prefix))?.slice(prefix.length);
}

function isValidSignature(body: string, signatureHeader: string, secret: string, headers: Headers) {
  const parts = signatureHeader.split(",");
  const timestamp = readField(parts, "t");
  const headerNames = readField(parts, "h");
  const provided = readField(parts, "v1");
  if (!timestamp || headerNames === undefined || !provided) {
    return false;
  }

  const ageSeconds = Date.now() / 1000 - Number.parseInt(timestamp, 10);
  if (Number.isNaN(ageSeconds) || ageSeconds > MAX_AGE_SECONDS) {
    return false;
  }

  const headerValues = headerNames
    .split(" ")
    .map((name) => headers.get(name) ?? "")
    .join(".");
  const signedPayload = `${timestamp}.${headerNames}.${headerValues}.${body}`;
  const expected = createHmac("sha256", secret).update(signedPayload, "utf8").digest("hex");

  const expectedBuffer = Buffer.from(expected, "hex");
  const providedBuffer = Buffer.from(provided, "hex");
  return (
    expectedBuffer.length === providedBuffer.length && timingSafeEqual(expectedBuffer, providedBuffer)
  );
}

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

  const body = await request.text();
  const signatureHeader = request.headers.get("x-hook0-signature") ?? "";

  if (!isValidSignature(body, signatureHeader, secret, request.headers)) {
    return new Response("Invalid signature", { status: 400 });
  }

  const event = JSON.parse(body) as { eventType: string; id: string };
  console.log(`Received Coinbase event: ${event.eventType} for checkout ${event.id}`);

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

Start the app:

npm run dev

You add the secret in a later step.

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-coinbase-app
Output
HORIZON: Tunnel connected
  URL          https://my-coinbase-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-coinbase-app

Use -s. Without it, the subdomain is random and changes on every run, and you would have to update the subscription each time you restart. Reserved subdomains are a paid feature, see Pricing.

Create the subscription

Create the subscription with the CDP CLI. It listens to every checkout event type, as Coinbase recommends, and points at your tunnel.

cdp data webhooks subscriptions create \
  description="Checkout status webhook" \
  'eventTypes:=["checkout.payment.success","checkout.payment.failed","checkout.payment.expired","checkout.refund.success","checkout.refund.failed"]' \
  target.url=https://my-coinbase-app.hrzn.run/api/webhooks/coinbase \
  target.method=POST \
  isEnabled:=true

The response is 201 Created. It holds the subscriptionId and the secret for verification. Copy the secret now, and keep the subscriptionId to manage the subscription later.

To point an existing subscription at a new URL, run cdp data webhooks subscriptions update <SUBSCRIPTION_ID>. An update replaces the whole subscription, so pass every field again, including the ones you don't change.

Verify the signature

Add the secret to .env.local:

.env.local
COINBASE_WEBHOOK_SECRET=replace-with-your-subscription-secret

Restart npm run dev so Next.js loads the new variable.

Check it works

Coinbase documents no test-event command. Events fire when a checkout changes state, for example checkout.payment.success when a customer pays. Create a checkout with the Coinbase Business Checkouts API and pay it to trigger one. Fulfill on checkout.payment.success, not on the redirect alone, because payment confirmation is asynchronous.

When an event arrives, the Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/coinbase

Your app terminal prints one line such as Received Coinbase event: checkout.payment.success for checkout <id>.

To see the delivery from Coinbase's side, list the delivery attempts of your subscription. The output includes the delivery status, the retry count and the HTTP response details:

cdp data webhooks subscriptions events <SUBSCRIPTION_ID>

Troubleshooting

The signature doesn't match

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

  1. The secret. COINBASE_WEBHOOK_SECRET must be the secret from the response of the subscription you created. Did you restart the dev server after you edited .env.local?
  2. The body. Hash the raw body. Don't call request.json() first.
  3. The headers. The h field of X-Hook0-Signature names the headers Coinbase signed. The handler reads those values from the incoming request, so a proxy that changes them breaks the signature.
  4. The age. The handler rejects a webhook older than 5 minutes. Check that your computer's clock is correct.

The request returns 404

The Horizon line shows [404]. The route file app/api/webhooks/coinbase/route.ts serves /api/webhooks/coinbase. Check target.url for typos, and make sure the file exports POST.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Coinbase still sends events to the old URL. Restart with -s, then run cdp data webhooks subscriptions update <SUBSCRIPTION_ID> with the new URL.

Nothing arrives

Check that isEnabled is true, that the event type you expect is in eventTypes, and that both the Horizon tunnel and npm run dev are running. Run cdp data webhooks subscriptions events <SUBSCRIPTION_ID> to see whether Coinbase tried to deliver.

Next steps

On this page