Verifying Webhook Signatures

Verify TextGrid webhook signatures using C#, Node.js, or Python, with matching synthetic test vectors.

Use your account's Webhook Secret to verify that a webhook was signed by TextGrid and that the signed URL and content have not changed.

Read the signature from the X-TextGrid-Signature request header. This is separate from the Account SID and Auth Token used to call the API.

Build the signing text

MethodSigning text
GETThe full public request URL, including its encoded query string exactly once. Do not append a request body.
POSTThe original callback URL, including any existing query string, concatenated directly with the unchanged request body.

For POST, insert no separator between the URL and body. For GET, keep the original ?, &, parameter order, and percent encoding. Do not decode and rebuild the query string.

Capture the POST body before your framework parses or reformats it. The samples accept that body as its original UTF-8 text. Do not serialize a parsed JSON object or rebuild form fields for verification.

If a reverse proxy changes your application's hostname, scheme, or path, supply the public URL TextGrid actually requested rather than the internal address. Preserve its original query string.

Calculate and compare the signature

  1. Replace each UTF-16 code unit above ASCII 127 in the signing text with a literal \uXXXX escape using lowercase hexadecimal. For example, é becomes \u00e9; an emoji can become two surrogate escapes.
  2. Convert the escaped signing text and your Webhook Secret into UTF-8 byte arrays. Do not apply the non-ASCII escaping step to the secret.
  3. Compute HMAC-SHA1, using the secret's bytes as the key and the signing text's bytes as the message.
  4. Base64-encode the resulting hash. To verify it, decode the received signature and compare the hash bytes using a constant-time comparison.

Use the Webhook Secret exactly as displayed in your dashboard. Do not Base64-decode it, even if it looks like Base64 text.

Code samples

Pass the HTTP method, exact public request URL, raw POST body (or an empty string for GET), Webhook Secret, and received header to VerifySignature.

using System;
using System.Security.Cryptography;
using System.Text;

public static class TextGridWebhookSignature
{
    private static string EscapeNonAsciiUtf16(string text)
    {
        var result = new StringBuilder();
        foreach (char unit in text)
        {
            if (unit <= 127)
                result.Append(unit);
            else
                result.Append("\\u").Append(((int)unit).ToString("x4"));
        }
        return result.ToString();
    }

    public static string CreateSignature(
        string method, string publicUrl, string rawBody, string webhookSecret)
    {
        bool isGet = string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase);
        bool isPost = string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase);
        if (!isGet && !isPost)
            throw new ArgumentException("Expected GET or POST", nameof(method));

        string signingText = publicUrl + (isPost ? (rawBody ?? "") : "");
        byte[] key = Encoding.UTF8.GetBytes(webhookSecret);
        byte[] message = Encoding.UTF8.GetBytes(EscapeNonAsciiUtf16(signingText));

        using (var hmac = new HMACSHA1(key))
            return Convert.ToBase64String(hmac.ComputeHash(message));
    }

    public static bool VerifySignature(
        string method, string publicUrl, string rawBody,
        string webhookSecret, string receivedSignature)
    {
        if (string.IsNullOrEmpty(receivedSignature))
            return false;

        byte[] received;
        try { received = Convert.FromBase64String(receivedSignature); }
        catch (FormatException) { return false; }

        byte[] expected = Convert.FromBase64String(
            CreateSignature(method, publicUrl, rawBody, webhookSecret));
        if (received.Length != expected.Length)
            return false;

        int difference = 0;
        for (int i = 0; i < expected.Length; i++)
            difference |= expected[i] ^ received[i];
        return difference == 0;
    }
}
import { createHmac, timingSafeEqual } from 'node:crypto';

function escapeNonAsciiUtf16(text) {
  let result = '';
  for (let i = 0; i < text.length; i++) {
    const unit = text.charCodeAt(i);
    result += unit <= 127
      ? text[i]
      : '\\u' + unit.toString(16).padStart(4, '0');
  }
  return result;
}

