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.
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.
TIKTOK_CLIENT_SECRET=replace-with-your-client-secretStart the app on port 3000.
npm run devStart 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-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour 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
- Open your app in the TikTok for Developers portal.
- Select the Webhooks tab on your Development configuration page.
- Enter
https://my-app.hrzn.run/api/webhooks/tiktokas the callback URL. - Select the Test URL button, then select Send. TikTok sends a
POSTrequest to your callback URL. - 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:
POST 200 /api/webhooks/tiktokYour app terminal prints a line like:
Received TikTok event: authorization.removedReal 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_SECRETis the client secret of the same app, with no extra spaces or newline. Restartnpm run devafter you edit.env.local. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, 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.headersto 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 forReconnected. - Check that the callback URL ends with
/api/webhooks/tiktok.
Next steps
- Read TikTok's webhooks overview and event reference.
- Read TikTok's guide to verifying webhooks.
Test Instagram webhooks locally
Receive Instagram API webhook events like comments and messages on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.
Test WhatsApp Cloud API webhooks locally
Receive WhatsApp Business Cloud API webhooks, such as incoming messages and status updates, on localhost with a Horizon tunnel.