> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pushcash.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enable webhook notifications

<div className="Goal">
  ## Goal

  Receive asynchronous updates for payment authorizations so your internal transaction state remains accurate and reliable.
</div>

## Steps

***

## Step 1: Configure webhook delivery on authorization requests

<div className="What">
  <p className="bold-header">What you need to do</p>

  Include webhook configuration when submitting authorization requests so Push can notify your system of final payment results.
</div>

#### How to do it

When calling [authorize-payment](./apireference/authorization/authorize-payment), provide:

* A `webhook_url` where Push will deliver webhook events
* A `webhook_secret` used to verify webhook authenticity
* A `tag` that maps webhook events back to your internal transaction record

This ensures your system can recover final payment outcomes even if the authorization request times out or returns an
ambiguous response.

## Step 2: Receive and process webhook events

<div className="What">
  <p className="bold-header">What you need to do</p>

  Expose an HTTPS endpoint that can receive webhook events from Push, verify their authenticity, and update your internal
  transaction state.
</div>

#### How to do it

1. Create a HTTP endpoint at the configured `webhook_url` capable of receiving POST requests.
2. Verify the webhook signature and timestamp for each request (see [security](#security)).
3. Parse the webhook payload and update the transaction record in your database accordingly. Please refer to the [reference guide](./webhook-types) for the structure of the webhook payload.

Due to the asynchronous nature of webhook delivery, your integration must support out-of-order delivery and handle duplicate events idempotently. See [Independent Ordering](#independent-ordering) and [Idempotency](#idempotency) for more information.

## Security

Webhooks deliver data directly to an endpoint you control over the public internet. Because they are invoked automatically
by Push, webhook endpoints **must be explicitly secured** to prevent unauthorized requests, data tampering, and replay attacks.

Without proper verification, a malicious actor could spoof webhook requests and falsely mark payments as approved or declined in your system.

<Note>
  If your cloud data environment restricts network access from external IPs via a firewall, you may need to allow inbound
  traffic from Push IP addresses in order to receive webhook requests.

  <Accordion title="Push webhook source IP addresses">
    **Production** `44.238.180.175`

    **Sandbox** `34.209.246.44`
  </Accordion>
</Note>

### Signature verification

When a `webhook_secret` is provided, Push signs each webhook request using an HMAC-SHA256 signature derived from the raw
request payload. This allows your application to verify that:

* The request was sent by Push
* The payload has not been modified in transit

Every webhook request must be verified before it is processed.

**Signature format**

```
X-Webhook-Signature: sha256=<hex_encoded_hmac>
```

**Verification steps**

1. Extract the signature from the `X-Webhook-Signature` header
2. Read the raw request body as bytes (before parsing JSON)
3. Compute an HMAC-SHA256 signature using your `webhook_secret` and the raw body
4. Compare the computed signature to the received signature using constant-time comparison
5. Reject requests with invalid signatures (`401 Unauthorized`)

<Accordion title="Code examples for signature verification">
  <CodeGroup>
    ```javascript JavaScript (Node.js) theme={null}
    const crypto = require('crypto');

    function verifyWebhookSignature(secret, rawBody, signatureHeader) {
      // Parse signature from header
      if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
        return false;
      }
      
      const receivedSignature = signatureHeader.substring(7); // Remove 'sha256=' prefix
      
      // Compute expected signature
      const hmac = crypto.createHmac('sha256', secret);
      hmac.update(rawBody); // rawBody must be Buffer or string
      const expectedSignature = hmac.digest('hex');
      
      // Constant-time comparison to prevent timing attacks
      return crypto.timingSafeEqual(
        Buffer.from(receivedSignature, 'hex'),
        Buffer.from(expectedSignature, 'hex')
      );
    }

    // Express.js example
    app.post('/webhooks/push', express.raw({ type: 'application/json' }), (req, res) => {
      const signature = req.headers['x-webhook-signature'];
      const rawBody = req.body; // Raw buffer from express.raw()
      
      if (!verifyWebhookSignature(WEBHOOK_SECRET, rawBody, signature)) {
        return res.status(401).send('Invalid signature');
      }
      
      // Parse JSON after verification
      const payload = JSON.parse(rawBody.toString());
      
      // Verify timestamp (see below)
      // Process webhook...
      
      res.status(200).send('OK');
    });
    ```

    ```python Python theme={null}
    import hmac
    import hashlib
    import time
    from datetime import datetime, timezone


    def verify_webhook_signature(secret: str, raw_body: bytes, signature_header: str) -> bool:
       """Verify webhook signature using constant-time comparison."""
       if not signature_header or not signature_header.startswith('sha256='):
           return False
      
       received_signature = signature_header[7:]  # Remove 'sha256=' prefix
      
       # Compute expected signature
       expected_signature = hmac.new(
           secret.encode('utf-8'),
           raw_body,
           hashlib.sha256
       ).hexdigest()
      
       # Constant-time comparison
       return hmac.compare_digest(received_signature, expected_signature)


    # Flask example
    @app.route('/webhooks/push', methods=['POST'])
    def handle_webhook():
       signature = request.headers.get('X-Webhook-Signature')
       raw_body = request.get_data()
      
       if not verify_webhook_signature(WEBHOOK_SECRET, raw_body, signature):
           return 'Invalid signature', 401
      
       payload = request.get_json()
      
       # Verify timestamp (see below)
       # Process webhook...
      
       return 'OK', 200
    ```

    ```go Go theme={null}
    package main


    import (
       "crypto/hmac"
       "crypto/sha256"
       "encoding/hex"
       "io"
       "net/http"
       "strings"
    )


    func verifyWebhookSignature(secret string, rawBody []byte, signatureHeader string) bool {
       if !strings.HasPrefix(signatureHeader, "sha256=") {
           return false
       }
      
       receivedSignature := signatureHeader[7:] // Remove 'sha256=' prefix
      
       // Compute expected signature
       mac := hmac.New(sha256.New, []byte(secret))
       mac.Write(rawBody)
       expectedSignature := hex.EncodeToString(mac.Sum(nil))
      
       // Constant-time comparison
       return hmac.Equal([]byte(receivedSignature), []byte(expectedSignature))
    }


    func handleWebhook(w http.ResponseWriter, r *http.Request) {
       signature := r.Header.Get("X-Webhook-Signature")
       rawBody, _ := io.ReadAll(r.Body)
      
       if !verifyWebhookSignature(webhookSecret, rawBody, signature) {
           http.Error(w, "Invalid signature", http.StatusUnauthorized)
           return
       }
      
       // Parse JSON after verification
       var payload WebhookPayload
       json.Unmarshal(rawBody, &payload)
      
       // Verify timestamp (see below)
       // Process webhook...
      
       w.WriteHeader(http.StatusOK)
    }
    ```

    ```csharp C# (.NET) theme={null}
    using System;
    using System.Security.Cryptography;
    using System.Text;
    using Microsoft.AspNetCore.Mvc;

    public class WebhookSignatureVerifier
    {
        public static bool VerifyWebhookSignature(string secret, byte[] rawBody, string signatureHeader)
        {
            // Parse signature from header
            if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256="))
            {
                return false;
            }
            
            string receivedSignature = signatureHeader.Substring(7); // Remove 'sha256=' prefix
            
            // Compute expected signature
            using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
            {
                byte[] hashBytes = hmac.ComputeHash(rawBody);
                string expectedSignature = BitConverter.ToString(hashBytes)
                    .Replace("-", "")
                    .ToLower();
                
                // Constant-time comparison to prevent timing attacks
                return CryptographicOperations.FixedTimeEquals(
                    Encoding.UTF8.GetBytes(receivedSignature),
                    Encoding.UTF8.GetBytes(expectedSignature)
                );
            }
        }
    }

    // ASP.NET Core Controller Example
    [ApiController]
    [Route("webhooks")]
    public class WebhooksController : ControllerBase
    {
        private readonly string _webhookSecret;
        
        public WebhooksController(IConfiguration configuration)
        {
            _webhookSecret = configuration["WebhookSecret"];
        }
        
        [HttpPost("push")]
        public async Task<IActionResult> HandleWebhook()
        {
            string signature = Request.Headers["X-Webhook-Signature"];
            
            // Read raw body as bytes
            using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true))
            {
                Request.Body.Position = 0;
                byte[] rawBody = await new StreamReader(Request.Body).BaseStream.ReadAllBytesAsync();
                
                if (!WebhookSignatureVerifier.VerifyWebhookSignature(_webhookSecret, rawBody, signature))
                {
                    return Unauthorized(new { error = "Invalid signature" });
                }
                
                // Parse JSON after verification
                string bodyString = Encoding.UTF8.GetString(rawBody);
                var payload = JsonSerializer.Deserialize<WebhookPayload>(bodyString);
                
                // Verify timestamp (see below)
                // Process webhook...
                
                return Ok();
            }
        }
    }
    ```

    ```php PHP theme={null}
    <?php

    function verifyWebhookSignature($secret, $rawBody, $signatureHeader) {
        // Parse signature from header
        if (empty($signatureHeader) || strpos($signatureHeader, 'sha256=') !== 0) {
            return false;
        }
        
        $receivedSignature = substr($signatureHeader, 7); // Remove 'sha256=' prefix
        
        // Compute expected signature
        $expectedSignature = hash_hmac('sha256', $rawBody, $secret);
        
        // Constant-time comparison to prevent timing attacks
        return hash_equals($receivedSignature, $expectedSignature);
    }

    // Plain PHP Example
    <?php

    $webhookSecret = getenv('WEBHOOK_SECRET');

    // Get signature from header
    $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

    // Read raw POST body
    $rawBody = file_get_contents('php://input');

    if (!verifyWebhookSignature($webhookSecret, $rawBody, $signature)) {
        http_response_code(401);
        echo json_encode(['error' => 'Invalid signature']);
        exit;
    }

    // Parse JSON after verification
    $payload = json_decode($rawBody, true);

    // Verify timestamp (see below)
    // Process webhook...

    http_response_code(200);
    echo json_encode(['status' => 'OK']);
    ?>

    // Laravel Example
    Route::post('/webhooks/push', function (Request $request) {
        $signature = $request->header('X-Webhook-Signature');
        $rawBody = $request->getContent(); // Get raw request body
        
        if (!verifyWebhookSignature(env('WEBHOOK_SECRET'), $rawBody, $signature)) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }
        
        $payload = json_decode($rawBody, true);
        
        // Verify timestamp (see below)
        // Process webhook...
        
        return response()->json(['status' => 'OK'], 200);
    });

    // Symfony Example
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;

    class WebhookController extends AbstractController
    {
        #[Route('/webhooks/push', methods: ['POST'])]
        public function handleWebhook(Request $request): Response
        {
            $signature = $request->headers->get('X-Webhook-Signature');
            $rawBody = $request->getContent();
            
            if (!$this->verifyWebhookSignature($_ENV['WEBHOOK_SECRET'], $rawBody, $signature)) {
                return new Response(
                    json_encode(['error' => 'Invalid signature']),
                    Response::HTTP_UNAUTHORIZED,
                    ['Content-Type' => 'application/json']
                );
            }
            
            $payload = json_decode($rawBody, true);
            
            // Verify timestamp (see below)
            // Process webhook...
            
            return new Response(
                json_encode(['status' => 'OK']),
                Response::HTTP_OK,
                ['Content-Type' => 'application/json']
            );
        }
    }
    ```

    ```ruby Ruby on Rails theme={null}
    require 'openssl'

    def verify_webhook_signature(secret, raw_body, signature_header)
      # Parse signature from header
      return false if signature_header.nil? || !signature_header.start_with?('sha256=')
      
      received_signature = signature_header[7..-1] # Remove 'sha256=' prefix
      
      # Compute expected signature
      expected_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
      
      # Constant-time comparison to prevent timing attacks
      ActiveSupport::SecurityUtils.secure_compare(received_signature, expected_signature)
    end

    # Rails Controller Example
    class WebhooksController < ApplicationController
      # Disable CSRF protection for webhook endpoint
      skip_before_action :verify_authenticity_token
      
      def push
        signature = request.headers['X-Webhook-Signature']
        raw_body = request.raw_post # Get raw request body
        
        unless verify_webhook_signature(WEBHOOK_SECRET, raw_body, signature)
          render json: { error: 'Invalid signature' }, status: :unauthorized
          return
        end
        
        payload = JSON.parse(raw_body)
        
        # Verify timestamp (see below)
        # Process webhook...
        
        head :ok
      end
    end
    ```
  </CodeGroup>

  ### Timestamp Verification

  The `timestamp` field in the webhook payload indicates when the webhook was created. To prevent replay attacks, verify that the timestamp is recent (within 10 minutes).

  **Verification Steps**

  1. Parse the `timestamp` field from the payload (`ISO 8601`format)
  2. Compare with current time
  3. Reject requests older than 10 minutes (return`401 Unauthorized`)

  <CodeGroup>
    ```javascript JavaScript (Node.js) theme={null}
    function verifyWebhookTimestamp(timestamp, maxAgeMinutes = 10) {
     const webhookTime = new Date(timestamp);
     const now = new Date();
     const ageMinutes = (now - webhookTime) / 1000 / 60;


     return ageMinutes <= maxAgeMinutes;
    }


    // In your webhook handler
    if (!verifyWebhookTimestamp(payload.timestamp)) {
     return res.status(401).send('Webhook timestamp too old');
    }
    ```

    ```python Python theme={null}
    from datetime import datetime, timezone, timedelta


    def verify_webhook_timestamp(timestamp_str: str, max_age_minutes: int = 10) -> bool:
       """Verify webhook timestamp is within acceptable age."""
       webhook_time = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
       now = datetime.now(timezone.utc)
       age = now - webhook_time
      
       return age <= timedelta(minutes=max_age_minutes)


    # In your webhook handler
    if not verify_webhook_timestamp(payload['timestamp']):
       return 'Webhook timestamp too old', 401
    ```

    ```go Go theme={null}
    import "time"


    func verifyWebhookTimestamp(timestamp string, maxAgeMinutes int) bool {
       webhookTime, err := time.Parse(time.RFC3339, timestamp)
       if err != nil {
           return false
       }
      
       age := time.Since(webhookTime)
       return age <= time.Duration(maxAgeMinutes) * time.Minute
    }


    // In your webhook handler
    if !verifyWebhookTimestamp(payload.Timestamp, 10) {
       http.Error(w, "Webhook timestamp too old", http.StatusUnauthorized)
       return
    }
    ```

    ```csharp C# (.NET) theme={null}
    using System;

    public class WebhookTimestampVerifier
    {
        public static bool VerifyWebhookTimestamp(string timestampStr, int maxAgeMinutes = 10)
        {
            try
            {
                // Parse ISO 8601 timestamp
                DateTime webhookTime = DateTime.Parse(timestampStr, null, 
                    System.Globalization.DateTimeStyles.RoundtripKind);
                
                // Ensure UTC
                if (webhookTime.Kind != DateTimeKind.Utc)
                {
                    webhookTime = webhookTime.ToUniversalTime();
                }
                
                DateTime now = DateTime.UtcNow;
                TimeSpan age = now - webhookTime;
                
                // Check if timestamp is in the future (clock skew protection)
                if (age.TotalMinutes < 0)
                {
                    return false;
                }
                
                return age.TotalMinutes <= maxAgeMinutes;
            }
            catch (FormatException)
            {
                // Invalid timestamp format
                return false;
            }
        }
    }
                
    // In your webhook handler
    if (!WebhookTimestampVerifier.VerifyWebhookTimestamp(payload.Timestamp))
    {
        return Unauthorized(new { error = "Webhook timestamp too old" });
    }
    ```

    ```php PHP theme={null}
    <?php

    function verifyWebhookTimestamp($timestampStr, $maxAgeMinutes = 10) {
        try {
            // Parse ISO 8601 timestamp
            $webhookTime = new DateTime($timestampStr, new DateTimeZone('UTC'));
            $now = new DateTime('now', new DateTimeZone('UTC'));
            
            // Calculate age in minutes
            $interval = $now->diff($webhookTime);
            $ageMinutes = ($interval->days * 24 * 60) + 
                          ($interval->h * 60) + 
                          $interval->i + 
                          ($interval->s / 60);
            
            // Check if webhook is from the future (clock skew protection)
            if ($interval->invert === 0) {
                return false;
            }
            
            return $ageMinutes <= $maxAgeMinutes;
        } catch (Exception $e) {
            // Invalid timestamp format
            return false;
        }
    }

    // In your webhook handler
    if (!verifyWebhookTimestamp($payload['timestamp'])) {
        http_response_code(401);
        echo json_encode(['error' => 'Webhook timestamp too old']);
        exit;
    }

    // In your webhook handler (Laravel)
    if (!verifyWebhookTimestamp($payload['timestamp'])) {
        return response()->json(['error' => 'Webhook timestamp too old'], 401);
    }

    // In your webhook handler (Symfony)
    if (!$this->verifyWebhookTimestamp($payload['timestamp'])) {
      return new JsonResponse(
          ['error' => 'Webhook timestamp too old'],
          Response::HTTP_UNAUTHORIZED
      );
    }
    ```

    ```ruby Ruby on Rails theme={null}
    require 'time'

    def verify_webhook_timestamp(timestamp_str, max_age_minutes = 10)
      # Parse ISO 8601 timestamp
      webhook_time = Time.parse(timestamp_str).utc
      now = Time.now.utc
      age_minutes = (now - webhook_time) / 60.0
      
      age_minutes <= max_age_minutes
    rescue ArgumentError
      # Invalid timestamp format
      false
    end
        
    # In your webhook handler
    unless verify_webhook_timestamp(payload['timestamp'])
      render json: { error: 'Webhook timestamp too old' }, status: :unauthorized
      return
    end
    ```
  </CodeGroup>
</Accordion>

<RequestExample>
  ```bash Authorize Payment with Webhook Configuration theme={null}
  curl --request POST \
    --url https://sandbox.pushcash.com/authorize \
    --header 'Authorization: Bearer <token>' \
    --header 'Content-Type: application/json' \
    --data '
  {
    "user_id": "user_lVpbPL0K1XIiHx0DxipRbD",
    "amount": 2500,
    "currency": "USD",
    "direction": "cash_in",
    "token": "token_mbDRHFi3dxIZEtykHsgUGC",
    "webhook_url": "https://yourapp.com/webhooks/push",
    "webhook_secret": "whsec_32_characters_minimum",
    "tag": "txn_12345"
  }
  '
  ```
</RequestExample>

## Independent Ordering

Webhook delivery and authorization responses are independent and may arrive in any order. Your system must handle both scenarios by ensuring that the internal transaction record is committed to the database *before* the call to the authorization endpoint:

1. Webhook arrives first (before authorization response)
2. Authorization response arrives first (before webhook)

<Accordion title="Sequence diagram illustration">
  *Sequence diagram illustrates the two possible scenarios of ordering between webhook delivery and authorization results.*

  <img src="https://mintcdn.com/pushcash/iVZ4U0UVTyaZkRGZ/webhook-two-options.png?fit=max&auto=format&n=iVZ4U0UVTyaZkRGZ&q=85&s=fbba730c83e837f33b7715244c7aee16" alt="Sequence diagram illustrates the two possible scenarios of ordering between webhook delivery and authorization results." width="1222" height="1164" data-path="webhook-two-options.png" />
</Accordion>

## Idempotency

Push provides at-least-once delivery for webhooks. Your application must handle duplicate webhook deliveries gracefully using database transactions to ensure idempotency. In the case of a duplicated webhook delivery from Push either due to an error or timeout from your callback handler, you should discard the request and return a`200 OK`.

If Push does not receive a`200 OK`response from your webhook endpoint, delivery will be retried with exponential backoff.

| Environment    | Retry Behavior                                        |
| :------------- | :---------------------------------------------------- |
| **Sandbox**    | Up to 3 attempts                                      |
| **Production** | Up to 40 attempts over 3 days, then marked as expired |

## Integration checklist

* Payment requests include the `webhook_url`, `webhook_secret`, and `tag` parameters
* Webhook endpoint is reachable over HTTPS from the Push IP addresses
* Webhook signatures and timestamps are verified before processing
* Duplicate webhook deliveries are handled idempotently
