RDC Scanning Webhooks

GoodFunds Gateway uses batched webhooks to notify your system about Remote Deposit Capture (RDC) check processing events. Webhooks are sent in batches to improve efficiency and reduce HTTP overhead. Check images are delivered via secure, time-limited URLs rather than base64-encoded data.

⚠️ Important: Webhooks are the primary method for receiving processed check data. Your endpoint must be publicly accessible and respond with a 2xx status code to acknowledge receipt.

Overview

The RDC Scanning system automatically processes check images through Azure Document Intelligence, extracts all relevant data, and delivers it to your webhook endpoint in batches. Each batch contains multiple transactions with pagination metadata. Check images are stored securely on our servers and accessed via signed, expiring URLs.

WEBHOOK POSTYour configured endpoint

Authentication

All webhooks are signed using HMAC-SHA256 to verify they originated from GoodFunds Gateway. Each request includes the following headers:

Header Description
X-Webhook-Signature HMAC-SHA256 signature of the raw request body using your webhook token
X-Webhook-Timestamp UNIX timestamp when the webhook was sent (use to prevent replay attacks)
X-Client-ID Your unique client identifier
X-Webhook-Version Webhook version (currently "1.0")

Verifying Webhook Signatures

To verify a webhook's authenticity:

  1. Get the raw request body (do not modify or pretty-print it)
  2. Retrieve your client's secret webhook token from your dashboard
  3. Generate an HMAC-SHA256 hash of the request body using your token
  4. Compare your generated signature with the X-Webhook-Signature header using a constant-time comparison function
  5. Check that the timestamp is within 5 minutes of your server's current time to prevent replay attacks

Webhook Payload Format

Webhooks are sent in batches with pagination. Each transaction includes BOTH a base64-encoded image (for immediate display) AND a secure URL (for on-demand retrieval).

{
            "meta": {
                "batch_id": "20260610-01",
                "page": 1,
                "total_pages": 5,
                "total_count": 50,
                "per_page": 10,
                "image_url_expiry_hours": 24
            },
            "transactions": [
                {
                "batch_id": "20260610-01",
                "image_id": "33295785",
                "internal_id": "9287",
                "image": "base64_encoded_image_data...",
                "image_url": "https://api.goodfundsgateway.com/secure_image/view/33295785/1138/1769654400/abc123...",
                "image_expires": "2026-06-11T15:30:00+00:00",
                "check_date": "2026-06-08",
                "check_number": "1491",
                "bank_name": "Wells Fargo",
                "payee": "Example Payee",
                "payor": "Example Payor",
                "amount": "500.00"
                }
            ]
            }
Image Handling Options:
  • Base64 Image (image field) - Use for immediate display, no additional API call needed
  • Secure URL (image_url field) - Use for on-demand retrieval, valid for 24 hours
  • Recommended approach - Use base64 for immediate display, then transition to URL-based retrieval for subsequent views

Webhook Authentication (Dual-Token Support)

We support dual-token authentication for zero-downtime token rotation. During rotation, both old and new tokens are accepted.

Client Receiver Implementation with Dual-Token Support

<?php
            function verify_webhook_signature($payload, $signature, $client_id) {
                // Get both primary and secondary tokens from your storage
                $primary_token = get_webhook_token($client_id);
                $secondary_token = get_webhook_token_secondary($client_id);
                
                // Check primary token
                $expected = hash_hmac('sha256', $payload, $primary_token);
                if (hash_equals($expected, $signature)) {
                    return true;
                }
                
                // During rotation, check secondary token
                if ($secondary_token) {
                    $expected_secondary = hash_hmac('sha256', $payload, $secondary_token);
                    if (hash_equals($expected_secondary, $signature)) {
                        log_message('info', "Secondary token used for client {$client_id}");
                        return true;
                    }
                }
                
                return false;
            }
            ?>

Meta Fields

