Tidewire Docs Dashboard

Verify webhook signatures

Tidewire signs every delivery, so your endpoint can check that it really came from Tidewire and wasn’t changed on the way. Verify the signature before you trust the payload: the SDKs do it in one call, and doing it yourself takes about twenty lines.

How signing works

Every delivery carries a Tidewire-Signature header with a timestamp and a signature:

HTTP header
Tidewire-Signature: t=1727354000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
PartWhat it is
tWhen Tidewire signed the delivery, as a Unix timestamp in seconds.
v1The signature: an HMAC-SHA256, hex-encoded. During a secret rotation there are two.

To make the signature, Tidewire joins the timestamp and the raw request body with a period, and signs the result with your endpoint secret:

Signature
signed_payload = timestamp + "." + raw_body
v1             = hex(HMAC_SHA256(endpoint_secret, signed_payload))

Your endpoint secret starts with twsec_. It isn’t your API key: every endpoint has its own secret, which you’ll find under Endpoints in the dashboard. Keep it on your server.

Verify with the SDK

Install the SDK with npm install @tidewire/node or pip install tidewire. Then call verify() with the raw body, the header and your secret. It returns the event, or throws if the signature is wrong or too old.

server.js app.py
import express from "express";
import Tidewire from "@tidewire/node";

const tidewire = new Tidewire(process.env.TIDEWIRE_API_KEY);
const app = express();
const raw = express.raw({ type: "application/json" });

// The raw body, on this route only: the signature covers the exact bytes.
app.post("/webhooks/tidewire", raw, (req, res) => {
  let event;
  try {
    event = tidewire.webhooks.verify(
      req.body,
      req.get("Tidewire-Signature"),
      process.env.TIDEWIRE_WEBHOOK_SECRET, // twsec_…
    );
  } catch (err) {
    return res.status(400).send(`Webhook error: ${err.message}`);
  }

  res.sendStatus(200); // Acknowledge first, process later.
  jobs.enqueue(event);
});
import os
from flask import Flask, request
from tidewire import Tidewire, SignatureError

tidewire = Tidewire(os.environ["TIDEWIRE_API_KEY"])
app = Flask(__name__)

@app.post("/webhooks/tidewire")
def tidewire_webhook():
    try:
        event = tidewire.webhooks.verify(
            request.get_data(),  # the raw bytes, not request.json
            request.headers.get("Tidewire-Signature", ""),
            os.environ["TIDEWIRE_WEBHOOK_SECRET"],  # twsec_…
        )
    except SignatureError as err:
        return f"Webhook error: {err}", 400

    jobs.enqueue(event)  # Acknowledge first, process later.
    return "", 200

The Go and Ruby SDKs work the same way: webhooks.verify takes the raw body, the header and your secret.

Verify it yourself

No SDK for your language? Verifying takes five steps:

  1. Read the header. Split Tidewire-Signature on commas, then each part on =. Keep t and every v1.
  2. Rebuild the signed payload: the timestamp, a period, and the raw request body, byte for byte.
  3. Sign it with HMAC-SHA256, using your endpoint secret as the key, and hex-encode the result.
  4. Compare it with each v1 in constant time. If none match, reject the delivery with a 400.
  5. Check the timestamp. Reject it if it’s more than 300 seconds away from your clock.
Node.jsverify.js
import crypto from "node:crypto";

export function verifySignature(rawBody, header, secret, tolerance = 300) {
  const parts = header.split(",").map((part) => part.split("="));
  const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
  const signatures = parts.filter(([key]) => key === "v1").map(([, v]) => v);

  const expected = crypto.createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");

  const matches = signatures.some((signature) =>
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)));
  if (!matches) throw new Error("No matching signature");

  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
    throw new Error("Signature is too old");
  }
  return JSON.parse(rawBody);
}

Never compare signatures with ===. A normal string comparison stops at the first different character, and the time that takes leaks how much of a forged signature is right.

Replay protection

Every signature includes the moment it was made, which makes a captured request useless later. Signatures older than five minutes are rejected, to stop replayed deliveries. The SDKs check this for you: verify() accepts a tolerance option in seconds (default 300), but keep the window as short as your setup allows.

Retries are signed again when they’re sent, so a retried delivery never arrives with an old timestamp. If fresh deliveries still fail the check, look at your server’s clock, or at a queue that holds requests before verifying them.

