Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.tallyforagents.com/llms.txt

Use this file to discover all available pages before exploring further.

The SDK’s webhook surface is intentionally narrow today: a signature verifier you call inside your webhook handler. Endpoint management (create / list / revoke) is dashboard-only — see the Webhooks concept.

Types

WebhookVerifyInput

type WebhookVerifyInput = {
  /** Raw request body — string, exactly the bytes Tally signed.
   *  Don't pass a JSON-parsed object. */
  body: string;
  /** Value of the `tally-signature` header on the incoming request. */
  header: string | null | undefined;
  /** The plaintext signing secret saved at webhook creation
   *  (`whsec_<mode>_<base64url>`). */
  secret: string;
  /** Unix seconds. Defaults to now. Pin for tests. */
  now?: number;
  /** How far the header timestamp may drift from `now`. Defaults to 300s
   *  (5 minutes). */
  toleranceSeconds?: number;
};

WebhookVerifyResult

type WebhookVerifyResult =
  | { ok: true }
  | {
      ok: false;
      reason:
        | "missing_header"
        | "malformed_header"
        | "no_v1_signature"
        | "timestamp_out_of_tolerance"
        | "signature_mismatch";
    };

DEFAULT_TOLERANCE_SECONDS

const DEFAULT_TOLERANCE_SECONDS = 300; // 5 minutes
Exported for reference; use the toleranceSeconds field on WebhookVerifyInput to override.

Methods

tally.webhooks.verifySignature(input)

Verifies a tally-signature header against the raw request body.
const result = tally.webhooks.verifySignature({
  body,
  header: req.headers.get("tally-signature"),
  secret: process.env.TALLY_WEBHOOK_SECRET!,
});

if (!result.ok) {
  console.warn("rejected webhook:", result.reason);
  return new Response("invalid signature", { status: 400 });
}
Returns: WebhookVerifyResult. Synchronous; no HTTP, no allocation beyond the HMAC buffer.

verifySignature(input) (standalone)

The same function, exported directly for consumers who don’t want to instantiate a Tally client just to verify a webhook.
import { verifySignature } from "@tallyforagents/sdk";

const result = verifySignature({
  body,
  header: req.headers.get("tally-signature"),
  secret: process.env.TALLY_WEBHOOK_SECRET!,
});
Both forms are identical — pick whichever fits your dependency graph.

Full receiver example

Next.js App Router handler:
import { verifySignature } from "@tallyforagents/sdk";

export async function POST(req: Request) {
  const body = await req.text(); // raw, not JSON-parsed

  const result = verifySignature({
    body,
    header: req.headers.get("tally-signature"),
    secret: process.env.TALLY_WEBHOOK_SECRET!,
  });

  if (!result.ok) {
    console.warn("webhook rejected:", result.reason);
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(body);

  switch (event.type) {
    case "payment.confirmed":
      await handlePaymentConfirmed(event.data);
      break;
    case "payment.failed":
      await handlePaymentFailed(event.data);
      break;
    case "inbound.received":
      await handleInbound(event.data);
      break;
    default:
      // Ignore subscribed-to events we don't handle yet. Don't return 5xx
      // for unrecognized types — Tally will retry forever.
      break;
  }

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

Why pass the raw body

The HMAC is computed over the exact bytes Tally signed — <unix_ts>.<body>. Any transformation (parsing, re-stringifying, whitespace normalization) breaks the match. In most frameworks, getting the raw body is await req.text() or equivalent. Don’t call req.json() and then JSON.stringify the result back — round-tripping through your runtime’s JSON parser changes key ordering, whitespace, or both, depending on the runtime. If you’re behind a middleware or framework that consumes the body automatically (Express’s body-parser, for example), you’ll need to opt out for the webhook route. Most frameworks support a “give me the raw bytes” mode for this exact case.

Rejection reasons

When verifySignature returns { ok: false, reason }, the reason tells you exactly what failed:
ReasonMeaningLikely cause
missing_headerNo tally-signature header on the request.Wrong endpoint, or the proxy stripped headers.
malformed_headerThe header doesn’t parse — couldn’t extract t=.Manual testing with the wrong format.
no_v1_signatureThe v1= segment is absent.Same — manual tests, or a future signature version Tally adds.
timestamp_out_of_toleranceThe t= timestamp is more than toleranceSeconds away from now.Clock skew on either side, or a replayed payload.
signature_mismatchThe header parsed and the timestamp is in window, but the HMAC doesn’t match.Wrong secret, or the body was modified in transit.
Log the reason — it’s the fastest path to diagnosing webhook failures.

Tolerance window

The default 5-minute tolerance is calibrated for the realities of clock drift across servers and Tally’s retry schedule (the first retry is 1 minute after the initial attempt). Don’t widen it casually:
verifySignature({
  body,
  header,
  secret,
  toleranceSeconds: 3600, // 1 hour — bad idea
});
A wider window means a stolen signature stays valid longer. If you’re seeing legitimate deliveries fail timestamp_out_of_tolerance, fix the clock skew on your receiving server first.

Not yet in the SDK

  • tally.webhooks.list() / create() / revoke() — endpoint management.
  • tally.webhooks.deliveries.list() / replay() — delivery log access.
These are dashboard-driven today. Tracked in BUILD_LOG.md.