Field Type Description
batch_id string Unique batch identifier in format YYYYMMDD-XX (auto-increments daily)
page integer Current page number (1-indexed)
total_pages integer Total number of pages available
total_count integer Total number of transactions across all pages
per_page integer Number of transactions per page (configurable, default: 10)
image_url_expiry_hours integer Number of hours until the image URL expires (default: 24)

Transaction Fields

Field Type Description
batch_id string Batch identifier for this transaction (same as meta.batch_id)
image_id string Unique identifier for the check image
internal_id string The store ID from your RDC gateway account. Use this field to route the transaction to the correct store/location in your system.
image_url string Secure, signed URL to download the check image (expires after 24 hours)
image_expires string ISO 8601 timestamp when the image URL expires
check_date date Date written on the check (YYYY-MM-DD format)
check_number string Check number as printed on the check
bank_name string Name of the financial institution
payee string Payee name (who the check is written to)
payor string Payor name (who wrote the check)
payor_street_address string Payor's street address (including apartment/unit if present)
payor_city string Payor's city
payor_state string Payor's state (2-letter code)
payor_zip string Payor's ZIP code (first 5 digits only, +4 suffix removed)
payor_country string Payor's country (defaults to "US")
routing string Bank routing number (9 digits)
account string Bank account number (masked, showing only last 4 digits)
amount string Check amount (formatted as decimal with 2 decimal places)
memo string Memo line text from the check
confidence_score string Azure Document Intelligence confidence score (0-100). Higher values indicate better extraction accuracy.
deposit_date string Date the check was deposited/processed (MM-DD-YYYY format)
🔑 Understanding internal_id:
  • This is the store_id configured in your RDC gateway account
  • Each RDC gateway account you have will have a unique internal_id
  • Use this field to identify which store/location the check belongs to

Secure Image Access

Check images are delivered via secure, signed URLs that provide:

📸 Downloading the Image:

The image_url field contains a complete URL. Simply make a GET request to this URL to download the check image. The URL includes an authentication signature and expiration timestamp.

// PHP Example
$image_data = file_get_contents($transaction['image_url']);
file_put_contents('check_image.tiff', $image_data);
⚠️ Important Security Notes:
  • Image URLs expire after 24 hours. Always download and store images promptly.
  • Do not share or expose image URLs publicly.
  • If an image URL expires, contact support to request a new one.

Image Handling

Beyond the check image itself, each transaction includes text fields (payee, payor, memo) extracted from the handwritten check via OCR. A few things worth knowing about how that text is produced:

📝 Handwriting Term Correction:

Handwritten checks made out to or from Jewish organizations and individuals often include Hebrew/Yiddish-derived words and names (e.g. "Congregation," "Yahrzeit," "Rosenberg") that a general-purpose handwriting OCR model can misread, since these terms fall outside its everyday-English language model.

To reduce this, payee, payor, and memo are automatically passed through a dictionary-based correction step after OCR extraction and before the record is saved — so the corrected text is what you receive in the webhook. This runs as an in-memory dictionary lookup with no external calls, adding a negligible amount of time (well under a millisecond per field) to processing. It does not delay batch or webhook delivery.

This correction is conservative by design: it only fires on high-confidence dictionary matches or very close spelling variants, specifically to avoid altering names or text that aren't in its dictionary. If you ever notice a payee/payor/memo value that looks incorrectly "corrected," let support know — the dictionary is tunable on our end.

Batch Processing

Webhooks are sent in batches to improve efficiency. Each batch contains up to per_page transactions (configurable, default 10). Process each page sequentially using the pagination metadata:

// Process current page
$transactions = $payload['transactions'];
foreach ($transactions as $transaction) {
    // Download the image using the secure URL
    $image_data = file_get_contents($transaction['image_url']);
    
    // Process each transaction
}

// Check if there are more pages
if ($payload['meta']['page'] < $payload['meta']['total_pages']) {
    // Wait for next webhook (will be sent automatically)
}

Responding to Webhooks

Your endpoint should respond with a 2xx HTTP status code (e.g., 200 OK) to acknowledge successful receipt. Any other status code (4xx or 5xx) will be considered a failure and trigger a retry.