The time window stops old requests from being replayed, not duplicates. To handle the same delivery twice safely, store each Tidewire-Delivery-Id and skip the ones you’ve seen. See Retries and idempotency.

Rotate your secret

Rotate the secret on a schedule, or when someone with access leaves. Nothing goes down while you do:

  1. In the dashboard, open Endpoints, choose the endpoint, and click Roll secret.
  2. For the next 24 hours, two secrets are valid. Tidewire signs every delivery with both, so the header carries two v1 signatures, and servers with either secret accept it.
  3. Deploy the new secret before the 24 hours are up. After that, only the new one signs.

If a secret has leaked, choose Expire now instead, and the old secret stops working immediately.

Common problems

The signature never matches

You’re almost certainly verifying a parsed body. Tidewire signs the raw bytes, and JSON that was parsed and serialized again never matches: whitespace and key order change. In Express, use express.raw({ type: "application/json" }) on the webhook route only, and keep express.json() for the rest of your app.

Valid deliveries are rejected as too old

Your server’s clock is probably off. The five-minute check uses your clock, so keep it in sync with NTP. On containers and serverless platforms, the host’s clock is usually right; on long-running VMs, it drifts.

It works in testing, but not in production

Check that you’re using the right endpoint’s secret. Every endpoint has its own, and test mode endpoints never share secrets with live ones. The endpoint that sent a delivery is in its Tidewire-Endpoint header.

How it works · Stand Inline

A whisper in the margin of your docs

Developers open the docs to fix things on their own, and a chat bubble in the corner only gets in the way. Here the chat is one quiet line under the table of contents. When someone does ask, the answer grows as a thread in the margin, right beside the sentence they were stuck on.

Try it

  1. In the right margin, under On this page, click Ask about this page. A greeting and three questions appear: pick one or type your own. The table of contents folds away and the conversation grows in its place, while the article stays where it is.
  2. Select a sentence in Replay protection and click Ask about this. The sentence travels with your question as a quote, so “Can I make that window longer?” is all you need to type.
  3. Scroll to the end of the article. The line there offers to continue the same conversation: both lines share one. On a phone, where the margin is hidden, that line is where you ask.
  4. Or : scripted, nothing is sent.
Chats here use site="demo", Stand Chat's shared demo site. Its demo Stand-in reads the page's private prompt, but it may still decline to speak for a made-up webhooks company. With your own Site ID, your own Stand-ins answer, trained on your website.

The element

<script type="module" src="stand-inline.js"></script>

<!-- In the margin, under "On this page" -->
<stand-inline
  id="ask-docs"
  site="YOUR-SITE-ID"
  look="whisper"
  ask-selection="article"
  placeholder="Ask about this page"
  greeting="Stuck on signatures? Ask me anything about this page."
  suggestions="Why does my signature never match? | How do I rotate the secret? | Show me this in Go"
  prompt="The visitor is reading 'Verify webhook signatures'. Header: t=…,v1=…, the HMAC-SHA256 of timestamp + '.' + raw body; secrets start with twsec_; 300 s tolerance…"
  analytics-id="docs-verify-signatures">
</stand-inline>

<!-- Where the article ends, and on phones -->
<stand-inline
  id="ask-docs-end"
  site="YOUR-SITE-ID"
  look="whisper"
  ask-selection="article"
  placeholder="Still stuck? Ask about this page"
  prompt="…the same prompt…"
  analytics-id="docs-verify-signatures-end">
</stand-inline>

Make it yours

It takes its font, text color and background from the page. Tidewire sets the accent, keeps the thread inside the sticky margin, and lets the page make room with data-state, which the element sets on itself:

stand-inline {
  --si-accent: #0E7C86;
  --si-muted: #57606A;
  --si-radius: 8px;
}

.rail stand-inline {
  font-size: 14px;
  /* Past this height, the thread scrolls inside the sticky rail. */
  --si-max-height: clamp(200px, 100vh - 320px, 680px);
}

/* A narrow margin skips the status line: the AI badge says enough. */
.rail stand-inline::part(status) { display: none; }
stand-inline::part(selection-button) { background: #0E7C86; }

/* Once a conversation starts, the contents fold away. */
.rail:has(stand-inline[data-state="conversation"]) .toc {
  grid-template-rows: 0fr;
  opacity: 0;
}

Good to know

Copy this example

npx degit standchat/examples/stand-inline my-stand-inline