How to Configure Webhooks
Set up a webhook endpoint, register it in the Forage dashboard, and verify signatures
To start receiving webhooks from Forage, complete the following steps. (See Webhook Events for available events and payload schemas.)
To configure a webhook:
- Set up your receiving endpoint
- Add the webhook to the Forage dashboard
- Verify the webhook signature
- Verify the webhook timestamp
Step 1: Set up your receiving endpoint
Your endpoint should be publicly available and accept a POST request with a JSON payload. Confirm that your endpoint returns a 200 when you receive the webhook. If the 200 isn't received, then Forage retries the webhook with an exponential backoff.
Example endpoints
# Python Django Example
@api_view(http_method_names=["POST"])
def handle_webhook(request):
event = request.data
received_signature = request.headers.get("Webhook-Signature")
# Validate signature (more instructions in Step 3)
event_type = event["type"]
# Handle the event
if event_type == "ORDER_STATUS_UPDATED":
# do something with order
order_ref = event["data"]["order_ref"]
pass
elif event_type == "PAYMENT_STATUS_UPDATED":
payment_ref = event["data"]["payment_ref"]
pass
elif event_type == "REFUND_STATUS_UPDATED":
refund_ref = event["data"]["refund_ref"]
pass
# Return a 200 response to acknowledge receipt of the event
return HttpResponse(status=200)
// Java Spring Example
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebhookController {
@PostMapping("/mywebhookendpoint")
public ResponseEntity<String> handleRequest(@RequestBody MyRequestData requestData, HttpServletRequest request) {
String received_signature = request.getHeader("Webhook-Signature");
// Validate signature (more instructions in Step 3)
String eventType = requestData.getType();
// Handle the event
switch (eventType) {
case "ORDER_STATUS_UPDATED": {
break;
}
case "PAYMENT_STATUS_UPDATED": {
break;
}
case "REFUND_STATUS_UPDATED": {
break;
}
default:
System.out.println("Webhook Event is unhandled");
}
// Return a 200 response to acknowledge receipt of the event
String responseBody = "Processed Webhook";
return new ResponseEntity<>(responseBody, HttpStatus.OK);
}
}// Node Example
const express = require('express');
const app = express();
app.post('/mywebhookendpoint', express.raw({type: 'application/json'}), (request, response) => {
const signature = request.headers['Webhook-Signature'];
// Validate signature (more instructions in Step 3)
let event;
event = request.body
// Handle the event
switch (event.type) {
case 'ORDER_STATUS_UPDATED':
const orderRef = event.data.order_ref;
// Handle order ref
break;
case 'PAYMENT_STATUS_UPDATED':
const paymentRef = event.data.payment_ref
// Handle payment ref
break;
case 'REFUND_STATUS_UPDATED':
const refundRef = event.data.refund_ref
// Handle refund ref
break;
default:
console.log(`Event type ${event.type} not handled`);
}
// Return a 200 response to acknowledge receipt of the event
response.send();
});require 'json'
require 'sinatra'
post '/webhook' do
event = JSON.parse(request.body.read)
received_signature = request.env['Webhook-Signature']
# Validate signature (more instructions in Step 3)
event_type = event['type']
# Handle the event
case event_type
when 'ORDER_STATUS_UPDATED'
# do something with order
order_ref = event['data']['order_ref']
when 'PAYMENT_STATUS_UPDATED'
payment_ref = event['data']['payment_ref']
when 'REFUND_STATUS_UPDATED'
refund_ref = event['data']['refund_ref']
end
# Return a 200 response to acknowledge receipt of the event
status 200
endAlways return 2XX
2XXYour endpoint should return 2XX for any well-formed payload, whether or not you act on the data.
Step 2: Add the webhook to the Forage dashboard
Navigate to Webhooks in the Forage dashboard. From the Webhooks page, click +Add webhook and provide the requested information:
- Webhook URL: The endpoint that receives the webhook (you set this up in Step 1)
- Status: Whether the webhook is Active, currently being listened for, or Inactive
- Subscribed Events: The Forage webhook events to listen for
- Notification Email: An email address that Forage can use to contact you in case of webhook errors
Step 3: Verify the webhook signature
Forage uses an HMAC with SHA-256 to sign its webhooks. The signature is passed in the webhook payload Webhook-Signature header, for example:
Webhook-Signature: be521964c21a8eb7f5ddd0f45b5bf83d8904a4dd4238b7b14f3eee73fa9c21f2
As a security measure, Forage requires that you validate the Webhook-Signature value against a signature that you calculate using the webhook's secret from the Forage dashboard. This verifies that the webhook message came from Forage.
To calculate the signature, retrieve the webhook's secret from the Forage dashboard and convert the webhook payload to a JSON string. Pass the secret as the key and the stringified payload as the message to a hash function to compute the HMAC with SHA-256. Finally, compare the signature that you computed with the Webhook-Signature.
flowchart TD
A[Receive webhook POST] --> B[Extract Webhook-Signature header]
B --> C[Get webhook secret from Forage dashboard]
C --> D[Compute HMAC-SHA256\nsecret + raw request body]
D --> E{Constant-time compare:\ncomputed == received?}
E -->|Match| F[Process webhook payload]
E -->|No match| G[Reject: discard payload]
Example comparison
Use the raw request bodyForage calculates the signature using the webhook's raw request body. To avoid miscalculating the signature, make sure that any framework that you're using doesn't manipulate the request body. For example, you might need to skip parsing if the framework removes whitespaces (e.g. Express.js).
Use constant-time comparisonUse a constant-time string comparison to compare the two signatures to avoid timing attacks.
import hashlib
import hmac
def get_hmac_string(secret, message):
return hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Django example
@api_view(http_method_names=["POST"])
def webhook_view(request):
webhook_payload = request.body # ie. "{\"ref\": \"6ce5bdb204\", \"created\": \"2023-06-23T18:48:13.791077+00:00\", \"type\": \"ORDER_STATUS_UPDATED\", \"data\": {\"order_ref\": \"3b96a5312a\", \"status\": \"canceled\", \"snap_total\": \"20.00\", \"ebt_cash_total\": \"20.00\", \"remaining_total\": \"0.00\"}}"
my_secret = "wh_secretabc123"
# Compute expected signature
expected_signature = get_hmac_string(my_secret, webhook_payload)
received_signature = request.headers.get("Webhook-Signature")
# Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
if not hmac.compare_digest(expected_signature, received_signature):
raise SignatureError()import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.MessageDigest;
public class WebhookHandler {
public static String getHmacString(String secret, String message) throws NoSuchAlgorithmException, InvalidKeyException {
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKey);
byte[] hmacBytes = sha256Hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte hmacByte : hmacBytes) {
String hex = Integer.toHexString(0xff & hmacByte);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
public static boolean constantTimeEquals(String str1, String str2) throws NoSuchAlgorithmException {
byte[] digest1 = getDigest(str1);
byte[] digest2 = getDigest(str2);
// Compare the digests using MessageDigest.isEqual()
return MessageDigest.isEqual(digest1, digest2);
}
// Spring Boot example
@PostMapping("/webhook")
public void webhookHandler(@RequestBody String webhookPayload, @RequestHeader("Webhook-Signature") String receivedSignature) {
String mySecret = "wh_secretabc123";
try {
// Compute expected signature
String expectedSignature = getHmacString(mySecret, webhookPayload);
// Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
if (!constantTimeEquals(expectedSignature, receivedSignature)) {
throw new SignatureError();
}
// Process the webhook payload
// ...
} catch (Exception e) {
// Handle the exception
e.printStackTrace();
}
}
}const crypto = require('crypto');
function getHmacString(secret, message) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(message, 'utf8');
return hmac.digest('hex');
}
// Express example
app.post('/webhook', (req, res) => {
const webhookPayload = req.body.toString(); // assuming the request body contains the JSON payload
const mySecret = 'wh_secretabc123';
try {
// Compute expected signature
const expectedSignature = getHmacString(mySecret, webhookPayload);
const receivedSignature = req.headers['webhook-signature'];
// Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
if (!crypto.timingSafeEqual(Buffer.from(expectedSignature, 'hex'), Buffer.from(receivedSignature, 'hex'))) {
throw new Error('SignatureError');
}
// Process the webhook payload
// ...
res.sendStatus(200);
} catch (error) {
// Handle the error
res.status(500).send(error.message);
}
});require 'openssl'
require 'base64'
require 'sinatra'
def get_hmac_string(secret, message)
digest = OpenSSL::Digest.new('sha256')
hmac = OpenSSL::HMAC.hexdigest(digest, secret, message)
hmac
end
# Sinatra example
post '/webhook' do
webhook_payload = request.body.read
my_secret = 'wh_secretabc123'
# Compute expected signature
expected_signature = get_hmac_string(my_secret, webhook_payload)
received_signature = request.env['HTTP_WEBHOOK_SIGNATURE']
# Assert that computed signature above equals Webhook-Signature header using constant-time string comparison
unless Rack::Utils.secure_compare(expected_signature, received_signature)
raise 'SignatureError'
end
# Process the webhook payload
# ...
status 200
endStep 4: Verify timestamp
To further validate the webhook, compare the created value in the payload against your system's timestamp for the corresponding resource. For example, confirm that the created value of a PAYMENT_STATUS_UPDATED webhook and the timestamp for when the payment was created in your system are within a few seconds, or whatever your risk tolerance is.
Related documentation
- How Forage Webhooks Work. What webhooks are, when events fire, and how Forage delivers them.
- Reference: Webhook Events. Specs for all event types, payload schemas, field definitions, and IP addresses.
- Errors (API Reference). Full error response schema and general API error codes.
Updated 2 months ago
