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.