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β
| Event | Description |
|---|---|
user.enrolled | User successfully enrolled |
user.verified | User passed identity verification |
user.deleted | User account deleted |
score.updated | Credit score has been updated |
score.changed | Credit score changed significantly |
alert.created | New credit alert generated |
offer.clicked | User clicked on an offer |
offer.converted | User 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Your HTTPS endpoint URL |
events | array | Yes | Events to subscribe to |
secret | string | Yes | Secret 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 sentv1- HMAC-SHA256 signature
Verification Processβ
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;
}
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
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:
- Use HTTPS - HTTP endpoints are not supported
- Respond quickly - Return 2xx within 30 seconds
- Be idempotent - Handle duplicate deliveries
- Return 2xx status - Any 2xx indicates success
Example 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:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 24 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β
- Respond quickly - Process asynchronously if needed
- Handle duplicates - Use event ID for deduplication
- Verify signatures - Always verify before processing
- Log everything - Keep logs for debugging
- Monitor failures - Alert on repeated failures
- Use queues - Process webhooks via a message queue for reliability
Next Stepsβ
- Review Error Handling patterns
- Implement Security Best Practices
- Explore API Endpoints