Volley

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.

On this page