Webhooks
Volley can send webhooks directly to your backend throughout the request and payment lifecycle. Webhooks can be registered in advance during onboarding.
Authentication
As your webhook endpoint is public to the internet, webhook requests include a header for signature verification, allowing you to check the request is legitimate and preventing malicious use. Signatures are added as a header to the request to your webhook endpoint, with the following format:
X-Volley-Signature: sha256=f7bc83f4305...
Volley will setup the shared secret required for signature verification with you during onboarding.
Validating the signature
Volley will provide you with an HMAC secret with your webhook which you can use to validate the signature by recomputing it using the entire request body as the payload.
#!/bin/bash
PAYLOAD='<Webhook HTTP request body>'
BASE64_SECRET='<Your webhook secret>'
PROVIDED_SIG='sha256=... <signature from request header>'
CALCULATED=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$(echo -n "$BASE64_SECRET" | base64 -d)" | cut -d' ' -f2)
EXPECTED=$(echo "$PROVIDED_SIG" | cut -d'=' -f2)
echo "Calculated: $CALCULATED"
echo "Expected : $EXPECTED"
if [ "$CALCULATED" = "$EXPECTED" ]; then
echo "Valid"
else
echo "Invalid"
fi
[HttpPost]
public IActionResult Webhook([FromBody] string payload)
{
string signature = Request.Headers["X-Volley-Signature"];
string secret = Configuration["WebhookSecret"]; // This should be the HMAC secret I gave you
if (WebhookValidator.ValidateWebhook(payload, secret, signature))
{
// Process webhook
return Ok();
}
return Unauthorized();
}
public static class WebhookValidator
{
public static bool ValidateWebhook(string payload, string base64Secret, string providedSignature)
{
try
{
byte[] secretKey = Convert.FromBase64String(base64Secret);
using (var hmac = new HMACSHA256(secretKey))
{
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
byte[] hash = hmac.ComputeHash(payloadBytes);
string calculated = Convert.ToHexString(hash).ToLowerInvariant();
string expected = providedSignature.StartsWith("sha256=")
? providedSignature.Substring(7)
: providedSignature;
return calculated == expected;
}
}
catch
{
return false;
}
}
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
payload := string(body)
signature := r.Header.Get("X-Volley-Signature")
secret := os.Getenv("WEBHOOK_SECRET") // Base64 encoded HMAC secret
if validateWebhook(payload, secret, signature) {
// Process webhook
processWebhook(payload)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusUnauthorized)
}
func validateWebhook(payload, base64Secret, providedSignature string) bool {
secretKey, err := base64.StdEncoding.DecodeString(base64Secret)
if err != nil {
return false
}
h := hmac.New(sha256.New, secretKey)
h.Write([]byte(payload))
calculated := hex.EncodeToString(h.Sum(nil))
expected := providedSignature
if strings.HasPrefix(providedSignature, "sha256=") {
expected = providedSignature[7:]
}
return hmac.Equal([]byte(calculated), []byte(expected))
}
func processWebhook(payload string) {
// TODO: Add your webhook processing logic here
println("Processing webhook:", payload)
}
class WebhookController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
payload = request.body.read
signature = request.headers['X-Volley-Signature']
secret = ENV['WEBHOOK_SECRET'] # Base64 encoded HMAC secret
if WebhookValidator.validate(payload, secret, signature)
# Process webhook
process_webhook(payload)
head :ok
else
head :unauthorized
end
end
private
def process_webhook(payload)
# TODO: Add your webhook processing logic here
Rails.logger.info "Processing webhook: #{payload}"
end
end
class WebhookValidator
def self.validate(payload, base64_secret, provided_signature)
return false if base64_secret.nil? || provided_signature.nil?
begin
secret_key = Base64.decode64(base64_secret)
calculated = OpenSSL::HMAC.hexdigest('sha256', secret_key, payload)
expected = provided_signature.start_with?('sha256=') ?
provided_signature[7..-1] :
provided_signature
OpenSSL.fixed_length_secure_compare(calculated, expected)
rescue
false
end
end
end
const express = require('express');
const crypto = require('crypto');
const app = express();
// Middleware to get raw body for signature verification
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const payload = req.body.toString();
const signature = req.headers['x-volley-signature'];
const secret = process.env.WEBHOOK_SECRET; // Base64 encoded HMAC secret
if (validateWebhook(payload, secret, signature)) {
// Process webhook
processWebhook(payload);
res.status(200).send('OK');
} else {
res.status(401).send('Unauthorized');
}
});
function validateWebhook(payload, base64Secret, providedSignature) {
if (!base64Secret || !providedSignature) return false;
try {
const secretKey = Buffer.from(base64Secret, 'base64');
const calculated = crypto.createHmac('sha256', secretKey)
.update(payload, 'utf8')
.digest('hex');
const expected = providedSignature.startsWith('sha256=') ?
providedSignature.substring(7) :
providedSignature;
return crypto.timingSafeEqual(Buffer.from(calculated), Buffer.from(expected));
} catch {
return false;
}
}
function processWebhook(payload) {
// TODO: Add your webhook processing logic here
console.log('Processing webhook:', payload);
}
Retry behaviour
Volley will retry webhook delivery, until a 2XX status is provided with the following conditions:
-
Backoff time: 0.25s to 10.0s with jitter
-
Maximum attempts: 20
-
Maximum retry duration: 10 minutes
Events
Each webhook will contain a type
field that identifies the event and event specific data.
Volley will send webhooks for the events below.
{
"type": "request.created",
"version": "1",
"data": {
"id": "request_8GbnJK6WrxGvPobCylFDO",
"merchant_identifier": "e18330ee725f4553b88b0046e683190b",
"user_id": "user_cEwCTcXyxhdFbJLyune6x",
"created_at": "2025-02-12T08:30:00Z",
"expires_at": "2025-02-12T08:35:00Z"
}
}
{
"type": "request.expired",
"version": "1",
"data": {
"id": "request_8GbnJK6WrxGvPobCylFDO",
"user_id": "user_cEwCTcXyxhdFbJLyune6x",
"merchant_identifier": "e18330ee725f4553b88b0046e683190b",
"status": "expired",
"updated_at": "2025-02-12T08:45:00Z",
}
}
{
"type": "request.updated",
"data": {
"id": "request_8GbnJK6WrxGvPobCylFDO",
"user_id": "user_cEwCTcXyxhdFbJLyune6x",
"merchant_identifier": "e18330ee725f4553b88b0046e683190b",
"status": "paid",
"updated_at": "2025-02-12T08:45:00Z",
}
}
{
"type": "payment.created",
"version": "1",
"data": {
"id": "payment_Bzv6djpVl07tmMx2Tuode",
"request_id": "request_8GbnJK6WrxGvPobCylFDO",
"user_id": "user_cEwCTcXyxhdFbJLyune6x",
"merchant_identifier": "e18330ee725f4553b88b0046e683190b",
"status": "awaiting-consent",
"updated_at": "2025-02-12T08:45:00Z",
}
}
{
"type": "payment.status_updated",
"version": "1",
"data": {
"id": "payment_Bzv6djpVl07tmMx2Tuode",
"request_id": "request_8GbnJK6WrxGvPobCylFDO",
"user_id": "user_cEwCTcXyxhdFbJLyune6x",
"merchant_identifier": "e18330ee725f4553b88b0046e683190b",
"status": "[successful|cancelled|failed|unconfirmed]",
"updated_at": "2025-02-12T08:45:00Z",
}
}
Implementation Guide
Handling Duplicate Events and Ordering
Handle duplicates by tracking processed events. Check if you've already processed a specific payment or request by storing its ID and status in your system. If the event has already been processed you can success response immediately.
Handle out-of-order delivery using timestamps. Due to network failures and retries, webhooks may arrive out of chronological order. Always compare the updated_at
timestamp and ignore webhook updates that are older than what you've already processed to prevent overwriting newer data with stale information.
Responses
Respond with a 2xx status code within 15 seconds. If your webhook handler needs more time to process actions like sending emails or updating external systems, we recommend using a queue to complete those actions asynchronously after responding to the webhook.
Return 204 to stop redeliveries. Use a 204 status code when you want to acknowledge the webhook but stop further retry attempts. Any 4xx or 5xx response will cause Volley to retry the webhook delivery according to our retry policy.
Local development
To test webhooks with a local development server, Volley’s server needs to be able to reach your local machine over the internet using a tunnel service.
We recommend using webhook.site. Requests can viewed in the UI and forwarded to your local app over XHR (with CORS) and/or with the webhook.site CLI.