We recommend responding immediately with a 200 status before processing the data asynchronously to avoid timeouts:

// Accept immediately
http_response_code(200);
echo json_encode(['success' => true]);

// Then process in background (e.g., queue job, background process)
// ... your processing logic ...

Retry Logic

GoodFunds Gateway implements a robust retry system to ensure reliable delivery:

Attempt Delay
First failure Immediate retry after 1 minute
Second failure Retry after 5 minutes
Third failure Retry after 15 minutes

PHP Example

<?php
// Get the raw request body
$payload = file_get_contents('php://input');

// Get headers
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$client_id = $_SERVER['HTTP_X_CLIENT_ID'] ?? '';

// 1. Check timestamp (prevent replay attacks - 5 minute window)
if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(401);
    die(json_encode(['error' => 'Invalid timestamp']));
}

// 2. Get your webhook token from secure storage
$token = 'your-webhook-token-here';

// 3. Generate expected signature
$expected = hash_hmac('sha256', $payload, $token);

// 4. Compare signatures securely
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    die(json_encode(['error' => 'Invalid signature']));
}

// 5. Parse the batched payload
$data = json_decode($payload, true);
$meta = $data['meta'];
$transactions = $data['transactions'];

foreach ($transactions as $transaction) {
    // Download the image using the secure URL
    $image_data = file_get_contents($transaction['image_url']);
    $filename = "/path/to/images/{$transaction['image_id']}.tiff";
    file_put_contents($filename, $image_data);
    
    // Process the check data
    // ... your business logic ...
}

// 6. Acknowledge receipt
http_response_code(200);
echo json_encode(['success' => true]);
?>

Node.js Example

const crypto = require('crypto');
const fs = require('fs');
const https = require('https');
const express = require('express');

const app = express();

// Middleware to get raw body for signature verification
app.use(express.json({
    verify: (req, res, buf) => {
        req.rawBody = buf.toString();
    }
}));

// Function to download image from URL
function downloadImage(url, filepath) {
    return new Promise((resolve, reject) => {
        const file = fs.createWriteStream(filepath);
        https.get(url, (response) => {
            response.pipe(file);
            file.on('finish', () => {
                file.close();
                resolve();
            });
        }).on('error', reject);
    });
}

app.post('/webhook-endpoint', async (req, res) => {
    const payload = req.rawBody;
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const clientId = req.headers['x-client-id'];
    
    // 1. Check timestamp (prevent replay attacks - 5 minute window)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp)) > 300) {
        return res.status(401).json({ error: 'Invalid timestamp' });
    }
    
    // 2. Get your webhook token from environment variable
    const token = process.env.WEBHOOK_TOKEN;
    
    // 3. Generate expected signature
    const expected = crypto
        .createHmac('sha256', token)
        .update(payload)
        .digest('hex');
    
    // 4. Compare signatures
    if (expected !== signature) {
        return res.status(401).json({ error: 'Invalid signature' });
    }
    
    // 5. Process the batch
    const data = req.body;
    const meta = data.meta;
    const transactions = data.transactions;
    
    for (const transaction of transactions) {
        // Download the image using the secure URL
        const filename = `/path/to/images/${transaction.image_id}.tiff`;
        await downloadImage(transaction.image_url, filename);
        
        // Process the check data
        // ... your business logic ...
    }
    
    // 6. Acknowledge receipt
    res.status(200).json({ 
        success: true, 
        processed: transactions.length,
        batch_id: meta.batch_id 
    });
});

app.listen(3000, () => {
    console.log('Webhook receiver listening on port 3000');
});

Python (Flask) Example

import hmac
import hashlib
import base64
import time
import os
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)

# Get webhook token from environment variable
WEBHOOK_TOKEN = os.environ.get('WEBHOOK_TOKEN', '')

