Testing Webhooks

Before pointing TapTree at your production receiver, validate it end-to-end. This guide shows you how to use the test environment, run signature verification locally, and how to handle common edge cases.

Test environment setup

  1. In the Dashboard, switch to the Test environment using the toggle in the top bar.
  2. Create a webhook endpoint pointing at your staging receiver (or a tunnel like ngrok if you're developing locally).
  3. Copy the plaintext signing secret that appears once after create. Store it in your staging secret manager.
  4. Trigger a payment via the API in test mode and observe the event arriving at your receiver.

The endpoint's Audit-Log shows the configuration history (created, updated, rotated, revealed). Per-delivery status is a separate view.

Test events have mode: "test" in their body and signature-secret-id from your test endpoint's secret. Live events never reach test endpoints and vice versa.

Signature verification snippets

Every snippet below performs the same five steps:

  1. Read the raw request body before any parsing.
  2. Look up the local secret by signature-secret-id.
  3. Reject events whose signature-timestamp is more than 300 seconds away from now.
  4. Compute hmac_sha256(secret, "${timestamp}.${raw_body}").
  5. Compare the result against the signature header in constant time.

Node.js (Express)

import crypto from 'node:crypto'
import express from 'express'

const SECRETS = {
  // Keep both the current secret and (during the 24h grace period) the previous one.
  'whsec_id_a3xq72k1': process.env.WEBHOOK_SECRET_CURRENT,
  'whsec_id_p9b21yh4': process.env.WEBHOOK_SECRET_PREVIOUS,
}

const app = express()
app.post('/taptree-webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.header('signature-timestamp')
  const signature = req.header('signature')
  const secretId = req.header('signature-secret-id')
  const version = req.header('signature-algo')

  if (version !== 'hmac-sha256-v2') return res.status(400).end()

  const ts = Number(timestamp)
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
    return res.status(400).end()
  }

  const secret = SECRETS[secretId]
  if (!secret) return res.status(400).end()

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex')

  const expectedBuf = Buffer.from(expected)
  const signatureBuf = Buffer.from(signature ?? '')
  if (expectedBuf.length !== signatureBuf.length || !crypto.timingSafeEqual(expectedBuf, signatureBuf)) {
    return res.status(401).end()
  }

  const event = JSON.parse(req.body.toString('utf8'))
  // process event here — dedupe on event.id
  res.status(200).end()
})

PHP

<?php
$timestamp = $_SERVER['HTTP_SIGNATURE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_SIGNATURE'] ?? '';
$secretId  = $_SERVER['HTTP_SIGNATURE_SECRET_ID'] ?? '';
$version   = $_SERVER['HTTP_SIGNATURE_ALGO'] ?? '';

if ($version !== 'hmac-sha256-v2') { http_response_code(400); exit; }

$ts = (int)$timestamp;
if (abs(time() - $ts) > 300) { http_response_code(400); exit; }

$secrets = [
  'whsec_id_a3xq72k1' => getenv('WEBHOOK_SECRET_CURRENT'),
  'whsec_id_p9b21yh4' => getenv('WEBHOOK_SECRET_PREVIOUS'),
];
$secret = $secrets[$secretId] ?? null;
if (!$secret) { http_response_code(400); exit; }

$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);

if (!hash_equals($expected, $signature)) { http_response_code(401); exit; }

$event = json_decode($body, true);
// process event — dedupe on event['id']
http_response_code(200);

Python (Flask)

import hmac
import hashlib
import os
import time
from flask import Flask, request

SECRETS = {
    'whsec_id_a3xq72k1': os.environ['WEBHOOK_SECRET_CURRENT'],
    'whsec_id_p9b21yh4': os.environ.get('WEBHOOK_SECRET_PREVIOUS'),
}

app = Flask(__name__)

@app.route('/taptree-webhooks', methods=['POST'])
def handler():
    if request.headers.get('signature-algo') != 'hmac-sha256-v2':
        return '', 400

    try:
        ts = int(request.headers.get('signature-timestamp', ''))
    except ValueError:
        return '', 400
    if abs(time.time() - ts) > 300:
        return '', 400

    secret = SECRETS.get(request.headers.get('signature-secret-id'))
    if not secret:
        return '', 400

    body = request.get_data()  # raw bytes
    expected = hmac.new(secret.encode(), f'{ts}.{body.decode()}'.encode(), hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, request.headers.get('signature', '')):
        return '', 401

    event = request.get_json()
    # process event — dedupe on event['id']
    return '', 200

Ruby (Rails)

require 'openssl'
require 'rack/utils'

SECRETS = {
  'whsec_id_a3xq72k1' => ENV['WEBHOOK_SECRET_CURRENT'],
  'whsec_id_p9b21yh4' => ENV['WEBHOOK_SECRET_PREVIOUS'],
}

def verify_webhook(request)
  return false unless request.headers['signature-algo'] == 'hmac-sha256-v2'

  ts = request.headers['signature-timestamp'].to_i
  return false if (Time.now.to_i - ts).abs > 300

  secret = SECRETS[request.headers['signature-secret-id']]
  return false unless secret

  body = request.body.read
  request.body.rewind
  expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{ts}.#{body}")
  provided = request.headers['signature'].to_s

  # Rack::Utils.secure_compare requires equal-length strings — pre-check.
  # Constant-time alternative on Ruby 3.0+: OpenSSL.fixed_length_secure_compare.
  return false unless expected.bytesize == provided.bytesize
  Rack::Utils.secure_compare(expected, provided)
end

Common pitfalls

Replaying failed deliveries

Failed deliveries appear in the endpoint's Audit-Log in the Dashboard.

Going live

Once your test endpoint signs and processes events end-to-end:

  1. Switch the Dashboard environment toggle to Live.
  2. Create a new endpoint pointing at your production URL.
  3. Copy the new plaintext secret into your production secret manager.
  4. Deploy. Verify the first few live deliveries succeed at your receiver.
  5. (Optional) Delete the test endpoint if you no longer need it.

Test and live secrets are completely independent — there's no way to "promote" a test secret. This separation prevents test-environment compromises from ever signing real events.

Was this page helpful?