Skip to main content

Webhooks

Webhooks allow you to receive real-time notifications when events occur in SavvyMoney. Instead of polling the API, you can subscribe to events and receive HTTP POST requests to your endpoint.

Overview​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SavvyMoney │────▢│ Your Webhook │────▢│ Your System β”‚
β”‚ Event β”‚ β”‚ Endpoint β”‚ β”‚ (Processing) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Supported Events​

EventDescription
user.enrolledUser successfully enrolled
user.verifiedUser passed identity verification
user.deletedUser account deleted
score.updatedCredit score has been updated
score.changedCredit score changed significantly
alert.createdNew credit alert generated
offer.clickedUser clicked on an offer
offer.convertedUser completed an offer application

Setting Up Webhooks​

Create Webhook Subscription​

POST /v1/webhooks

Request Body:

{
"url": "https://your-domain.com/webhooks/savvymoney",
"events": [
"score.updated",
"score.changed",
"alert.created"
],
"secret": "your_webhook_secret"
}
FieldTypeRequiredDescription
urlstringYesYour HTTPS endpoint URL
eventsarrayYesEvents to subscribe to
secretstringYesSecret for signature verification

Response:

{
"data": {
"webhookId": "wh_abc123",
"url": "https://your-domain.com/webhooks/savvymoney",
"events": ["score.updated", "score.changed", "alert.created"],
"status": "active",
"createdAt": "2024-01-15T10:30:00Z"
}
}

List Webhooks​

GET /v1/webhooks

Update Webhook​

PATCH /v1/webhooks/{webhookId}

Delete Webhook​

DELETE /v1/webhooks/{webhookId}

Webhook Payload​

All webhook payloads follow this structure:

{
"id": "evt_xyz789",
"type": "score.updated",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
// Event-specific data
}
}

Event Examples​

score.updated​

Sent when a user's credit score is refreshed.

{
"id": "evt_abc123",
"type": "score.updated",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
"userId": "usr_abc123xyz",
"externalUserId": "user-12345",
"score": 742,
"previousScore": 738,
"change": 4,
"scoreDate": "2024-01-15"
}
}

score.changed​

Sent when a score changes by more than 10 points.

{
"id": "evt_def456",
"type": "score.changed",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
"userId": "usr_abc123xyz",
"externalUserId": "user-12345",
"score": 765,
"previousScore": 742,
"change": 23,
"direction": "up",
"scoreDate": "2024-01-15"
}
}

alert.created​

Sent when a new credit alert is generated.

{
"id": "evt_ghi789",
"type": "alert.created",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
"userId": "usr_abc123xyz",
"externalUserId": "user-12345",
"alert": {
"alertId": "alt_xyz789",
"type": "new_inquiry",
"title": "New Credit Inquiry",
"message": "Example Bank checked your credit report.",
"severity": "warning"
}
}
}

user.enrolled​

Sent when a user completes enrollment.

{
"id": "evt_jkl012",
"type": "user.enrolled",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
"userId": "usr_abc123xyz",
"externalUserId": "user-12345",
"email": "user@example.com",
"status": "pending_verification"
}
}

offer.clicked​

Sent when a user clicks on an offer.

{
"id": "evt_mno345",
"type": "offer.clicked",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
"userId": "usr_abc123xyz",
"externalUserId": "user-12345",
"offerId": "off_xyz789",
"offerType": "credit_card",
"provider": "Example Bank",
"clickId": "clk_abc123"
}
}

Verifying Webhooks​

All webhooks are signed for security. Always verify the signature before processing.

Signature Header​

Webhooks include a signature in the X-SavvyMoney-Signature header:

X-SavvyMoney-Signature: t=1705312800,v1=5257a869e7ecebeda32affa62cdca3fa51c...

The signature contains:

  • t - Unix timestamp of when the webhook was sent
  • v1 - HMAC-SHA256 signature

Verification Process​

Node.js Verification
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
// Parse the signature header
const elements = signature.split(',');
const timestamp = elements.find(e => e.startsWith('t=')).slice(2);
const receivedSig = elements.find(e => e.startsWith('v1=')).slice(3);

// Check timestamp is within 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old');
}

// Compute expected signature
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

// Compare signatures
if (!crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
)) {
throw new Error('Invalid webhook signature');
}

