Docs/SideBet/Webhooks

Webhooks

Receive real-time notifications as ACH payments move through the pipeline

Payload Structure

Every webhook POST body follows this structure:

Webhook Payloadjson
{
  #98C379]">"id": "evt_a1b2c3d4e5f6",
  #98C379]">"event": "roundup.completed",
  #98C379]">"data": {
    #98C379]">"roundupId": "roundup_9f8e7d6c",
    #98C379]">"consumerId": "cust_j4k5l6m7",
    #98C379]">"merchantId": "merch_xyz789",
    #98C379]">"betAmountCents": 2347,
    #98C379]">"roundUpCents": 53,
    #98C379]">"amount": 0.52,
    #98C379]">"fees": 0.01,
    #98C379]">"status": "completed"
  },
  #98C379]">"timestamp": "2026-03-24T18:30:00Z",
  #98C379]">"signature": "sha256=a1b2c3d4..."
}
FieldTypeDescription
idstringUnique event ID (prefix: evt_). Use for idempotency/deduplication.
eventstringThe event name (e.g., roundup.completed, roundup.failed)
dataobjectEvent-specific payload containing round-up details
timestampstringISO 8601 timestamp of when the event occurred
signaturestringHMAC-SHA256 signature for validation (also sent in X-Hedge-Signature header)

Event Types

roundup.initiated

Fired when the ACH debit is submitted to the consumer's bank. This is the first event after calling /api/roundup/initiate.

json
{
  #98C379]">"id": "evt_init_abc123",
  #98C379]">"event": "roundup.initiated",
  #98C379]">"data": {
    #98C379]">"roundupId": "roundup_9f8e7d6c",
    #98C379]">"consumerId": "cust_j4k5l6m7",
    #98C379]">"merchantId": "merch_xyz789",
    #98C379]">"betAmountCents": 2347,
    #98C379]">"roundUpCents": 53,
    #98C379]">"amount": 0.53,
    #98C379]">"fees": 0.00,
    #98C379]">"status": "initiated"
  },
  #98C379]">"timestamp": "2026-03-24T14:30:05Z",
  #98C379]">"signature": "sha256=..."
}

roundup.ach_batched

Fired when the payment is grouped into an ACH batch for processing by the Federal Reserve. Typically occurs within hours of initiation.

json
{
  #98C379]">"id": "evt_batch_def456",
  #98C379]">"event": "roundup.ach_batched",
  #98C379]">"data": {
    #98C379]">"roundupId": "roundup_9f8e7d6c",
    #98C379]">"consumerId": "cust_j4k5l6m7",
    #98C379]">"merchantId": "merch_xyz789",
    #98C379]">"betAmountCents": 2347,
    #98C379]">"roundUpCents": 53,
    #98C379]">"amount": 0.53,
    #98C379]">"fees": 0.00,
    #98C379]">"batchId": "batch_20260324_001",
    #98C379]">"status": "processing"
  },
  #98C379]">"timestamp": "2026-03-24T20:00:00Z",
  #98C379]">"signature": "sha256=..."
}

roundup.completed

Fired when funds have been received and settled to the merchant. This is the terminal success state.

json
{
  #98C379]">"id": "evt_comp_ghi789",
  #98C379]">"event": "roundup.completed",
  #98C379]">"data": {
    #98C379]">"roundupId": "roundup_9f8e7d6c",
    #98C379]">"consumerId": "cust_j4k5l6m7",
    #98C379]">"merchantId": "merch_xyz789",
    #98C379]">"betAmountCents": 2347,
    #98C379]">"roundUpCents": 53,
    #98C379]">"amount": 0.52,
    #98C379]">"fees": 0.01,
    #98C379]">"status": "completed"
  },
  #98C379]">"timestamp": "2026-03-27T09:15:00Z",
  #98C379]">"signature": "sha256=..."
}

roundup.failed

Fired when the payment could not be processed at all. Typically caused by invalid bank account details or system errors.

json
{
  #98C379]">"id": "evt_fail_jkl012",
  #98C379]">"event": "roundup.failed",
  #98C379]">"data": {
    #98C379]">"roundupId": "roundup_xyz789ghi",
    #98C379]">"consumerId": "cust_j4k5l6m7",
    #98C379]">"merchantId": "merch_xyz789",
    #98C379]">"betAmountCents": 2347,
    #98C379]">"roundUpCents": 53,
    #98C379]">"amount": 0.00,
    #98C379]">"fees": 0.00,
    #98C379]">"reason": "Invalid account number",
    #98C379]">"status": "failed"
  },
  #98C379]">"timestamp": "2026-03-24T14:31:00Z",
  #98C379]">"signature": "sha256=..."
}

roundup.returned

