Horizon

Test TikTok webhooks locally

Receive TikTok for Developers webhook events on localhost with a Horizon tunnel, and verify the TikTok-Signature header.

Receive TikTok events on your laptop while you build, with an HTTPS URL TikTok can reach.

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 Next.js app that uses the App Router and runs on port 3000
  • An app in the TikTok for Developers portal, with its client secret

Start your app

TikTok sends each event as a POST request with a JSON body and a TikTok-Signature header. The header looks like t=1633174587,s=18494715036a.... To verify it, build the signed payload from the timestamp t, a . character and the raw request body. Then compute an HMAC SHA-256 (a keyed hash) of that string with your app's client secret. The hex digest must match s.

TikTok doesn't state a timestamp tolerance, so the code below picks five minutes. TikTok also expects an immediate 200 response and retries for up to 72 hours otherwise. Keep the handler fast.

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

const TOLERANCE_SECONDS = 5 * 60;

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

  const body = await request.text();
  const header = request.headers.get("tiktok-signature") ?? "";
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.split("=") as [string, string]),
  );
  const timestamp = parts.t;
  const signature = parts.s;

  if (!timestamp || !signature) {
    return new Response("Missing signature", { status: 401 });
  }

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(ageSeconds <= TOLERANCE_SECONDS)) {
    return new Response("Stale timestamp", { status: 401 });
  }

  const expected = createHmac("sha256", clientSecret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const receivedBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

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

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

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

Add your client secret to .env.local. Find it on your app's page in the developer portal.

.env.local
TIKTOK_CLIENT_SECRET=replace-with-your-client-secret

Start the app on port 3000.

npm run dev

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so the callback URL you saved in TikTok would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.

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

Your public URL is https://my-app.hrzn.run. TikTok requires an HTTPS callback URL, and every Horizon tunnel has it. Keep this terminal open.

Add the callback URL in TikTok

  1. Open your app in the TikTok for Developers portal.
  2. Select the Webhooks tab on your Development configuration page.
  3. Enter https://my-app.hrzn.run/api/webhooks/tiktok as the callback URL.
  4. Select the Test URL button, then select Send. TikTok sends a POST request to your callback URL.
  5. When your app received the request correctly, select Save changes.

TikTok subscribes you to all events once a callback URL is set.

Check it works

When you select Send, the Horizon terminal prints one line:

Output
  POST    200  /api/webhooks/tiktok

Your app terminal prints a line like:

Output
Received TikTok event: authorization.removed

Real events use one of these names: authorization.removed, video.upload.failed, video.publish.completed and portability.download.ready. Each payload has client_key, event, create_time, user_openid and content. The content field is a JSON string, so parse it again.

Troubleshooting

The test request returns 401

The Horizon line shows [401]. Your app logs show which check failed.

  • Check that TIKTOK_CLIENT_SECRET is the client secret of the same app, with no extra spaces or newline. Restart npm run dev after you edit .env.local.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • The signed string is the timestamp, a . and the body. Don't add anything else.
  • TikTok doesn't document whether the test request carries a signature. If the Test URL request fails and the header is missing, log request.headers to see what arrived.

TikTok sends the same event twice

TikTok delivers events at least once, so duplicates can happen. Make your handler safe to run twice for the same event.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and update the callback URL in the portal if it differs. -s needs a subdomain you reserved, see Pricing.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that the callback URL ends with /api/webhooks/tiktok.

Next steps

On this page