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
- In the Dashboard, switch to the Test environment using the toggle in the top bar.
- Create a webhook endpoint pointing at your staging receiver (or a tunnel like
ngrokif you're developing locally). - Copy the plaintext signing secret that appears once after create. Store it in your staging secret manager.
- 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:
- Read the raw request body before any parsing.
- Look up the local secret by
signature-secret-id. - Reject events whose
signature-timestampis more than 300 seconds away fromnow. - Compute
hmac_sha256(secret, "${timestamp}.${raw_body}"). - Compare the result against the
signatureheader 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
Reading body twice strips the bytes: Many web frameworks parse JSON automatically on first read, so reading request.body again returns empty. Wire your route to receive raw bytes before any JSON middleware fires.
Don't re-serialize before signing: If you parse JSON and re-serialize, key ordering or whitespace can differ from what we signed. Always hash the raw bytes off the wire.
Storing only "the current secret" breaks rotation: When the secret rotates, your service receives events signed with both the old and new secret for 24 hours. Index your secrets by signature-secret-id, not "current/previous" labels.
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:
- Switch the Dashboard environment toggle to Live.
- Create a new endpoint pointing at your production URL.
- Copy the new plaintext secret into your production secret manager.
- Deploy. Verify the first few live deliveries succeed at your receiver.
- (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.