Webhooks
Receive notifications sent to your backend when request and payment events occur.
Volley sends webhooks to your backend as payment requests move through their lifecycle, e.g. when a payment is initiated, completed, or failed. This lets you react to events in real time without polling our API.
You configure your webhook endpoint in the Volley Dashboard. This must be a publicly accessible URL on your backend server. Volley wsends a POST request to your endpoint with a JSON payload describing each event and your server should respond with a 2XX status to acknowledge receipt.
All webhook requests include a signature header so you can verify they're coming from Volley and not a third party. See authentication below.
Authentication
As your webhook endpoint is public to the internet, webhook requests include a header for signature verification. This allows you to check the request is legitimate and came from Volley and prevents malicious use or spoofing.
Signatures are added as a header to the request to your webhook endpoint:
X-Volley-Signature: sha256=f7bc83f4305...You will be able to retrieve your webhook secret when creating each new webhook in the Dashboard. Your secret will only be displayed to you once, and you'll need to securely store it within your own backend (e.g. in secure environment variables or a runtime secrets manager). If you've lost access to your secret you will need to re-create your webhook.
Validating the signature
You can validate the signature on an incoming webhook request 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"];
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")
if validateWebhook(payload, secret, signature) {
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) {
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']
if WebhookValidator.validate(payload, secret, signature)
process_webhook(payload)
head :ok
else
head :unauthorized
end
end
private
def process_webhook(payload)
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();
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;
if (validateWebhook(payload, secret, signature)) {
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) {
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
If you've been unable to accept webhooks outside of these conditions get in touch and we can arrange re-delivery.
Event types
Volley will send webhooks for the events below. Each webhook contains a type field that identifies the event, and a data payload that is specific to each type.
{
"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",
}
}Recommendations
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.
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 if 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.