Webhooks Core Concepts
This page explains how TestingBot webhooks behave: when they fire, what they send, how the request is authorized and what delivery guarantees apply. If you have not created a webhook yet, start with the Quick Start.
When webhooks fire
A webhook fires after each completed test that matches its trigger settings. You configure three filters when creating or editing a webhook at /members/integrations/webhooks:
- Result filter: fire on failures only, or on all tests regardless of their result.
- Test type: include all tests, only automated browser tests or only real device tests.
-
Name filter: an optional wildcard pattern such as
regression_*. The webhook only fires when the pattern matches the test name or the build name.
Requests may arrive with a short delay after a test completes.
Default and custom payloads
Each webhook sends one of two payload types, selected with the Default | Custom toggle in the webhook form:
- Default: a fixed JSON document with all the test details, documented in the reference below. This is the easiest option when your own endpoint consumes the request.
-
Custom: a JSON body that you write yourself, using built-in variables such as
{{TEST_NAME}}and{{TEST_STATUS}}. Use this to match the format an external service expects, or pick one of the templates to prefill a custom payload for a popular service.
Default payload reference
When you do not configure a custom payload, your webhook URL receives a POST request in JSON format with the following payload:
| Data Field | Format | Description |
|---|---|---|
id |
STRING | The unique id of the test. Usually this will be a WebDriver SessionId, or Playwright/Puppeteer unique id. |
creation_time |
DATETIME | The date-time value, in YYYY-MM-DDTHH:mm:ssZ format, at which the test launched. |
completion_time |
DATETIME | The date-time value, in YYYY-MM-DDTHH:mm:ssZ format, at which the test was completed. |
user_id |
INTEGER | The id of the user who launched the test. |
team_id |
INTEGER | The id of the team for which this test was run. |
status |
ENUM |
The status of the test, which can be one of the following values:
|
name |
STRING | The name of the test, if it was passed through the capabilities or set by API. |
browser_name |
STRING | The name of the browser on which this test ran. In case of a mobile app test, this might be NULL. |
device_name |
STRING | The name of the device on which this test ran. Might be NULL when this test ran on a Desktop environment. |
browser_version |
STRING | The version of the browser on which this test ran. In case of a mobile app test, this might be NULL or the device's version. |
platform |
STRING | The OS on which this test ran. |
duration_sec |
INTEGER | How long the test took to complete, the duration is in seconds. |
build |
STRING | The build this test belongs to, if it was passed through the capabilities or set by API. |
tags |
ARRAY | Array of strings that were added for this test, if it was passed through the capabilities or set by API. |
errors |
ARRAY |
Array of error objects that were reported for this test. Empty if no errors were reported. The error objects are structured like this:
Copy
|
Authorization, headers and parameters
The Authorization, Headers and Params tabs in the webhook form control how the HTTP request is sent. The tabs appear when the Custom payload mode is selected; with the Default payload, only the Authorization fields are shown:
-
Authorization: choose No Auth, Basic Auth (username and password) or a Bearer token. The credentials are added to the
Authorizationheader of every request. - Headers: add custom HTTP headers, for example an API key header that the receiving service requires.
- Params: add query parameters that are appended to the webhook URL.
Header values, query parameter values and the URL itself support built-in variables, so you can include test details such as {{TEST_ID}} outside of the payload body.
Delivery and security
- Delivery is best effort and is not retried: make sure your endpoint is available and responds quickly.
- Your endpoint is expected to return a 2xx status code.
- Requests time out after 90 seconds.
- Redirects are not followed: point the webhook directly at the final URL.
- Every delivery is signed with an HMAC-SHA256 signature, so your endpoint can verify that the request really comes from TestingBot. You can additionally configure authorization as a second factor.
- Webhook URLs that resolve to private or internal addresses are refused; the endpoint must be reachable on the public internet.
Verify deliveries
Each webhook has a signing secret (it starts with whsec_), shown on the webhook's edit page in the members area, where you can also rotate it.
TestingBot signs every delivery with it and adds two headers to the request:
| Header | Contents |
|---|---|
X-TestingBot-Timestamp |
The Unix timestamp (seconds) at which the request was signed. |
X-TestingBot-Signature |
sha256=<hexdigest>: the HMAC-SHA256 of "<timestamp>.<raw request body>", computed with your signing secret. |
To verify a delivery on your server:
- Read the raw request body, before any JSON parsing or re-serialization. A single re-encoded space breaks the signature.
- Reject the request when the timestamp is older than 5 minutes; this blocks replayed deliveries.
- Compute the HMAC-SHA256 of
"<timestamp>.<raw body>"with your signing secret and prefix it withsha256=. - Compare it with the
X-TestingBot-Signatureheader using a constant-time comparison, never a plain string equality.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public class TestingBotWebhook {
public static boolean verifySignature(String rawBody, String timestamp,
String signature, String secret) throws Exception {
if (Long.parseLong(timestamp) < (System.currentTimeMillis() / 1000) - 300) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
String expected = "sha256=" + hex;
// MessageDigest.isEqual is a constant-time comparison.
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
(signature == null ? "" : signature).getBytes(StandardCharsets.UTF_8));
}
}
const crypto = require('crypto');
function verifyTestingBotSignature(rawBody, timestamp, signature, secret) {
if (Number(timestamp) < Math.floor(Date.now() / 1000) - 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(String(signature || ''));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express needs the raw body, for example:
// app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); } }));
require 'openssl'
require 'rack/utils'
def verify_testingbot_signature(raw_body, timestamp, signature, secret)
return false if timestamp.to_i < Time.now.to_i - 300
expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
Rack::Utils.secure_compare(expected, signature.to_s)
end
# Rails example:
# verify_testingbot_signature(
# request.raw_post,
# request.headers['X-TestingBot-Timestamp'],
# request.headers['X-TestingBot-Signature'],
# ENV['TESTINGBOT_WEBHOOK_SECRET']
# )
import hashlib
import hmac
import time
def verify_testingbot_signature(raw_body, timestamp, signature, secret):
if int(timestamp) < int(time.time()) - 300:
return False
signed = f"{timestamp}.{raw_body}".encode()
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
using System;
using System.Security.Cryptography;
using System.Text;
public static class TestingBotWebhook
{
public static bool VerifySignature(string rawBody, string timestamp,
string signature, string secret)
{
if (long.Parse(timestamp) < DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 300)
{
return false;
}
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
var expected = "sha256=" + Convert.ToHexString(digest).ToLowerInvariant();
// FixedTimeEquals is a constant-time comparison.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature ?? string.Empty));
}
}
// ASP.NET Core needs the raw body, for example:
// using var reader = new StreamReader(Request.Body);
// var rawBody = await reader.ReadToEndAsync();
The Test Webhook button signs its request with the webhook's real secret when you test a saved webhook, so you can exercise your verification code from the edit page before any real test fires. After rotating the secret, update your receiver immediately: deliveries signed with the old secret no longer validate.
Limits
- You can create up to 5 webhooks per team.
- A custom payload template can be at most 64,000 characters.
- The response shown in the Test Webhook panel is truncated to 10,000 characters.
Related
- Built-in variables: every variable you can use in custom payloads, URLs, headers and parameters.
- Integrations: ready-made templates for Slack, Teams, Discord, PagerDuty, OpsGenie and Jira.
- Quick Start: create and test your first webhook step by step.