Webhook Delivery
How Rhumby delivers webhook events
Webhook Delivery
Rhumby delivers webhook events via HTTP POST requests to the URLs you register. This page covers the delivery format, signature verification, retry behavior, and security best practices.
Event Types
Rhumby supports the following webhook event types:
| Event Type | Description |
|---|---|
results.published | Race results have been posted for the first time |
results.updated | Published results were corrected or modified |
registration.created | A new boat registered for an event |
registration.cancelled | A registration was cancelled |
event.published | An event status changed from draft to published |
event.updated | Event details (name, dates, venue, etc.) were modified |
protest.filed | A new protest was submitted |
protest.resolved | A protest hearing decision was made |
Delivery Format
When an event occurs, Rhumby sends a POST request to your webhook URL with these headers:
POST /webhooks/rhumby HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-Rhumby-Signature: sha256=a1b2c3d4e5f6...
X-Rhumby-Event: results.published
X-Rhumby-Delivery: 3f71fa87494e4a0e993738b3599390f6
X-Rhumby-Timestamp: 1743019800
User-Agent: Rhumby-Webhooks/1.0Header Reference:
X-Rhumby-Signature: HMAC-SHA256 signature for payload verificationX-Rhumby-Event: The event type (e.g.,results.published)X-Rhumby-Delivery: Unique ID for this delivery attempt (for idempotency)X-Rhumby-Timestamp: Unix timestamp (seconds) when the event occurred
Payload Examples
results.published
{
"raceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"raceNumber": 5,
"eventId": "b2c3d4e5-f678-90ab-cdef-1234567890ab",
"eventSlug": "friday-night-spring-2026",
"resultsCount": 12,
"timestamp": "2026-03-29T18:30:00.000Z"
}registration.created
{
"registrationId": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"eventId": "b2c3d4e5-f678-90ab-cdef-1234567890ab",
"eventSlug": "friday-night-spring-2026",
"userId": "d4e5f678-90ab-cdef-1234-567890abcdef",
"boatId": "e5f67890-abcd-ef12-3456-7890abcdef12",
"fleetId": "f6789012-bcde-f123-4567-890abcdef123",
"timestamp": "2026-03-29T18:15:00.000Z"
}event.published
{
"eventId": "b2c3d4e5-f678-90ab-cdef-1234567890ab",
"eventSlug": "friday-night-spring-2026",
"eventName": "Friday Night Racing - Spring 2026",
"startDate": "2026-04-05T00:00:00.000Z",
"endDate": "2026-06-27T00:00:00.000Z",
"timestamp": "2026-03-29T17:00:00.000Z"
}event.updated
{
"eventId": "b2c3d4e5-f678-90ab-cdef-1234567890ab",
"eventSlug": "friday-night-spring-2026",
"eventName": "Friday Night Racing - Spring 2026",
"changedFields": ["startDate", "registrationClose", "venue"],
"timestamp": "2026-03-29T17:30:00.000Z"
}Signature Verification
Every webhook payload is signed with HMAC-SHA256 using your webhook secret. Always verify signatures before processing events to ensure they came from Rhumby.
Signature Format
The signature covers both the timestamp and the JSON payload:
signature = HMAC-SHA256(secret, "${timestamp}.${JSON.stringify(payload)}")The X-Rhumby-Signature header contains: sha256=<hex_signature>
Node.js Verification
import crypto from 'crypto';
function verifyWebhookSignature(secret, signature, timestamp, payload) {
// 1. Check timestamp freshness (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return false;
}
// 2. Compute expected signature
const message = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
// 3. Constant-time comparison to prevent timing attacks
if (signature.length !== expectedSignature.length) {
return false;
}
let result = 0;
for (let i = 0; i < signature.length; i++) {
result |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
}
return result === 0;
}
// Express middleware
app.post('/webhooks/rhumby', express.json(), (req, res) => {
const signature = req.headers['x-rhumby-signature']?.replace('sha256=', '');
const timestamp = parseInt(req.headers['x-rhumby-timestamp'], 10);
const payload = req.body;
if (!verifyWebhookSignature(process.env.RHUMBY_WEBHOOK_SECRET, signature, timestamp, payload)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const eventType = req.headers['x-rhumby-event'];
// Process the event
switch (eventType) {
case 'results.published':
handleResultsPublished(payload);
break;
case 'registration.created':
handleNewRegistration(payload);
break;
// ... other events
}
res.status(200).send('OK');
});Python Verification
import hmac
import hashlib
import json
import time
def verify_webhook_signature(secret: str, signature: str, timestamp: int, payload: dict) -> bool:
# 1. Check timestamp freshness (within 5 minutes)
now = int(time.time())
if abs(now - timestamp) > 300:
return False
# 2. Compute expected signature
message = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
expected = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# 3. Constant-time comparison
return hmac.compare_digest(signature, expected)
# Flask example
@app.route('/webhooks/rhumby', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Rhumby-Signature', '').replace('sha256=', '')
timestamp = int(request.headers.get('X-Rhumby-Timestamp', '0'))
payload = request.json
if not verify_webhook_signature(RHUMBY_WEBHOOK_SECRET, signature, timestamp, payload):
return {'error': 'Invalid signature'}, 401
event_type = request.headers.get('X-Rhumby-Event')
# Process the event
if event_type == 'results.published':
handle_results_published(payload)
elif event_type == 'registration.created':
handle_new_registration(payload)
return 'OK', 200Retry & Failure Behavior
Rhumby delivers webhooks with a 5-second timeout. If your endpoint:
- Returns a non-2xx status code
- Times out (>5 seconds)
- Fails to connect
...we consider it a failure and increment the webhook's failure count.
Failure Handling
- Each webhook event is delivered once — there are no automatic retries
- Consecutive failures are tracked per webhook (not per delivery)
- After 10 consecutive failures, the webhook is automatically deactivated
- On success, the failure count resets to 0
Best Practices for Reliable Delivery
Since there are no built-in retries, follow these practices:
- Return 200 quickly (within 5 seconds) and process async
- Monitor your webhook endpoint health to catch issues early
- Use the Rhumby API to backfill missed events if needed
- Implement idempotency using
X-Rhumby-Deliveryheaders
Note: Automatic retry support (exponential backoff) is planned for a future release.
Security Best Practices
1. Always Verify Signatures
Never trust webhook payloads without signature verification. An attacker could send fake events to your endpoint.
// ❌ BAD - processes events without verification
app.post('/webhooks/rhumby', (req, res) => {
processEvent(req.body); // Dangerous!
res.send('OK');
});
// ✅ GOOD - verifies signature first
app.post('/webhooks/rhumby', (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature');
}
processEvent(req.body);
res.send('OK');
});2. Check Timestamp Freshness
The signature includes a timestamp to prevent replay attacks. Reject webhooks older than 5 minutes:
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return res.status(401).send('Timestamp too old');
}3. Use Constant-Time Comparison
Prevent timing attacks by using crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python):
// ❌ BAD - vulnerable to timing attacks
if (signature === expectedSignature) { ... }
// ✅ GOOD - constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { ... }4. Store Secrets Securely
Never commit webhook secrets to version control. Use environment variables:
# .env
RHUMBY_WEBHOOK_SECRET=a1b2c3d4e5f6...5. Respond Quickly
Return a 200 status within 5 seconds, then process the event asynchronously:
app.post('/webhooks/rhumby', async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature');
}
// Return 200 immediately
res.status(200).send('OK');
// Process async (job queue, background worker, etc.)
await queue.add('process-webhook', {
event: req.headers['x-rhumby-event'],
payload: req.body,
});
});6. Implement Idempotency
Use X-Rhumby-Delivery to detect duplicate deliveries:
const deliveryId = req.headers['x-rhumby-delivery'];
// Check if already processed
if (await cache.exists(`webhook:${deliveryId}`)) {
return res.status(200).send('Already processed');
}
// Process and mark as done
await processEvent(req.body);
await cache.set(`webhook:${deliveryId}`, true, 'EX', 86400); // 24 hour TTLTesting Webhooks
Use webhook.site or ngrok to test webhook delivery during development:
# 1. Start your local server
node server.js
# 2. Expose it with ngrok
ngrok http 3000
# 3. Register the ngrok URL as a webhook
curl -X POST https://rhumby.com/api/v1/webhooks \
-H "Authorization: Bearer rhb_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/rhumby",
"events": ["results.published"]
}'Troubleshooting
Webhook not receiving events
- Check that your webhook is active:
GET /api/v1/webhooks - Verify the
eventsarray includes the event type you expect - Check that the event is firing from the correct organization
- Review webhook failure count — it may be deactivated after 10 failures
Signature verification failing
- Ensure you're using the correct secret (returned once on creation)
- Verify you're signing
${timestamp}.${JSON.stringify(payload)} - Check for JSON formatting differences (use
separators=(',', ':')in Python) - Confirm timestamp is within 5 minutes
High failure count
- Ensure your endpoint returns 200 within 5 seconds
- Check server logs for crashes or errors
- Verify your server is publicly accessible (not behind firewall)
- Test with
curlfrom outside your network
Next Steps
- Create a webhook via the API
- View webhook signatures for more verification examples
- Browse webhook event types