return true;
}
Python Verification
import hmac
import hashlib
import time
import json

def verify_webhook_signature(payload, signature, secret):
# Parse the signature header
elements = dict(e.split('=') for e in signature.split(','))
timestamp = elements['t']
received_sig = elements['v1']

# Check timestamp is within 5 minutes
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
raise ValueError('Webhook timestamp too old')

# Compute expected signature
signed_payload = f"{timestamp}.{json.dumps(payload)}"
expected_sig = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()

# Compare signatures
if not hmac.compare_digest(received_sig, expected_sig):
raise ValueError('Invalid webhook signature')

return True
Java Verification
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

public class WebhookVerifier {

public boolean verifySignature(String payload, String signature, String secret)
throws Exception {

// Parse signature header
String[] elements = signature.split(",");
String timestamp = null;
String receivedSig = null;

for (String element : elements) {
if (element.startsWith("t=")) {
timestamp = element.substring(2);
} else if (element.startsWith("v1=")) {
receivedSig = element.substring(3);
}
}

// Check timestamp
long currentTime = System.currentTimeMillis() / 1000;
if (Math.abs(currentTime - Long.parseLong(timestamp)) > 300) {
throw new SecurityException("Webhook timestamp too old");
}

// Compute expected signature
String signedPayload = timestamp + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
mac.init(secretKey);
byte[] hash = mac.doFinal(signedPayload.getBytes());
String expectedSig = bytesToHex(hash);

// Compare signatures (timing-safe)
return MessageDigest.isEqual(
receivedSig.getBytes(),
expectedSig.getBytes()
);
}
}

Handling Webhooks​

Endpoint Requirements​

Your webhook endpoint must:

  1. Use HTTPS - HTTP endpoints are not supported
  2. Respond quickly - Return 2xx within 30 seconds
  3. Be idempotent - Handle duplicate deliveries
  4. Return 2xx status - Any 2xx indicates success

Example Handler​

Express.js Handler
const express = require('express');
const app = express();

// Parse raw body for signature verification
app.use('/webhooks/savvymoney',
express.raw({ type: 'application/json' })
);

app.post('/webhooks/savvymoney', async (req, res) => {
const signature = req.headers['x-savvymoney-signature'];
const payload = JSON.parse(req.body);

try {
// Verify signature
verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET);

// Process event
switch (payload.type) {
case 'score.updated':
await handleScoreUpdate(payload.data);
break;
case 'alert.created':
await handleNewAlert(payload.data);
break;
default:
console.log(`Unhandled event type: ${payload.type}`);
}

// Acknowledge receipt
res.status(200).json({ received: true });

} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: error.message });
}
});

async function handleScoreUpdate(data) {
// Update user's score in your database
await db.users.update({
where: { externalId: data.externalUserId },
data: { creditScore: data.score, scoreUpdatedAt: new Date() }
});

// Notify user if significant change
if (Math.abs(data.change) >= 10) {
await sendPushNotification(data.externalUserId, {
title: 'Credit Score Update',
body: `Your score ${data.change > 0 ? 'increased' : 'decreased'} by ${Math.abs(data.change)} points`
});
}
}

Retry Policy​

If your endpoint fails to respond with a 2xx status, we'll retry:

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry24 hours

After 5 failed attempts, the webhook is marked as failed and we'll send an email notification.

Testing Webhooks​

Test Endpoint​

Send a test webhook to verify your endpoint:

POST /v1/webhooks/{webhookId}/test

Request Body:

{
"eventType": "score.updated"
}

Webhook Logs​

View recent webhook deliveries:

GET /v1/webhooks/{webhookId}/logs

Response:

{
"data": [
{
"deliveryId": "del_abc123",
"eventId": "evt_xyz789",
"eventType": "score.updated",
"status": "success",
"statusCode": 200,
"responseTime": 145,
"deliveredAt": "2024-01-15T10:30:00Z"
}
]
}

Best Practices​

  1. Respond quickly - Process asynchronously if needed
  2. Handle duplicates - Use event ID for deduplication
  3. Verify signatures - Always verify before processing
  4. Log everything - Keep logs for debugging
  5. Monitor failures - Alert on repeated failures
  6. Use queues - Process webhooks via a message queue for reliability

Next Steps​