@app.route('/webhook-endpoint', methods=['POST'])
def webhook_receiver():
    # Get request data
    payload = request.get_data(as_text=True)
    signature = request.headers.get('X-Webhook-Signature', '')
    timestamp = request.headers.get('X-Webhook-Timestamp', '')
    client_id = request.headers.get('X-Client-ID', '')
    
    # 1. Check timestamp (prevent replay attacks - 5 minute window)
    if abs(time.time() - int(timestamp)) > 300:
        return jsonify({'error': 'Invalid timestamp'}), 401
    
    # 2. Generate expected signature
    expected = hmac.new(
        WEBHOOK_TOKEN.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    # 3. Compare signatures
    if not hmac.compare_digest(expected, signature):
        return jsonify({'error': 'Invalid signature'}), 401
    
    # 4. Process the batch
    data = request.json
    meta = data['meta']
    transactions = data['transactions']
    
    for transaction in transactions:
        # Download the image using the secure URL
        response = requests.get(transaction['image_url'])
        filename = f"/path/to/images/{transaction['image_id']}.tiff"
        with open(filename, 'wb') as f:
            f.write(response.content)
        
        # Process the check data
        # ... your business logic ...
    
    # 5. Acknowledge receipt
    return jsonify({
        'success': True,
        'processed': len(transactions),
        'batch_id': meta['batch_id']
    }), 200

if __name__ == '__main__':
    app.run(port=3000)

Ruby (Sinatra) Example

require 'sinatra'
require 'json'
require 'openssl'
require 'base64'
require 'net/http'

# Get webhook token from environment variable
WEBHOOK_TOKEN = ENV['WEBHOOK_TOKEN']

post '/webhook-endpoint' do
  # Get raw payload and headers
  payload = request.body.read
  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']
  timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP']
  client_id = request.env['HTTP_X_CLIENT_ID']
  
  # 1. Check timestamp (prevent replay attacks - 5 minute window)
  current_time = Time.now.to_i
  if (current_time - timestamp.to_i).abs > 300
    halt 401, { error: 'Invalid timestamp' }.to_json
  end
  
  # 2. Generate expected signature
  expected = OpenSSL::HMAC.hexdigest('sha256', WEBHOOK_TOKEN, payload)
  
  # 3. Compare signatures
  unless expected == signature
    halt 401, { error: 'Invalid signature' }.to_json
  end
  
  # 4. Process the batch
  data = JSON.parse(payload)
  meta = data['meta']
  transactions = data['transactions']
  
  transactions.each do |transaction|
    # Download the image using the secure URL
    uri = URI(transaction['image_url'])
    image_data = Net::HTTP.get(uri)
    filename = "/path/to/images/#{transaction['image_id']}.tiff"
    File.write(filename, image_data)
    
    # Process the check data
    # ... your business logic ...
  end
  
  # 5. Acknowledge receipt
  content_type :json
  status 200
  {
    success: true,
    processed: transactions.length,
    batch_id: meta['batch_id']
  }.to_json
end

Testing Your Webhook

Use our test page to validate your webhook endpoint:

Go to Webhook Test Page

The test page allows you to:

Troubleshooting Common Issues

Issue Solution
Signature verification fails Ensure you're using the raw request body (not pretty-printed JSON). Check that your token is correct.
Webhook not receiving requests Verify your endpoint is publicly accessible and firewall isn't blocking requests.
Image URL returns 401 The URL has expired (24 hour window). Contact support for a new URL if needed.
Image URL returns 403 Your client ID doesn't match the image owner. Verify you're using the correct client ID.

Frequently Asked Questions

How long are image URLs valid?

Image URLs are valid for 24 hours from the time the webhook is sent. We recommend downloading and storing images immediately upon receipt.

Can I request a longer expiration time?

The default expiration is 24 hours. If you need longer access, please contact support to discuss your requirements.

What image format is used?

Images are typically in TIFF format. Your image processing library should support TIFF files.

How are account numbers masked?

Account numbers show only the last 4 digits (e.g., "******1234"). The full number is stored securely but never exposed in webhooks.

Can I change the batch size?

Yes, batch size is configurable per client. Contact support to adjust the default of 10 transactions per batch.

Support

Need help with webhook integration? Contact our support team: