4 5
Keys SDK Call Hooks Live
Step 4 of 5 ยท ~10 min

Handle Webhooks

Polling the API for transfer status is inefficient and unreliable. Webhooks push status changes to your endpoint the moment they happen. This step covers endpoint setup, signature verification, and idempotency.

Why webhooks, not polling?
ACH transfers settle T+1. Polling every minute for 24+ hours to detect a status change wastes API quota and adds latency. With webhooks, you get notified within seconds of settlement, return, or failure.
1
Register your webhook endpoint
  1. In the dashboard, go to Settings โ†’ Webhooks โ†’ Add Endpoint
  2. Enter your endpoint URL (e.g., https://yourapp.com/webhooks/payments)
  3. Select the events to subscribe to: transfer.completed, transfer.returned, transfer.failed
  4. Copy the generated webhook secret (whsec_) โ€” add it to your .env
Local development
Use ngrok http 3000 (or cloudflared tunnel) to expose your local server. Paste the ngrok HTTPS URL as your endpoint in the dashboard for sandbox testing.
2
Implement the webhook endpoint

Your endpoint must: (1) read the raw request body, (2) verify the HMAC-SHA256 signature before parsing, and (3) return HTTP 200 immediately. Heavy processing should happen asynchronously after the 200 response.

const express = require('express');
const crypto = require('crypto');
const app = express();

// IMPORTANT: parse as raw bytes โ€” must come before json() middleware
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-payments-signature'];
  const secret = process.env.PAYMENTS_WEBHOOK_SECRET;

  // Verify HMAC-SHA256 signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expected, 'utf8')
  );

  if (!isValid) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString());

  // Respond 200 immediately, process asynchronously
  res.status(200).end();

  switch (event.type) {
    case 'transfer.completed':
      handleTransferCompleted(event.data);
      break;
    case 'transfer.returned':
      handleTransferReturned(event.data);
      break;
    case 'transfer.failed':
      handleTransferFailed(event.data);
      break;
  }
});
import hmac, hashlib, os
from flask import Flask, request, abort
import json

app = Flask(__name__)

@app.route("/webhooks/payments", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Payments-Signature", "")
    secret = os.environ["PAYMENTS_WEBHOOK_SECRET"].encode()
    body = request.get_data()  # raw bytes โ€” critical

    expected = hmac.new(secret, body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = json.loads(body)

    # Return 200 immediately
    if event["type"] == "transfer.completed":
        handle_completed(event["data"])
    elif event["type"] == "transfer.returned":
        handle_returned(event["data"])

    return "", 200
Raw body is required for signature verification
If you parse the body with express.json() before the signature check, JSON serialization differences will cause every signature to fail. Always read raw bytes first, verify signature, then parse JSON.
3
Handle duplicate event delivery with idempotency

The Payments API retries unacknowledged webhooks up to 5 times over 24 hours. Your handler must be idempotent โ€” processing the same event twice should not double-credit an account or trigger two emails.

// Use Redis or your database to track processed event IDs
const processedEvents = new Set(); // dev only โ€” use Redis in prod

async function handleEvent(event) {
  if (processedEvents.has(event.id)) {
    console.log(`Duplicate event ${event.id} โ€” skipping`);
    return;
  }
  processedEvents.add(event.id);

  // Now safely process the event
  await process(event);
}
# Use Redis or your database โ€” set() a key with TTL for 48h window
processed_events = set()  # dev only

def handle_event(event):
    if event["id"] in processed_events:
        print(f"Duplicate event {event['id']} โ€” skipping")
        return
    processed_events.add(event["id"])

    # Now safely process the event
    process(event)
Step 4 Checklist
โ† Step 3