Fired when the consumer's bank returns the ACH debit. Common reasons: insufficient funds, account closed, or authorization revoked. Can occur up to 60 days after initiation.

json
{
  #98C379]">"id": "evt_ret_mno345",
  #98C379]">"event": "roundup.returned",
  #98C379]">"data": {
    #98C379]">"roundupId": "roundup_9f8e7d6c",
    #98C379]">"consumerId": "cust_j4k5l6m7",
    #98C379]">"merchantId": "merch_xyz789",
    #98C379]">"betAmountCents": 2347,
    #98C379]">"roundUpCents": 53,
    #98C379]">"amount": 0.00,
    #98C379]">"fees": 0.00,
    #98C379]">"returnCode": "R01",
    #98C379]">"reason": "Insufficient Funds",
    #98C379]">"status": "returned"
  },
  #98C379]">"timestamp": "2026-03-26T11:00:00Z",
  #98C379]">"signature": "sha256=..."
}

Signature Validation

Every webhook request includes an X-Hedge-Signature header containing an HMAC-SHA256 signature. Validate it using your webhook secret to ensure the request is authentic:

Signature Verificationjavascript
import crypto from class="text-[class="text-[#5C6370] italic">#98C379]">'crypto';

function verifyWebhookSignature(payload, signature, secret) {
  const expected = crypto
    .createHmac(class="text-[class="text-[#5C6370] italic">#98C379]">'sha256', secret)
    .update(JSON.stringify(payload))
    .digest(class="text-[class="text-[#5C6370] italic">#98C379]">'hex');

  const expectedSig = class="text-[class="text-[#5C6370] italic">#98C379]">`sha256=${expected}`;

  class="text-[#5C6370] italic">// Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSig)
  );
}

class="text-[#5C6370] italic">// Usage
const isValid = verifyWebhookSignature(
  req.body,
  req.headers[class="text-[class="text-[#5C6370] italic">#98C379]">'x-hedge-signature'],
  process.env.HEDGE_WEBHOOK_SECRET
);

if (!isValid) {
  return res.status(class="text-[#D19A66]">401).send(class="text-[class="text-[#5C6370] italic">#98C379]">'Invalid signature');
}

Retry Policy

SideBet retries failed webhook deliveries with exponential backoff:

3 retries with exponential backoff

Retry 1 after 1 minute, retry 2 after 10 minutes, retry 3 after 1 hour. After 3 failed attempts, the event is marked as undeliverable.

Success condition

Your endpoint must return HTTP 200 OK. Any other status code (including 3xx redirects) is treated as a failure.

Timeout

5-second timeout per delivery attempt. If your endpoint does not respond within 5 seconds, the attempt is marked as failed and the retry schedule begins.

Best practice

Respond 200 OK immediately, then process the event asynchronously. This avoids timeouts on slow database writes or downstream API calls.

Idempotency

Use the event id field as a deduplication key. Due to retries and at-least-once delivery, your endpoint may receive the same event more than once. Store processed event IDs and skip duplicates:

Idempotency via Event IDjavascript
class="text-[#5C6370] italic">// Idempotency via event ID
const processedEvents = new Set(); class="text-[#5C6370] italic">// Use Redis or DB in production

function handleWebhook(payload) {
  const eventId = payload.id; class="text-[#5C6370] italic">// e.g., class="text-[#98C379]">"evt_a1b2c3d4e5f6"

  if (processedEvents.has(eventId)) {
    console.log(class="text-[class="text-[#5C6370] italic">#98C379]">'Duplicate event, skipping:', eventId);
    return;
  }

  processedEvents.add(eventId);
  class="text-[#5C6370] italic">// Process the event...
}

ACH State Machine

Each round-up transaction follows this state machine. Terminal states are shown in red:

PENDING

API response

INITIATED

roundup.initiated

PROCESSING

roundup.ach_batched

COMPLETEDTerminal

roundup.completed

FAILEDTerminal

roundup.failed

RETURNEDTerminal

roundup.returned

Node.js Webhook Handler

A production-ready webhook handler with HMAC-SHA256 validation, idempotency, and async processing:

Node.js Webhook Handlerjavascript
import express from class="text-[class="text-[#5C6370] italic">#98C379]">'express';
import crypto from class="text-[class="text-[#5C6370] italic">#98C379]">'crypto';

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.HEDGE_WEBHOOK_SECRET; class="text-[#5C6370] italic">// whsec_...

class="text-[#5C6370] italic">// Use Redis or your database in production
const processedEvents = new Set();

function verifySignature(payload, signature, secret) {
  const expected = crypto
    .createHmac(class="text-[class="text-[#5C6370] italic">#98C379]">'sha256', secret)
    .update(JSON.stringify(payload))
    .digest(class="text-[class="text-[#5C6370] italic">#98C379]">'hex');

  const expectedSig = class="text-[class="text-[#5C6370] italic">#98C379]">`sha256=${expected}`;

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSig)
    );
  } catch {
    return false;
  }
}

app.post(class="text-[class="text-[#5C6370] italic">#98C379]">'/webhooks/sidebet', async (req, res) => {
  class="text-[#5C6370] italic">// class="text-[#D19A66]">1. Validate HMAC-SHA256 signature
  const signature = req.headers[class="text-[class="text-[#5C6370] italic">#98C379]">'x-hedge-signature'];
  if (!signature || !verifySignature(req.body, signature, WEBHOOK_SECRET)) {
    console.error(class="text-[class="text-[#5C6370] italic">#98C379]">'Invalid webhook signature');
    return res.status(class="text-[#D19A66]">401).send(class="text-[class="text-[#5C6370] italic">#98C379]">'Unauthorized');
  }

  class="text-[#5C6370] italic">// class="text-[#D19A66]">2. Respond class="text-[#D19A66]">200 immediately to avoid timeout
  res.status(class="text-[#D19A66]">200).send(class="text-[class="text-[#5C6370] italic">#98C379]">'OK');

  class="text-[#5C6370] italic">// class="text-[#D19A66]">3. Deduplicate via event ID
  const { id, event, data, timestamp } = req.body;

  if (processedEvents.has(id)) {
    console.log(class="text-[class="text-[#5C6370] italic">#98C379]">'Duplicate event, skipping:', id);
    return;
  }
  processedEvents.add(id);

  class="text-[#5C6370] italic">// class="text-[#D19A66]">4. Handle each event type
  try {
    switch (event) {
      case class="text-[class="text-[#5C6370] italic">#98C379]">'roundup.initiated':
        console.log(class="text-[class="text-[#5C6370] italic">#98C379]">`[${timestamp}] Round-up initiated: ${data.roundupId}`);
        await updateTransactionStatus(data.roundupId, class="text-[class="text-[#5C6370] italic">#98C379]">'initiated');
        break;

      case class="text-[class="text-[#5C6370] italic">#98C379]">'roundup.ach_batched':
        console.log(class="text-[class="text-[#5C6370] italic">#98C379]">`[${timestamp}] Round-up batched: ${data.roundupId}`);
        await updateTransactionStatus(data.roundupId, class="text-[class="text-[#5C6370] italic">#98C379]">'processing');
        break;

      case class="text-[class="text-[#5C6370] italic">#98C379]">'roundup.completed':
        console.log(class="text-[class="text-[#5C6370] italic">#98C379]">`[${timestamp}] Round-up completed: ${data.roundupId} — $${data.amount}`);
        await updateTransactionStatus(data.roundupId, class="text-[class="text-[#5C6370] italic">#98C379]">'completed');
        await creditConsumerBalance(data.consumerId, data.amount);
        break;

      case class="text-[class="text-[#5C6370] italic">#98C379]">'roundup.failed':
        console.log(class="text-[class="text-[#5C6370] italic">#98C379]">`[${timestamp}] Round-up failed: ${data.roundupId} — ${data.reason}`);
        await updateTransactionStatus(data.roundupId, class="text-[class="text-[#5C6370] italic">#98C379]">'failed');
        await notifyConsumer(data.consumerId, class="text-[class="text-[#5C6370] italic">#98C379]">'Your round-up could not be processed.');
        break;

      case class="text-[class="text-[#5C6370] italic">#98C379]">'roundup.returned':
        console.log(class="text-[class="text-[#5C6370] italic">#98C379]">`[${timestamp}] Round-up returned: ${data.roundupId} — ${data.returnCode}: ${data.reason}`);
        await updateTransactionStatus(data.roundupId, class="text-[class="text-[#5C6370] italic">#98C379]">'returned');
        await reverseConsumerCredit(data.consumerId, data.roundupId);
        await notifyConsumer(data.consumerId, class="text-[class="text-[#5C6370] italic">#98C379]">`Your round-up was returned: ${data.reason}`);
        break;

      default:
        console.log(class="text-[class="text-[#5C6370] italic">#98C379]">'Unknown event type:', event);
    }
  } catch (err) {
    console.error(class="text-[class="text-[#5C6370] italic">#98C379]">'Error processing webhook:', err);
    class="text-[#5C6370] italic">// The class="text-[#D19A66]">200 was already sent — log and handle via your error monitoring
  }
});

app.listen(class="text-[#D19A66]">3000, () => console.log(class="text-[class="text-[#5C6370] italic">#98C379]">'Webhook server running on port class="text-[#D19A66]">3000'));