Webhook Signature Verification
Introduction
Pinwheel signs all webhook events that it sends to your endpoints. This allows you to verify that events were sent by Pinwheel, not a third party.
Verifying Pinwheel is the Sender
Each webhook event includes the x-pinwheel-signature
header. This header contains a signature in the form {signature_version}={signature_digest}
. The {signature_version}
should be v2
. The {signature_digest}
is a hex-encoded string generated using the signature algorithm.
You should use a string comparison function that is safe against timing attacks when comparing signatures. Otherwise, it may be possible for an attacker to ascertain information about secret values such as your API Secret. See the examples below for constant-time comparison functions in various languages.
Signature Algorithm
The signature algorithm is specified in pseudo-code. We also include example implementations in various languages. To verify that your implementation is compliant, please leverage the test suite at the bottom of this page.
- Retrieve the
{timestamp}
, stored in thex-timestamp
header. - Retrieve the
{raw_request_body}
.- This should be the un-parsed, un-decoded bytes from the HTTP request body. No UTF-8 decoding or JSON processing should have occurred on this value. We use the raw bytes to avoid per-language/framework processing differences.
- See the examples below for how to retrieve this in various web frameworks.
- Set
{key}
as your API Secret. - Construct the
{msg_prefix}
by UTF-8 encoding the string{signature_version}:{timestamp}:
. Notice the trailing:
. - Construct the
{msg}
by byte-concatenating{msg_prefix}
and{raw_request_body}
. - Calculate the
{raw_digest}
by running HMAC-SHA256 with{key}
on{msg}
. - Produce
{signature_digest}
by hex-encoding the{raw_digest}
.
Example Implementations
# Python
import hmac
import hashlib
def verify_signature(signature, timestamp, raw_body):
msg = f"v2:{timestamp}:".encode("utf-8") + raw_body
digest = hmac.new(
key="YOUR_API_SECRET".encode("utf-8"),
msg=msg,
digestmod=hashlib.sha256,
).hexdigest()
generated_signature = f"v2={digest}"
return hmac.compare_digest(signature, generated_signature)
# Example for Flask
def webhook_view(request):
signature = request.headers.get("x-pinwheel-signature")
timestamp = request.headers.get("x-timestamp")
raw_body = request.get_data(parse_form_data=True) # raw HTTP request body as a bytestring
# for Django, `raw_body = request.body`
# for Starlette/FastAPI, `raw_body = await request.body()`
if not verify_signature(signature, timestamp, raw_body):
raise Exception("Invalid webhook request signature")
// Javascript
const crypto = require('crypto');
const express = require('express');
function verifySignature(signature, timestamp, rawBody) {
const prefix = Buffer.from(`v2:${timestamp}:`, 'utf8');
const message = Buffer.concat([prefix, rawBody]);
const digest = crypto.createHmac('sha256', Buffer.from(process.env.YOUR_PINWHEEL_API_SECRET, 'utf8')).update(message).digest('hex');
const generatedSignature = `v2=${digest}`;
const bufSig = Buffer.from(signature);
const bufGen = Buffer.from(generatedSignature);
return bufSig.length === bufGen.length && crypto.timingSafeEqual(bufSig, bufGen);
}
export const authMiddleware = express.json({
verify: (request, response, buffer) => {
const signature = request.headers['x-pinwheel-signature']
const timestamp = request.headers['x-timestamp']
if (verifySignature(signature, timestamp, buffer)) return;
throw new Error('Invalid webhook request signature');
},
});
// Java
// - leveraging the okio library (https://square.github.io/okio/)
import okio.Buffer;
import okio.ByteString;
class PinwheelWebhookUtilities {
static ByteString SIGNATURE_PREFIX = ByteString.encodeUtf8("v2:");
static ByteString SIGNATURE_SEP = ByteString.encodeUtf8(":");
static int SIGNATURE_VERSION_HEADER_LEN = "v2=".length();
public static boolean verifySignature(String signature, String timestamp, ByteString rawBody) {
try (Buffer msg = new Buffer()) {
msg.write(SIGNATURE_PREFIX)
.write(ByteString.encodeUtf8(timestamp))
.write(SIGNATURE_SEP)
.write(rawBody);
ByteString digest = msg.hmacSha256(ByteString.encodeUtf8("YOUR_API_SECRET"));
ByteString expectedDigest = ByteString.decodeHex(signature.substring(SIGNATURE_VERSION_HEADER_LEN));
return digest.equals(expectedDigest);
}
}
}
Test Suite
We've put together test cases to cover a variety of payload structures your signature implementation may need to accommodate. To be prepared for the set of valid webhook events our system may send your endpoints your signature algorithm implementation must be compliant with the first 4 (text-based) test cases and should be compliant with the last (non-text) test case.
We used the following parameters as fixed inputs when generating each {signature_digest}
:
{timestamp}
=860860860
{key}
= the stringTEST_KEY
UTF-8 encoded, i.e.544553545f4b4559
in hex, i.e."TEST_KEY".encode("utf-8")
in Python.
You should download the test case files directly, using a command such as curl --remote-name $TEST_CASE_FILE_URL
, to avoid introducing newlines or other extraneous characters into them which will affect their binary contents. Similarly, when viewing test cases make sure to use a read-only mechanism, such as cat $TEST_CASE_FILE
, to avoid affecting their binary contents.
Test Case Description | Test Case File, {raw_request_body} | {signature_digest} |
---|---|---|
Base JSON payload | test_data_1_base.json | 42fb9eba200e821d4de63667f5a30f7e1b83609b135e148e26ce01eef2aa6ba8 |
Base JSON with keys in a different order | test_data_2_reordered.json | 032457677fee3481d60bdd3896554762f0d66ad277fead31da997ead50a645f6 |
Base JSON with all whitespace removed | test_data_3_no_whitespace.json | 46df1cb08a430de9c808de2b5209dea4a4fafca5a8a3a0c5af0ca705c62f3c32 |
Base JSON with characters (emoji) outside the Latin-1 character set | test_data_4_non_latin1.json | 1d694c72f8d5e00ef80f63bb3963f1097a02c4fb79cc7624eb42d6d566655d2d |
Binary, non-text data (image) | test_data_5_non_text.jpg | 3ebb15420de1f53cb20e0660c57952dabff41d9c38f718fb3ddec6140199f1d8 |
IP Address Allow List
Pinwheel will only send webhook events from a fixed number of IP addresses. For an extra layer of security, you should reject requests from elsewhere. The IP addresses are:
- 3.227.93.147/32
- 18.214.233.159/32
- 35.168.171.219/32
- 54.235.190.63/32
Please contact [email protected] for access to our Dashboard.
Updated 4 months ago