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

Registration happens before payment. This webhook fires when the visitor submits their email, not when they complete a purchase. Use it for lead capture and pre-qualification.


Configuration

To enable the Registration Webhook, configure it in your Zellify dashboard:

  1. Locate the Developer section

  2. Enter your Registration Webhook URL (the endpoint that will receive the payload)

  3. Enter your Registration Webhook Secret (used to sign and verify requests)


How It Works

  1. Visitor completes registration They provide their email and click the "Registration" button in your funnel

  2. Zellify sends webhook A POST request is immediately sent to your configured endpoint

  3. Your system processes data Verify the signature, then store the lead, trigger automations, or sync to other platforms

  4. 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

Field
Type
Description

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

About campaignId: This field is optional and only present when visitors arrive via /c/<id> campaign links. If a visitor directly accesses /funnel/<id>, the campaignId will be undefined. Best practice: In production, always share campaign links (/c/<id>) when posting ads or marketing materials to ensure campaignId is always available for attribution.

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: POST

  • Content-Type: application/json

  • Headers:

    • x-zellify-signature: HMAC-SHA256 signature of the request body

    • Custom 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)

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;
  }
}

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

No automatic retries. If your endpoint fails, the webhook is not automatically retried. The visitor will still proceed through the funnel.

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/registration

Last updated