less than a minute read • Updated an hour ago
Verify a webhook payload
How to confirm a webhook request came from Foxy using the payload signature, with example endpoint code in PHP, Node, and Ruby.
Once a webhook is configured, a JSON payload is sent to your endpoint whenever a subscribed event occurs. If your endpoint is secured with HTTPS, the payload is sent unencrypted, ready to use. If your endpoint isn’t secured, the payload is encrypted with AES-256-CBC before being sent, and will need to be decrypted before use — see Verify and decrypt a legacy webhook payload for the decryption steps, which use the same method.
Headers
Every webhook request includes these headers:
Header | Description |
|---|---|
| Name of the event that triggered this payload |
| An HMAC SHA256 signature of the payload, using the webhook’s encryption key. Used to verify the contents of the payload |
| A boolean signifying whether this payload has been refed. If false, this is the first time this instance of the event has been triggered |
| The ID of the store this webhook was triggered for |
| The store domain this webhook was triggered for |
Validating the signature
Using the encryption key associated with the webhook, a signature of the payload is generated and passed as the Foxy-Webhook-Signature header. To validate a request on your endpoint:
Always use a secure/constant-time comparison function for this step — hash_equals() in PHP 5.6+, secure_compare() in Ruby — rather than a direct == comparison.
Example endpoints
The following examples validate the payload signature and handle the parsed JSON.
PHP
<?php
define('FOXY_WEBHOOK_ENCRYPTION_KEY', 'ABC123');
$data = file_get_contents('php://input');
$parsedData = json_decode($data, true);
$event = $_SERVER['HTTP_FOXY_WEBHOOK_EVENT'];
// Verify the webhook payload
$signature = hash_hmac('sha256', $data, FOXY_WEBHOOK_ENCRYPTION_KEY);
if (!hash_equals($signature, $_SERVER['HTTP_FOXY_WEBHOOK_SIGNATURE'])) {
echo "Signature verification failed - data corrupted";
http_response_code(500);
return;
}
if (is_array($parsedData)) {
// Handle the payload
if ($event == "transaction/created") {
// Example of working with the transaction/created payload
$email_address = $parsedData['customer_email'];
$billing_country = $parsedData['_embedded']['fx:billing_addresses']['country'];
$shipping_country = $parsedData['_embedded']['fx:shipments'][0]['country']; // Assuming single-ship
$has_small_product_a = false;
$has_large_product_a = false;
foreach ($parsedData['_embedded']['fx:items'] as $item) {
$name = $item['name'];
$quantity = $item['quantity'];
$category = $item['_embedded']['fx:item_category']['code'];
if ($item['name'] == "Product A") {
foreach($item['_embedded']['fx:item_options'] as $item_option) {
if ($item_option['name'] == "size") {
if ($item_option['value'] == "small") {
$has_small_product_a = true;
} else if ($item_option['value'] == "large") {
$has_large_product_a = true;
}
}
}
}
}
}
} else {
// JSON data not found
echo("No data");
http_response_code(500);
return;
}
Node
You can also use the official SDK to make pieces of this easier, but this example uses minimal dependencies:
const crypto = require('crypto');
const foxyEncryptionKey = 'YOUR_ENCRYPTION_KEY_HERE';
const foxyStoreId = 'YOUR_STORE_ID_HERE';
const foxyStoreDomain = 'YOUR_STORE_DOMAIN_HERE';
const http = require('http');
const server = http.createServer(handleRequest);
const routes = {
'transaction/created': handleTransactionCreated,
}
function handleRequest(request, response) {
if (!validFoxyRequest(request)) {
response.statusCode = 403; // Forbidden
response.write('Forbidden');
return response.end();
}
const bodyChunks = [];
request.on('data', (chunk) => bodyChunks.push(chunk));
request.on('end', () => {
let body = Buffer.concat(bodyChunks).toString()
if (!validFoxySignature(request.headers['foxy-webhook-signature'], body)){
response.statusCode = 403; // Forbidden
response.write('Forbidden');
return response.end();
}
try {
body = JSON.parse(body);
} catch(e) {
response.statusCode = 400; // Bad Request
response.write('Bad Request');
return response.end();
}
const foxyEvent = request.headers['foxy-webhook-event'];
const responseData = routes[foxyEvent](request.headers, body);
console.log(responseData);
if (responseData) {
response.statusCode = 200;
response.write(JSON.stringify(responseData));
return response.end();
}
response.statusCode = 500;
response.end();
});
}
function validFoxyRequest(request) {
const postMethod = request.method === 'POST';
const headers = request.headers;
const routeForEventExists = Object.keys(routes).includes(headers['foxy-webhook-event']);
const foxySignatureExists = !!headers['foxy-webhook-signature'];
const foxyStoreIdIsCorrect = headers['foxy-store-id'] === foxyStoreId;
const foxyStoreDomainIsCorrect = headers['foxy-store-domain'] === foxyStoreDomain;
return postMethod && routeForEventExists && foxySignatureExists && foxyStoreIdIsCorrect && foxyStoreDomainIsCorrect;
}
function validFoxySignature(signature, payload) {
const referenceSignature = crypto.createHmac('sha256', foxyEncryptionKey).update(payload).digest('hex');
console.log(referenceSignature);
return signature === referenceSignature;
}
function handleTransactionCreated(headers, body) {
const emailAddress = body['customer_email'];
const billingCountry = body['_embedded']['fx:billing_addresses']['country'];
const shippingCountry = body['_embedded']['fx:shipments'][0]['country'];
// Check if there is a Product A and its size
let hasSmallA = false;
let hasLargeA = false;
for (let item of body['_embedded']['fx:items']) {
const name = item['name'];
const quantity = item['quantity'];
const category = item['_embedded']['fx:item_category']['code'];
if(item['name'] === "Product A") {
for (let itemOption of item['_embedded']['fx:item_options']) {
if (itemOption['name'] == 'size') {
if (itemOption['value'] === 'small') {
hasSmallA = true;
return {
ok: true,
details: "has small A"
}
} else if (itemOption['value'] === 'large') {
hasLargeA = true;
return {
ok: true,
details: "has large A"
}
}
}
}
}
}
}
server.listen(80);
Ruby
This example uses a small Sinatra app — some logic may need adjusting for a different framework.
require 'sinatra'
require 'json'
def verify_webhook(data, encryption_key)
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), encryption_key, data)
return halt 500, "Signature verification failed - data corrupted" unless Rack::Utils.secure_compare(signature, request.env['HTTP_FOXY_WEBHOOK_SIGNATURE'])
end
post '/webhook' do
request.body.rewind
data = request.body.read
event = request.env['HTTP_FOXY_WEBHOOK_EVENT']
verify_webhook(data, ENV['FOXY_WEBHOOK_ENCRYPTION_KEY'])
parsedData = JSON.parse(data)
# Handle the payload
puts parsedData['id']
end
Notes
Avoid storing your webhook encryption key directly in your codebase — store it as an environment variable instead.
When comparing signatures, always use a secure/constant-time comparison method rather than a direct equality check.