Registration Webhook
A secure webhook that triggers when a funnel visitor provides their email and clicks the Registration button, delivering form answers to your system in real-time.
Overview
The Registration Webhook is triggered immediately when a visitor completes the registration step in your funnel — providing their email address and clicking the "Registration" button. This webhook delivers the visitor's email along with all their quiz/form answers, enabling you to:
Capture leads in real-time — Send data to your CRM, email marketing platform, or database instantly
Personalize follow-up flows — Use quiz answers to trigger customized email sequences or experiences
Sync with external systems — Update customer profiles, assign tags, or route leads based on their responses
Configuration
To enable the Registration Webhook, configure it in your Zellify dashboard:
Navigate to dashboard.zellify.app/dashboard/settings
Locate the Developer section
Enter your Registration Webhook URL (the endpoint that will receive the payload)
Enter your Registration Webhook Secret (used to sign and verify requests)
Keep your secret secure. The webhook secret is used to sign all requests. Never commit it to version control or expose it in client-side code.
How It Works
Visitor completes registration They provide their email and click the "Registration" button in your funnel
Zellify sends webhook A POST request is immediately sent to your configured endpoint
Your system processes data Verify the signature, then store the lead, trigger automations, or sync to other platforms
Visitor continues funnel The funnel proceeds to the next step (typically payment or confirmation)
Payload Structure
TypeScript Definition
type RegistrationEventPayload = {
email: string;
funnelId: number;
campaignId?: number;
answers: Record<string, {
value: number | string | string[] | boolean;
viewSlug: string;
questionText: string;
}>
}Field Descriptions
email
string
The visitor's email address
funnelId
number
The ID of the funnel the visitor registered through
campaignId
number | undefined
The campaign ID (only present when visitor arrives via /c/<id> campaign link)
answers
object
Map of question IDs to answer objects
answers[id].value
number | string | string[] | boolean
The visitor's answer (type varies by question)
answers[id].viewSlug
string
Unique identifier for the question view
answers[id].questionText
string
The actual question text shown to the visitor
Example Payload
{
"email": "[email protected]",
"funnelId": 123,
"campaignId": 456,
"answers": {
"4b88b9b2-9851-4e02-b9fc-9276e021f283": {
"value": 1,
"viewSlug": "training-frequency",
"questionText": "How often do you train?"
},
"84bb36fd-5034-4737-8ef6-5b6ee1325d39": {
"value": ["1", "2"],
"viewSlug": "multiple-ranks",
"questionText": "Select all applicable ranks"
},
"c8323dd0-855c-4b2f-89c6-c1876e7acb5c": {
"value": "John Doe",
"viewSlug": "personal-info",
"questionText": "What is your full name?"
}
}
}Security & Verification
HTTP Request Details
Method:
POSTContent-Type:
application/jsonHeaders:
x-zellify-signature: HMAC-SHA256 signature of the request bodyCustom headers are optional; signature verification is the primary security mechanism
Signature Verification
Every request is signed using your Registration Webhook Secret. The signature is computed as:
HMAC-SHA256(secret, rawRequestBody)Always verify signatures before processing. Use constant-time comparison to prevent timing attacks.
Verification Implementation (Node.js)
import crypto from 'crypto';
function verifySignature(rawBodyBuffer, signatureHex, secret) {
if (!signatureHex || !secret) return false;
const computedHex = crypto
.createHmac('sha256', secret)
.update(rawBodyBuffer)
.digest('hex');
return constantTimeEqualHex(computedHex, signatureHex);
}
function constantTimeEqualHex(aHex, bHex) {
if (typeof aHex !== 'string' || typeof bHex !== 'string') return false;
if (aHex.length !== bHex.length) return false;
try {
const a = Buffer.from(aHex, 'hex');
const b = Buffer.from(bHex, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}Important: Verify the signature against the raw request body bytes, not the parsed JSON object. Using JSON.stringify(req.body) will fail due to formatting differences.
Implementation Examples
Express.js + CRM Integration
import express from 'express';
import crypto from 'crypto';
const app = express();
const WEBHOOK_SECRET = process.env.REGISTRATION_WEBHOOK_SECRET;
// Use express.raw() to capture raw body bytes
app.use('/webhooks/registration', express.raw({ type: 'application/json' }));
app.post('/webhooks/registration', async (req, res) => {
const rawBody = req.body; // Buffer of exact bytes
const signature = req.headers['x-zellify-signature'];
// Verify signature
if (!verifySignature(rawBody, String(signature || ''), WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse JSON after verification
let payload;
try {
payload = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).json({ error: 'Invalid JSON body' });
}
const { email, funnelId, campaignId, answers } = payload;
// Process the registration
await saveToCRM({
email,
funnelId,
campaignId, // Optional - may be undefined
trainingFrequency: answers['4b88b9b2-9851-4e02-b9fc-9276e021f283']?.value,
selectedRanks: answers['84bb36fd-5034-4737-8ef6-5b6ee1325d39']?.value,
fullName: answers['c8323dd0-855c-4b2f-89c6-c1876e7acb5c']?.value,
});
return res.status(200).json({ success: true });
});
function verifySignature(rawBodyBuffer, signatureHex, secret) {
if (!signatureHex || !secret) return false;
const computedHex = crypto
.createHmac('sha256', secret)
.update(rawBodyBuffer)
.digest('hex');
return constantTimeEqualHex(computedHex, signatureHex);
}
function constantTimeEqualHex(aHex, bHex) {
if (typeof aHex !== 'string' || typeof bHex !== 'string') return false;
if (aHex.length !== bHex.length) return false;
try {
const a = Buffer.from(aHex, 'hex');
const b = Buffer.from(bHex, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}Firebase Functions + Email Marketing
import { onRequest } from 'firebase-functions/v2/https';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.REGISTRATION_WEBHOOK_SECRET;
function constantTimeEqualHex(aHex, bHex) {
if (typeof aHex !== 'string' || typeof bHex !== 'string') return false;
if (aHex.length !== bHex.length) return false;
try {
const a = Buffer.from(aHex, 'hex');
const b = Buffer.from(bHex, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}
function verifySignature(rawBodyBuffer, signatureHex, secret) {
if (!signatureHex || !secret) return false;
const computedHex = crypto
.createHmac('sha256', secret)
.update(rawBodyBuffer)
.digest('hex');
return constantTimeEqualHex(computedHex, signatureHex);
}
export const registrationWebhook = onRequest(async (req, res) => {
const rawBody = req.rawBody; // Firebase provides this as Buffer
const signature = req.headers['x-zellify-signature'];
if (!verifySignature(rawBody, String(signature || ''), WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
let payload;
try {
payload = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).json({ error: 'Invalid JSON body' });
}
const { email, funnelId, campaignId, answers } = payload;
// Build tags based on answers
const tags = [];
for (const [id, answer] of Object.entries(answers)) {
if (answer.viewSlug === 'training-frequency' && answer.value >= 3) {
tags.push('high-frequency-trainer');
}
}
// Send to email marketing platform
await addToEmailList({
email,
funnelId,
campaignId, // Optional - may be undefined
tags,
customFields: {
fullName: answers['c8323dd0-855c-4b2f-89c6-c1876e7acb5c']?.value,
},
});
return res.status(200).json({ success: true });
});Best Practices
✅ Respond quickly — Aim for < 2 seconds response time to avoid timeouts
✅ Return 200 status — Always return a 200 status code on successful processing
✅ Make it idempotent — Handle duplicate webhooks gracefully (use email as a unique key)
✅ Log for debugging — Store raw payloads temporarily to troubleshoot issues
✅ Process asynchronously — For heavy operations, queue the work and return 200 immediately
✅ Use answer slugs — Reference answers by viewSlug instead of question ID for more maintainable code
✅ Handle missing answers — Not all questions may be answered; always check for existence
Error Handling
Webhook Delivery Behavior
2xx response → Webhook marked as successfully delivered
Non-2xx response or timeout → Zellify logs the error; the visitor continues through the funnel
Recommended Error Handling
app.post('/webhooks/registration', async (req, res) => {
try {
// Verify signature
if (!verifySignature(rawBody, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse and validate
const payload = JSON.parse(rawBody.toString('utf8'));
if (!payload.email) {
return res.status(400).json({ error: 'Email is required' });
}
// Process asynchronously (don't await)
processRegistration(payload).catch(err => {
console.error('Background processing failed:', err);
});
// Return success immediately
return res.status(200).json({ success: true });
} catch (error) {
console.error('Webhook error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});Local Testing
Generate Test Signature (Node.js)
node -e "const c=require('crypto');const raw=Buffer.from(JSON.stringify({email:'[email protected]',funnelId:123,campaignId:456,answers:{}}));const sig=c.createHmac('sha256',process.env.REGISTRATION_WEBHOOK_SECRET).update(raw).digest('hex');console.log('signature:',sig);console.log('body:',raw.toString());"Send Test Request (cURL)
curl -X POST "http://localhost:3000/webhooks/registration" \
-H "Content-Type: application/json" \
-H "x-zellify-signature: YOUR_COMPUTED_SIGNATURE" \
--data '{"email":"[email protected]","funnelId":123,"campaignId":456,"answers":{"test-id":{"value":"test","viewSlug":"test-slug","questionText":"Test Question?"}}}'Using Ngrok for Local Development
# Start ngrok tunnel
ngrok http 3000
# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
# Add to Zellify dashboard: https://abc123.ngrok.io/webhooks/registrationLast updated