Webhooks
Receive real-time notifications as ACH payments move through the pipeline
Payload Structure
Every webhook POST body follows this structure:
{
#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..."
}| Field | Type | Description |
|---|---|---|
| id | string | Unique event ID (prefix: evt_). Use for idempotency/deduplication. |
| event | string | The event name (e.g., roundup.completed, roundup.failed) |
| data | object | Event-specific payload containing round-up details |
| timestamp | string | ISO 8601 timestamp of when the event occurred |
| signature | string | HMAC-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.
{
#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.
{
#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.
{
#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.
{
#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.
{
#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:
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');
}Security
Your webhook secret (whsec_) is separate from your API key. Find it in the Hedge Payments dashboard under Settings → Webhooks. Always validate the HMAC-SHA256 signature to prevent spoofed webhooks.
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:
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...
}Important
Always use id (the event ID) for deduplication, not data.roundupId. A single round-up generates multiple events (initiated, batched, completed), each with a unique event ID.
ACH State Machine
Each round-up transaction follows this state machine. Terminal states are shown in red:
ACH Transaction State Machine
Insufficient funds, invalid account
ACH return code received
API response
roundup.initiated
roundup.ach_batched
roundup.completed
roundup.failed
roundup.returned
Node.js Webhook Handler
A production-ready webhook handler with HMAC-SHA256 validation, idempotency, and async processing:
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'));