export function createSignature(method, publicUrl, rawBody, webhookSecret) {
  method = method.toUpperCase();
  if (method !== 'GET' && method !== 'POST') {
    throw new Error('Expected GET or POST');
  }

  const signingText = publicUrl + (method === 'POST' ? (rawBody ?? '') : '');
  return createHmac('sha1', Buffer.from(webhookSecret, 'utf8'))
    .update(Buffer.from(escapeNonAsciiUtf16(signingText), 'utf8'))
    .digest('base64');
}

export function verifySignature(
  method, publicUrl, rawBody, webhookSecret, receivedSignature
) {
  if (typeof receivedSignature !== 'string' || !receivedSignature) return false;
  if (!/^[A-Za-z0-9+/]{27}=$/.test(receivedSignature)) return false;

  const received = Buffer.from(receivedSignature, 'base64');
  const expected = Buffer.from(
    createSignature(method, publicUrl, rawBody, webhookSecret), 'base64'
  );
  return received.length === expected.length && timingSafeEqual(expected, received);
}
import base64
import binascii
import hashlib
import hmac


def escape_non_ascii_utf16(text):
    units = text.encode("utf-16-le", errors="surrogatepass")
    result = []
    for i in range(0, len(units), 2):
        unit = int.from_bytes(units[i:i + 2], "little")
        result.append(chr(unit) if unit <= 127 else f"\\u{unit:04x}")
    return "".join(result)


def create_signature(method, public_url, raw_body, webhook_secret):
    method = method.upper()
    if method not in ("GET", "POST"):
        raise ValueError("Expected GET or POST")

    signing_text = public_url
    if method == "POST":
        signing_text += raw_body or ""

    return base64.b64encode(hmac.new(
        webhook_secret.encode("utf-8"),
        escape_non_ascii_utf16(signing_text).encode("utf-8"),
        hashlib.sha1,
    ).digest()).decode("ascii")


def verify_signature(method, public_url, raw_body,
                     webhook_secret, received_signature):
    if not received_signature:
        return False
    try:
        received = base64.b64decode(received_signature, validate=True)
    except (binascii.Error, ValueError, TypeError):
        return False

    expected = base64.b64decode(create_signature(
        method, public_url, raw_body, webhook_secret))
    return hmac.compare_digest(expected, received)

Synthetic test vectors

These values are fictitious algorithm checks, not production credentials or complete webhook payloads. All three samples should produce the expected signatures below.

Use this dummy secret:

example-webhook-secret

GET with an existing query string

Method: GET. Supply this entire value as the public URL:

https://example.com/webhook?tenant=demo&MessageSid=EXAMPLE_MESSAGE_SID&Digits=001%23

Raw body: an empty string.

Expected signature:

LFs56mFCQ6fptbOuh9gycsbDD1E=

POST containing Unicode

Method: POST. Public URL:

https://example.com/webhook?tenant=demo

The exact raw body below has no spaces outside the string value and no trailing newline:

{"description":"Café 😀"}

Send the actual é and emoji characters as shown, not JSON backslash escapes. A differently serialized body has a different signature even if it parses to an equivalent object.

Expected signature:

NPtCw8JeuWuvFKLIxwoxHQmAkV0=

Handle the verification result

Call VerifySignature, verifySignature, or verify_signature with the values from the incoming request. Reject a request whose signature is missing or invalid before performing its requested work. Process the webhook only when verification succeeds.

Keep the Webhook Secret on your server. These examples do not need customer data and must not use your production secret for the synthetic checks.

Troubleshoot a mismatch

  • Confirm you used the receiving account's Webhook Secret, not its Auth Token.
  • Check the public scheme, hostname, port, path, and query string.
  • For GET, do not append the query parameters a second time.
  • For POST, do not parse and reserialize the body or change whitespace.
  • Preserve percent encoding and field order. + and %20 may represent the same space to a parser, but are different signing text.
  • Use lowercase hexadecimal UTF-16 escapes exactly as the samples do.
  • Check for a trailing newline in copied test data.

Did this page help you?