Webhooks Full Closed Beta
Webhooks deliver loyalty data to your backend in real time. When a user performs a loyalty action in FanFest, your registered endpoint receives an HTTP POST with the event payload, signed with HMAC-SHA256 for authenticity verification.
Closed Beta
Webhook delivery is in Closed Beta. The payload format and delivery behavior described here are subject to change. Contact us for access.
Registering a Webhook
During the Closed Beta, webhook endpoints are registered by the FanFest team on your behalf. Provide:
- Endpoint URL — Must be HTTPS. HTTP endpoints are rejected. Private/internal IP ranges and localhost are blocked for security (SSRF protection).
- Description (optional) — A label to identify this webhook in delivery logs.
FanFest generates a cryptographically secure signing secret for each webhook (minimum 32 bytes of randomness). You will receive this secret during onboarding — store it securely and never expose it in client-side code.
Delivery Headers
Every webhook delivery includes three custom headers:
| Header | Format | Purpose |
|---|---|---|
X-FanFest-Signature | t=<unix_timestamp>,v1=<hex_hmac> | HMAC-SHA256 signature for payload verification |
X-FanFest-Timestamp | Unix timestamp (seconds) | When the signature was generated — used for replay protection |
X-FanFest-Event-Id | UUID string | Unique identifier for this event — used for idempotency |
Additionally, the Content-Type header is always application/json.
Payload Format
The request body is a JSON object whose shape depends on your channel's resolution mode. All payloads include a resolution discriminator field:
{
"resolution": "aggregated",
"channel_id": "ch_abc123",
"user_id": "usr_xyz789",
"app_action_name": "quiz_answer",
"total_points": 1250,
"total_occurrences": 47,
"community_ids": ["com_111", "com_222"],
"timestamp": "2025-06-15T14:32:00.000Z"
}See Webhook Payloads Reference for complete schemas for all three resolution modes.
Signature Verification
Every webhook delivery is signed using HMAC-SHA256. You must verify signatures before processing any payload to ensure the request originated from FanFest and was not tampered with in transit.
How Signing Works
- FanFest constructs the signed content by concatenating the Unix timestamp and the raw JSON body with a period separator:
<timestamp>.<json_body> - An HMAC-SHA256 hash is computed using your webhook secret as the key
- The result is sent in the
X-FanFest-Signatureheader ast=<timestamp>,v1=<hex_hmac>
Verification Steps
To verify a webhook signature:
- Extract the
t(timestamp) andv1(signature) values from theX-FanFest-Signatureheader - Reconstruct the signed content:
<t>.<raw_request_body> - Compute the HMAC-SHA256 of the signed content using your webhook secret
- Compare your computed signature with
v1using a timing-safe comparison (to prevent timing attacks) - Check that the timestamp is within an acceptable window (recommended: 5 minutes) to prevent replay attacks
Node.js Example
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(payload, secret, signatureHeader, maxAgeSeconds = 300) {
// 1. Parse the signature header
const parts = Object.fromEntries(
signatureHeader.split(',').map(part => {
const idx = part.indexOf('=');
return [part.slice(0, idx), part.slice(idx + 1)];
})
);
const timestamp = parseInt(parts['t'], 10);
const receivedSignature = parts['v1'];
// 2. Check timestamp freshness (replay protection)
const age = Math.floor(Date.now() / 1000) - timestamp;
if (age > maxAgeSeconds || age < -60) {
throw new Error('Webhook signature timestamp is too old or in the future');
}
// 3. Compute expected signature
const signedContent = `${timestamp}.${payload}`;
const expectedSignature = createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// 4. Timing-safe comparison
if (expectedSignature.length !== receivedSignature.length) {
throw new Error('Webhook signature verification failed');
}
const isValid = timingSafeEqual(
Buffer.from(expectedSignature, 'utf8'),
Buffer.from(receivedSignature, 'utf8'),
);
if (!isValid) {
throw new Error('Webhook signature verification failed');
}
return true;
}Usage in an Express handler:
app.post('/webhooks/fanfest', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-fanfest-signature'];
const payload = req.body.toString();
try {
verifyWebhookSignature(payload, process.env.FANFEST_WEBHOOK_SECRET, signature);
} catch (err) {
return res.status(401).json({ error: err.message });
}
const event = JSON.parse(payload);
const eventId = req.headers['x-fanfest-event-id'];
// Process the event (check idempotency first — see below)
console.log(`Received event ${eventId}:`, event);
res.status(200).json({ received: true });
});Python Example
import hashlib
import hmac
import time
def verify_webhook_signature(payload: str, secret: str, signature_header: str, max_age_seconds: int = 300) -> bool:
# 1. Parse the signature header
parts = dict(
part.split("=", 1) for part in signature_header.split(",")
)
timestamp = int(parts["t"])
received_signature = parts["v1"]
# 2. Check timestamp freshness (replay protection)
age = int(time.time()) - timestamp
if age > max_age_seconds or age < -60:
raise ValueError("Webhook signature timestamp is too old or in the future")
# 3. Compute expected signature
signed_content = f"{timestamp}.{payload}"
expected_signature = hmac.new(
secret.encode("utf-8"),
signed_content.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# 4. Timing-safe comparison
if not hmac.compare_digest(expected_signature, received_signature):
raise ValueError("Webhook signature verification failed")
return TrueImportant
Always use timing-safe comparison functions (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) instead of === or ==. Standard string comparison leaks timing information that can be exploited to forge signatures.
Idempotency
Every webhook delivery includes an X-FanFest-Event-Id header containing a unique identifier for the event. Use this ID to ensure your system processes each event exactly once.
Recommended approach: Before processing an event, check whether you have already successfully processed an event with the same X-FanFest-Event-Id. If so, return 200 immediately without reprocessing.
app.post('/webhooks/fanfest', async (req, res) => {
const eventId = req.headers['x-fanfest-event-id'];
// Check if already processed
const alreadyProcessed = await db.webhookEvents.findUnique({
where: { event_id: eventId },
});
if (alreadyProcessed) {
return res.status(200).json({ received: true, duplicate: true });
}
// Process the event...
// Mark as processed
await db.webhookEvents.create({
data: { event_id: eventId, processed_at: new Date() },
});
res.status(200).json({ received: true });
});Replay Protection
The X-FanFest-Timestamp header contains the Unix timestamp (in seconds) of when the signature was generated. Use this to reject stale deliveries that may be replayed by an attacker.
FanFest's own verification implementation rejects timestamps that are:
- Older than 5 minutes (300 seconds) from the current time
- More than 60 seconds in the future (to account for minor clock drift)
We recommend applying the same window in your verification logic, as shown in the code examples above.
Retry Behavior
If your endpoint does not respond with a 2xx status code within 10 seconds, FanFest considers the delivery failed. The retry behavior is:
| Attempt | Description |
|---|---|
| Initial delivery | First attempt immediately after the loyalty action occurs |
| Retry 1 | Automatic retry via SQS redelivery |
| Retry 2 | Second automatic retry via SQS redelivery |
| Dead Letter Queue | After 3 total attempts (initial + 2 retries), the event is routed to a Dead Letter Queue for manual investigation |
In total, FanFest makes 3 delivery attempts (the initial delivery plus 2 retries). After all attempts are exhausted, the event is moved to a Dead Letter Queue (DLQ) and a CloudWatch alarm is triggered.
INFO
Retries are handled by the SQS infrastructure, not application-level retry logic. This means retry timing is determined by the queue's visibility timeout (120 seconds), not exponential backoff.
Failure Monitoring
FanFest tracks consecutive delivery failures per webhook endpoint. When a webhook accumulates 5 or more consecutive failures, a Slack alert is triggered for the FanFest operations team to investigate.
Successful deliveries reset the consecutive failure counter to zero.
Security Requirements
- HTTPS only — Webhook URLs must use HTTPS. HTTP endpoints are rejected at registration time.
- SSRF protection — Webhook URLs are validated to reject private IP ranges (
10.x.x.x,172.16.x.x-172.31.x.x,192.168.x.x), loopback addresses (127.x.x.x,localhost), and link-local addresses. - 10-second timeout — Deliveries that do not receive a response within 10 seconds are aborted and counted as failures.
- Signing secrets — Secrets are generated with a minimum of 32 bytes of cryptographic randomness (format:
whsec_<64 hex characters>). Store your secret securely and never expose it in client-side code or version control.
