Validate and verify webhook events
BoldSign will sign all the webhook events that are delivered to your endpoint by including a signature computed using HMAC with SHA256 in the X-BoldSign-Signature
header. This signature can be used to verify the origin of the webhook and that the event was sent by BoldSign.
Retrieve endpoint secret
Before you can verify signatures, you need to retrieve your endpoint's secret from your manage webhook settings. The webhook signing secret key will be assigned once you have configured your webhook. You can reveal the secret by visiting the webhook overview page and clicking the "Reveal" button.
Step 1: Go to the webhook dashboard page and select a webhook item.
Step 2: Click the "Reveal" button to reveal the signing secret key that is used to generate the HMAC SHA256 signatures from the webhook payload.
Verify signatures
All webhook events have a signature in the header called X-BoldSign-Signature
. This header's value is an HMAC with SHA256 signature generated with your webhook secret key, the time of the generated event, and the raw body of the webhook event.
Examples of signature from the request header:
Case 1: A regular webhook event signature header.
x-boldsign-signature: t=1668693823, s0=9b7adf82fbd470cce0cdb8106d7cb023e547999ada5e8c9ad02306cd22ee7ca9
Case 2: A webhook event signature header, when the secret is rolled with the option selected to keep the old secret valid for a specified time.
x-boldsign-signature: t=1668708521, s0=c10e26bf9c87a082f2e8b266fe95ed6e487e47851d9f5c5de40da0cb49b454ab, s1=62a1abe13bde9b464d0798fd9adb1b813109d1dba9e21a2bbb0a86a22a0621f6
Structure of the signature
x-boldsign-signature: t=timestamp-of-event, s0=signature-generated-by-current-key, s1=signature-generated-by-old-key-if-its-valid
Extract the timestamp and signature from the header
Extract the timestamp and signatures by splitting with ,
as a character separator. Next, split each item by =
to get the key-value pair.
The t
corresponds to the epoch timestamp of the event generated.
The s0
corresponds to the HMAC SHA256 signature generated using the current secret key.
The s1
corresponds to the HMAC SHA256 signature generated using the old secret key only if it is still valid at the time of event generation. If it is not valid, then the s1
will not be present.
The signed payload is created by concatenating the following:
- The timestamp
t
- The character
.
- The raw JSON payload from the request body.
An example of the signed payload will be shown below.
1668708521.{"event":{"id":"ca7bf729-38ce-4d3a-a000-d4bf077201c3","created":1668693823,"eventType":"Signed","environment":"Test"}}
Once the signed payload is ready, compute the HMAC signature using the SHA256 hash function. Use the signing secret (copied from the webhook dashboard page using the "Reveal" Button) as the key and use the signed payload that just prepared above as the message.
Compare the signatures
Compare your calculated signature with the signature (or signatures) in the request header. If the signature does not match one of those sent in the request header, the request should be ignored and not treated as a legitimate BoldSign webhook event.
When there is an equality match, you can compute the difference between the current epoch timestamp and the request epoch timestamp 't,' and then decide whether or not to process the event based on your time tolerance.
To avoid timing attacks, compare the expected signature to each of the received signatures using a constant-time string comparison.
Important: To perform signature verification, BoldSign requires the raw body of the request. If you're going to use a framework, make sure it doesn't interfere with the raw body. Any changes to the request's raw body (such as JSON formatting or spaces etc.) cause the verification to fail.
namespace BoldSign.Examples { using System; using System.IO; using System.Threading.Tasks; using BoldSign.Api; using BoldSign.Model.Webhook; using Microsoft.AspNetCore.Mvc; public class WebhookExampleController : Controller { [HttpPost] [IgnoreAntiforgeryToken] public async Task<IActionResult> Webhook() { var sr = new StreamReader(this.Request.Body); var json = await sr.ReadToEndAsync(); if (this.Request.Headers[WebhookUtility.BoldSignEventHeader] == "Verification") { return this.Ok(); } // TODO: Update your webhook secret key var SECRET_KEY = "<<<<SECRET_KEY>>>>"; try { WebhookUtility.ValidateSignature( json, this.Request.Headers[WebhookUtility.BoldSignSignatureHeader], boldSignSigningSecret); } catch (BoldSignSignatureException ex) { Console.WriteLine(ex); return this.Forbid(); } var eventPayload = WebhookUtility.ParseEvent(json); switch (eventPayload.Event.EventType) { // if its a document event, cast as DocumentEvent case WebHookEventType.Declined: var documentEvent = eventPayload.Data as DocumentEvent; break; // if its a sender identity event, cast as SenderIdentityEvent case WebHookEventType.SenderIdentityUpdated: var senderIdentityEvent = eventPayload.Data as SenderIdentityEvent; break; } return this.Ok(); } } }
function parseHeader(header) { if (typeof header !== "string") { return null; } return header.split(",").reduce( (dest, item) => { const key = item.trim().split("="); if (key[0] === "t") { dest.timestamp = parseInt(key[1], 10); } if (["s0", "s1"].includes(key[0])) { dest.signatures.push(key[1]); } return dest; }, { timestamp: -1, signatures: [], } ); } function secureCompare(a, b) { a = Buffer.from(a); b = Buffer.from(b); if (a.length !== b.length) { return false; } if (crypto.timingSafeEqual) { return crypto.timingSafeEqual(a, b); } const len = a.length; let result = 0; for (let i = 0; i < len; ++i) { result |= a[i] ^ b[i]; } return result === 0; } function isFromBoldSign(signatureHeader, payload, secretKey) { const parsed = parseHeader(signatureHeader); if (!parsed) { throw "BoldSign signatures doesn't exist"; } const signatureMatched = parsed.signatures .map((x) => { // TODO: update signing secret here // https://app.boldsign.com/api-management/webhooks/ return crypto .createHmac("sha256", secretKey) .update(parsed.timestamp + "." + payload, "utf8") .digest("hex"); }) .some((x) => { return parsed.signatures.some((y) => secureCompare(x, y)); }); if (signatureMatched == false) { throw "Unable to verify the signatures"; } // 5 mins in seconds is safer choice, you can adjust if you prefer it const tolerance = 300; const timestampAge = Math.floor(Date.now() / 1000) - parsed.timestamp; // check for time tolerance to prevent replay attacks if (tolerance > 0 && timestampAge > tolerance) { throw "Exceeded allowed tolerance range"; } return true; } const express = require("express"); ... ... const app = express(); app.post("/webhook", bodyParser.raw({ type: "application/json" }), (req, res) => { const eventType = req.headers["x-boldsign-event"]; if (eventType == "Verification") { res.sendStatus(200); return; } const signature = req.headers["x-boldsign-signature"]; const payload = req.body.toString("utf-8"); // TODO: Update your webhook secret key const SECRET_KEY = "<<<<SIGNING-SECRET>>>>"; let isValid = false; try { isValid = isFromBoldSign(signature, payload, SECRET_KEY); } catch (e) { console.error(e); res.sendStatus(400); return; } if (!isValid) { res.sendStatus(403); return; } // handle the event res.sendStatus(200); });
# TODO: Update your webhook secret key SECRET_KEY = "<<<<SECRET_KEY>>>>" require "sinatra" require "openssl" module BoldSign class BoldSignError < StandardError end def self.is_from_boldsign(payload, header, secret, tolerance: 300) begin timestamp, signatures = parse_header(header) rescue StandardError raise BoldSignError.new( "Unable to parse timestamp from the header" ) end if signatures.empty? raise BoldSignError.new( "Unable to parse signatures from the header" ) end expected_sig = compute_signature(timestamp, payload, secret) unless signatures.any? { |s| secure_compare(expected_sig, s) } raise BoldSignError.new( "No signatures found matching with the computed signature for payload" ) end if tolerance && timestamp < Time.now - tolerance raise BoldSignError.new( "Payload timestamp was outside the tolerance zone (#{Time.at(timestamp)})" ) end end true def self.parse_header(header) list_items = header.split(/,\s*/).map { |i| i.split("=", 2) } timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1]) signatures = list_items.select { |i| (i[0] == "s0" || i[0] == "s1") }.map { |i| i[1] } [Time.at(timestamp), signatures] end def self.compute_signature(timestamp, payload, secret) raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time) raise ArgumentError, "payload should be a string" unless payload.is_a?(String) raise ArgumentError, "secret should be a string" unless secret.is_a?(String) timestamped_payload = "#{timestamp.to_i}.#{payload}" OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, timestamped_payload) end def self.secure_compare(str_a, str_b) return false unless str_a.bytesize == str_b.bytesize l = str_a.unpack "C#{str_a.bytesize}" res = 0 str_b.each_byte { |byte| res |= byte ^ l.shift } res.zero? end end # Using the Sinatra framework set :port, 4242 post "/webhook" do payload = request.body.read event_header = request.env["HTTP_X_BOLDSIGN_EVENT"] if event_header == "Verification" return status 200 end header = request.env["HTTP_X_BOLDSIGN_SIGNATURE"] begin BoldSign.is_from_boldsign(payload, header, SECRET_KEY) rescue BoldSign::BoldSignError => e # Invalid signature status 400 return end # Handle the event status 200 end
from django.http import HttpResponse import hashlib import hmac import time # TODO: Update your webhook secret key SECRET_KEY = "<<<<SECRET_KEY>>>>" class BoldSignWebhookError(Exception): def __init__(self, message): self.message = message super().__init__(self.message) class BoldSignHelper: @staticmethod def is_from_boldsign(signature: str, body: str, secretKey: str, tolerance=300) -> bool: parsed = BoldSignHelper.parse_header(signature) timestamp = parsed.timestamp signatures = parsed.signatures if timestamp == -1: raise BoldSignWebhookError("Unable to parse timestamp") if signature.count == 0: raise BoldSignWebhookError( "Unable to find any signature from the provided header value") computedSignature = hmac.new( key=secretKey.encode('utf-8'), msg=(str(timestamp) + "." + body).encode('utf-8'), digestmod=hashlib.sha256 ).hexdigest() matched = False for sign in signatures: if hmac.compare_digest(sign, computedSignature): matched = True if matched == False: raise BoldSignWebhookError("HMAC SHA256 signatures doesn't match") age = time.time() - timestamp if tolerance > 0 and age > tolerance: raise BoldSignWebhookError( "Event is outside of the timestamp age tolerance") return True @staticmethod def parse_header(header: str): class ParsedHeader: def __init__(self, timestamp, signatures): self.timestamp = timestamp self.signatures = signatures if the header is None: raise BoldSignWebhookError( "Signature header value seems to be empty") timestamp = -1 signatures = [] for item in header.split(","): x, y = item.strip().split("=") if x == "t": timestamp = int(y) elif x == "s0" or x == "s1": signatures.append(y) return ParsedHeader(timestamp, signatures) @csrf_exempt def webhook_view(request): payload = request.body event_header = request.META['HTTP_X_BOLDSIGN_EVENT'] if event_header == "Verification": return HttpResponse(status=200) header = request.META['HTTP_X_BOLDSIGN_SIGNATURE'] try: BoldSignHelper.is_from_boldsign(header, payload, SECRET_KEY) except BoldSignWebhookError as e: return HttpResponse(status=400) # Handle the event return HttpResponse(status=200)
package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/ioutil" "net/http" "os" "strconv" "strings" "time" ) // TODO: Update your secret key const SECRET_KEY = "<<<<SECRET_KEY>>>>" var ( ErrNoTimestamp = errors.New("Unable to parse timestamp from the header") ErrNoSignature = errors.New("Unable to parse signatures from the header") ErrNoValidSignature = errors.New("No signatures found matching with the computed signature for payload") ErrTolerance = errors.New("Payload timestamp was outside the tolerance zone") ) type signedHeader struct { timestamp time.Time signatures [][]byte } func IsFromBoldSign(payload []byte, header string, secret string, tolerance time.Duration) error { parsed, err := parseSignatureHeader(header) if err != nil { return err } expectedSignature := ComputeSignature(parsed.timestamp, payload, secret) expiredTimestamp := time.Since(parsed.timestamp) > tolerance if expiredTimestamp { return ErrTolerance } for _, sig := range parsed.signatures { if hmac.Equal(expectedSignature, sig) { return nil } } return ErrNoValidSignature } func parseSignatureHeader(header string) (*signedHeader, error) { parsed := &signedHeader{} if header == "" { return parsed, ErrNoSignature } pairs := strings.Split(strings.Trim(header, " "), ",") for _, pair := range pairs { parts := strings.Split(strings.Trim(pair, " "), "=") if len(parts) != 2 { return parsed, ErrNoTimestamp } var key = strings.Trim(parts[0], " ") switch key { case "t": timestamp, err := strconv.ParseInt(strings.Trim(parts[1], " "), 10, 64) if err != nil { return parsed, ErrNoTimestamp } parsed.timestamp = time.Unix(timestamp, 0) case "s0", "s1": sig, err := hex.DecodeString(strings.Trim(parts[1], " ")) if err != nil { continue } parsed.signatures = append(parsed.signatures, sig) default: continue } } if len(parsed.signatures) == 0 { return parsed, ErrNoValidSignature } return parsed, nil } func ComputeSignature(t time.Time, payload []byte, secret string) []byte { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(fmt.Sprintf("%d", t.Unix()))) mac.Write([]byte(".")) mac.Write(payload) return mac.Sum(nil) } func main() { http.HandleFunc("/webhook", func(w http.ResponseWriter, req *http.Request) { const MaxBodyBytes = int64(65536) req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes) payload, err := ioutil.ReadAll(req.Body) if err != nil { fmt.Fprintf(os.Stderr, "Error reading request body: %v\n", err) w.WriteHeader(http.StatusServiceUnavailable) return } x := req.Header.Get("X-BoldSign-event") if x == "Verification" { w.WriteHeader(http.StatusOK) return } x = req.Header.Get("X-BoldSign-Signature") t, _ := time.ParseDuration("5m") err = IsFromBoldSign(payload, x, SECRET_KEY, t) if err != nil { // Return a 400 when verification fails w.WriteHeader(http.StatusBadRequest) return } // handle event w.WriteHeader(http.StatusOK) }) err := http.ListenAndServe(":3333", nil) if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server closed\n") } else if err != nil { fmt.Printf("error starting server: %s\n", err) os.Exit(1) } }
<?php class BoldSignException extends \Exception { } class BoldSignHelper { // Timestamp tolerance is default to 300 seconds (5 minutes) public static function isFromBoldSign($payload, $header, $secret, $tolerance = 300) { $timestamp = self::getTimestamp($header); $signatures = self::getSignatures($header); if ($timestamp === -1) { throw new BoldSignException('Unable to parse timestamp from the header'); } if (empty($signatures)) { throw new BoldSignException('Unable to parse signatures from the header'); } $signedPayload = "{$timestamp}.{$payload}"; $computedSignature = self::computeSignature($signedPayload, $secret); $isMatched = false; foreach ($signatures as $signature) { if (self::secureCompare($computedSignature, $signature)) { $isMatched = true; break; } } if (!$isMatched) { throw new BoldSignException('No signatures found matching with the computed signature for payload'); } if (($tolerance > 0) && (\abs(\time() - $timestamp) > $tolerance)) { throw new BoldSignException('Payload timestamp was outside the tolerance zone'); } return true; } private static function getTimestamp($header) { $items = \explode(',', $header); foreach ($items as $item) { $itemParts = \explode('=', $item, 2); if ($itemParts[0] === 't') { if (!\is_numeric($itemParts[1])) { return -1; } return (int) ($itemParts[1]); } } return -1; } private static function getSignatures($header) { $signatures = []; $items = \explode(',', $header); foreach ($items as $item) { $itemParts = \explode('=', $item, 2); if (\trim($itemParts[0]) === "s0" || \trim($itemParts[0]) === "s1") { $signatures[] = $itemParts[1]; } } return $signatures; } private static function computeSignature($payload, $secret) { return \hash_hmac('sha256', $payload, $secret); } private static function secureCompare($a, $b) { if (\strlen($a) !== \strlen($b)) { return false; } $result = 0; for ($i = 0; $i < \strlen($a); ++$i) { $result |= \ord($a[$i]) ^ \ord($b[$i]); } return $result === 0; } } $event_header = $_SERVER['HTTP_X_BOLDSIGN_EVENT']; if ($event_header == "Verification") { http_response_code(200); return; } // TODO: Update your webhook secret key $SECRET_KEY = "<<<<SECRET_KEY>>>>"; $payload = @file_get_contents('php://input'); $header = $_SERVER['HTTP_X_BOLDSIGN_SIGNATURE']; try { $isFromBoldSign = BoldSignHelper::isFromBoldSign($payload, $header, $SECRET_KEY); } catch (BoldSignException $e) { // Invalid signature http_response_code(400); exit(); } // Handle the event http_response_code(200);
import java.math.BigInteger; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import com.sun.net.httpserver.HttpServer; public class Server { // TODO: Update your secret key private static final String SECRET_KEY = "<<<<SECRET_KEY>>>>"; public static void main(String[] args) { try { HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0); server.createContext("/", httpExchange -> { var eventHeader = httpExchange.getRequestHeaders().get("x-boldsign-event"); if (eventHeader.get(0).equals("Verification")) { httpExchange.sendResponseHeaders(200, 0); httpExchange.getResponseBody().close(); return; } var sigHeaders = httpExchange.getRequestHeaders().get("x-boldsign-signature"); var requestBody = new String(httpExchange.getRequestBody().readAllBytes()); String signatureHeader = sigHeaders.get(0); try { BoldSignHelper.isFromBoldsign(signatureHeader, requestBody, SECRET_KEY, 300); httpExchange.sendResponseHeaders(200, 0); httpExchange.getResponseBody().close(); } catch (BoldsignException e) { httpExchange.sendResponseHeaders(403, 0); httpExchange.getResponseBody().close(); return; } }); server.start(); } catch (Throwable tr) { tr.printStackTrace(); } } } class BoldSignHelper { // Default time tolerance is 300 (5 minutes) public static boolean isFromBoldsign(String signatureHeader, String rawRequestBody, String secretKey) throws BoldsignException { return isFromBoldsign(signatureHeader, rawRequestBody, secretKey, 300); } // Default time tolerance is 300 (5 minutes) public static boolean isFromBoldsign(String signatureHeader, String rawRequestBody, String secretKey, int tolerance) throws BoldsignException { if (signatureHeader == null || signatureHeader.isEmpty()) { throw new BoldsignException("Signature header cannot be null or empty"); } if (rawRequestBody == null || rawRequestBody.isEmpty()) { throw new BoldsignException("RAW request body string cannot be null or empty"); } if (secretKey == null || secretKey.isEmpty()) { throw new BoldsignException("Secret Key cannot be null or empty"); } ParseHeaders parseHeader = parseHeader(signatureHeader); if (parseHeader.timestamp == -1) { throw new BoldsignException("Unable to parse timestamp from the header"); } if (parseHeader.signatures.size() == 0) { throw new BoldsignException("Unable to parse signatures from the header"); } String computedSignature; try { computedSignature = computeHamcSignature(secretKey, parseHeader.timestamp + "." + rawRequestBody); } catch (Exception e) { throw new BoldsignException("Unable to compute signature for payload"); } var isMatched = false; for (String signature : parseHeader.signatures) { if (secureCompare(computedSignature, signature)) { isMatched = true; break; } } if (!isMatched) { throw new BoldsignException("No signatures found matching with the computed signature for payload"); } // Check tolerance if ((tolerance > 0) && (parseHeader.timestamp < ((System.currentTimeMillis() / 1000L) - tolerance))) { throw new BoldsignException("Timestamp outside the tolerance zone"); } return true; } private static ParseHeaders parseHeader(String signatureHeader) throws BoldsignException { var timestamp = -1; List<String> parsedSignatures = new ArrayList<String>(); for (String header : signatureHeader.split(",")) { List<String> pair = Arrays.asList(header.strip().split("=", -1)) .stream() .map(x -> x.strip()) .dropWhile(x -> x == null || x.isEmpty()) .collect(Collectors.toList()); if (pair.size() == 0 || pair.size() == 1) { throw new BoldsignException("Expected signature header value seems to be empty"); } switch (pair.get(0)) { case "t": timestamp = Integer.parseInt(pair.get(1)); break; case "s0": case "s1": parsedSignatures.add(pair.get(1)); break; } } return new ParseHeaders(timestamp, parsedSignatures); } private static String computeHamcSignature(String secretKey, String payload) { byte[] hmacSha256 = null; try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"); mac.init(secretKeySpec); hmacSha256 = mac.doFinal(payload.getBytes()); } catch (Exception e) { throw new RuntimeException("Failed to calculate hmac-sha256", e); } return String.format("%064x", new BigInteger(1, hmacSha256)); } private static boolean secureCompare(String a, String b) { byte[] digesta = a.getBytes(StandardCharsets.UTF_8); byte[] digestb = b.getBytes(StandardCharsets.UTF_8); return MessageDigest.isEqual(digesta, digestb); } } class BoldsignException extends Exception { public BoldsignException() { } public BoldsignException(String errorMessage) { super(errorMessage); } } class ParseHeaders { public final int timestamp; public final List<String> signatures; public ParseHeaders(int timestamp, List<String> signatures) { this.timestamp = timestamp; this.signatures = signatures; } }
Roll secret key
The webhook signing secret key can be changed by using the "Roll Secret" option. Rolling the secret will also provide you option on how long you want to keep the old secret in order to provide you the optimal time to switch the old secret for the new one within your application. This will be useful when you want to roll secret in a production application to avoid downtime.
Step 1: Click the "Roll Secret" button to reveal the roll secret option.
Step 2: Choose the expiration date for the current secret. You can select up to 24 hours, and webhook events will include signatures generated using both the old and new signing secret keys.
Importance of event verification
Since the webhook endpoints are open and public, they can be called by anyone and compromise your application data. So, all the webhook events sent to your endpoint must be validated to ensure that they came from the BoldSign.