Skip to main content

Overview

Webhooks deliver asynchronous notifications about the final result of a payment, so your internal transaction state stays accurate even if the authorization request times out or returns an ambiguous response. When an intent reaches a terminal state, Push sends a signed event — intent.approved or intent.declined — to an HTTPS endpoint you control. Events are delivered at-least-once and may arrive in any order relative to the authorization response, so your endpoint must verify each event, process it idempotently, and tolerate out-of-order delivery.

Integration overview

The steps below show an overview of how to receive and process webhooks.
  1. Configure webhook delivery. When calling authorize-payment, provide:
    • webhook_url — where Push delivers webhook events.
    • webhook_secret — used to sign each event so you can verify its authenticity (32 characters minimum).
    • tag — maps each event back to your internal transaction record.
  2. Expose an HTTPS endpoint. Create an endpoint at your webhook_url that accepts POST requests and is reachable over HTTPS from the Push IP addresses.
  3. Verify each event. Verify the request signature and timestamp before processing, and reject invalid requests with 401 Unauthorized. See Security.
  4. Update your record and acknowledge. Parse the payload (see Webhook types), update the matching transaction record (keyed on the tag or intent id), and return 200 OK. Your integration must tolerate out-of-order and duplicate deliveries — see Independent ordering and Idempotency.
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"
}
'
{
 "type": "intent.approved",
 "data": {
   "id": "intent_sandbox_mbDRHFi3dxIZEtykHsgUGC",
   "tag": "txn_12345",
   "direction": "cash_in",
   "amount": 5000,
   "status": "approved",
   "rail": "ach",
   "currency": "USD",
   "type": "secure_debit",
   "limits_utilization": {
     "daily_cash_in": "5000.00",
     "daily_cash_out": "0.00",
     "monthly_cash_in": "5000.00",
     "monthly_cash_out": "0.00"
   }
 },
 "timestamp": "2024-01-14T22:31:02.756096Z"
}
{
 "type": "intent.declined",
 "data": {
   "id": "intent_sandbox_mbDRHFi3dxIZEtykHsgUGC",
   "tag": "txn_12345",
   "direction": "cash_in",
   "amount": 5000,
   "status": "declined",
   "decline_category": "insufficient_funds",
   "currency": "USD",
   "type": "card_only_debit"
 },
 "timestamp": "2024-01-14T22:31:02.756096Z"
}

Webhook types

Push sends the following events. Each event has a type, a data object with the fields below, and a timestamp (ISO 8601).

intent.approved

Triggered when an intent is successfully approved.
FieldTypeDescription
idstringUnique identifier for the intent
tagstringYour internal transaction identifier (if provided)
directionstringEither cash_in or cash_out
amountintegerAmount in cents (e.g. 5000 = $50.00)
statusstringIntent status (approved or pending)
railstringPayment rail used (ach or card)
currencystringCurrency code (USD)
typestringPayment type (secure_debit, card_only_credit, or card_only_debit)
limits_utilizationobjectObject containing limit utilization details
limits_utilization.daily_cash_instringDaily cash-in limit usage
limits_utilization.daily_cash_outstringDaily cash-out limit usage
limits_utilization.monthly_cash_instringMonthly cash-in limit usage
limits_utilization.monthly_cash_outstringMonthly cash-out limit usage

intent.declined

Triggered when an intent is declined.
FieldTypeDescription
idstringUnique identifier for the intent
tagstringYour internal transaction identifier (if provided)
directionstringEither cash_in or cash_out
amountintegerAmount in cents (e.g. 5000 = $50.00)
statusstringIntent status (declined)
decline_categorystringReason for decline – see the full list of decline categories
currencystringCurrency code (USD)
typestringPayment type (secure_debit, card_only_credit, or card_only_debit)

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)
Sequence diagram illustrates the two possible scenarios of ordering between webhook delivery and authorization results.Sequence diagram illustrates the two possible scenarios of ordering between webhook delivery and authorization results.

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.
EnvironmentRetry Behavior
SandboxUp to 3 attempts
ProductionUp to 40 attempts over 3 days, then marked as expired

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.
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.
Production 44.238.180.175Sandbox 34.209.246.44

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)
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');
});
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
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)
}
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

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']
        );
    }
}
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

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 8601format)
  2. Compare with current time
  3. Reject requests older than 10 minutes (return401 Unauthorized)
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');
}
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
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
}
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

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
  );
}
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