Pre-Payment Webhook

A secure webhook that runs before charging the customer, letting you prepare accounts or pass data into processor metadata (Stripe or Paddle).

Why the Pre-Payment API Matters

The Pre-Payment API is your opportunity to prepare your systems before a customer completes their payment. Think of it as an early “heads up” — a webhook that triggers the moment a customer lands on the payment page, letting you perform crucial setup tasks in advance.

Prepare Accounts Before Payment Some businesses need to create or configure resources before payment is processed — for example:

  • Creating a user account in your database

  • Reserving inventory or provisioning services

By running this logic before the charge, you ensure a smoother onboarding experience for your customer.

📦 Pass Critical Data Into Processor Metadata The API response can include additional fields that Zellify automatically attaches to processor-specific targets (e.g., Stripe Customer metadata or Paddle custom_data/passthrough). This gives downstream systems context — without extra API calls.

🛡 Increase Reliability of Post-Purchase Flows Since everything is set up before the payment is charged, you avoid race conditions or failures in after-payment webhooks. This keeps your onboarding seamless, even under high load.


How the Pre-Payment API Works

  1. Customer enters email → Payment page loads Zellify captures the email address and immediately sends it to your Pre-Payment API endpoint.

  2. Your API runs pre-payment logic Create accounts, assign IDs, or fetch data from your systems.

  3. Your API returns a JSON object Any key-value pairs returned will be added to the processor’s metadata target (see below). Example:

    { "crm_id": "12345", "tier": "vip" }
  4. Zellify proceeds with payment The payment form completes loading and the customer can submit their payment.

Key point: The payment only proceeds after your Pre-Payment API responds.


Technical Details

1. Payload Structure

Zellify sends a minimal JSON payload:

{
  "email": "[email protected]"
}

2. HTTP Request

  • Method: POST

  • Content-Type: application/json

  • Headers:

    • x-zellify-signature: HMAC-SHA256 over the raw request body bytes. If a timestamp is used, sign the string "<timestamp>.<rawBody>" instead.

    • x-zellify-timestamp: milliseconds since epoch (optional, recommended for replay protection).

3. Security

  • Each request is signed with your organization’s Pre-Payment API secret.

  • Verify against the raw request body bytes (not JSON.stringify(req.body)).

  • If a timestamp is present, build the payload to sign as "<timestamp>.<rawBody>" and reject stale timestamps (e.g., older than 5 minutes).

  • Compare signatures using constant-time comparison with equal-length buffers.

    const raw = req.rawBody; // Buffer of exact bytes
    const timestamp = req.headers['x-zellify-timestamp'];
    const dataToSign = timestamp
      ? Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), raw])
      : raw;
    const computed = crypto.createHmac('sha256', secret)
      .update(dataToSign)
      .digest('hex');
    // Compare computed with x-zellify-signature using timingSafeEqual and equal lengths

Response Contract

  • Return a JSON object with flat key/value pairs.

  • Values must be strings (stringify numbers/booleans).

  • Recommended limits:

    • Max 50 keys

    • Max 500 characters per value

    • Max body size 8 KB

Processor-specific Behavior

Stripe

  • Metadata target: Customer.metadata

  • Value types: strings only (stringify numbers/booleans)

  • Optional mirroring: Checkout Session.metadata or PaymentIntent.metadata if your flow needs it

Paddle Billing (new platform)

  • Primary: customer.custom_data (persisted at customer level)

  • Fallback: Checkout custom_data to ensure values are echoed in Billing webhooks

Quick Comparison

Capability
Stripe
Paddle Billing
Paddle Classic

Pre-payment metadata target

Customer.metadata

customer.custom_data

checkout passthrough

Persisted at customer level

Yes

Yes

No

Webhook echo

Yes (via Customer)

Yes (custom_data in events)

Yes (passthrough in events)

Value types

Strings only

Strings

Strings

Paddle Classic

  • Target: Checkout passthrough (echoed in Classic webhooks like payment succeeded)

  • Note: No customer-level persistence; treat passthrough as the reliable carrier


Where Metadata Is Attached

Stripe

  • Primary: Customer metadata (strings only).

  • Optional: Can mirror to Checkout Session or PaymentIntent metadata if required by your flow.

  • Primary: customer.custom_data (persisted at customer level).

  • Fallback: Include in Checkout custom_data so it’s echoed in Billing webhooks.

Paddle Classic

  • Primary: Checkout passthrough (echoed in Classic webhooks such as payment succeeded).

  • Note: Classic does not persist customer-level metadata; rely on passthrough in webhooks.


Implementation Example

Here’s a Node.js + Firebase Functions example that:

  • Verifies the request signature

  • Creates a Firebase Auth user

  • Sends them a password email

  • Returns a UID for Stripe metadata

import { onRequest } from 'firebase-functions/v2/https';
import * as admin from 'firebase-admin';
import crypto from 'crypto';

admin.initializeApp();
const auth = admin.auth();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const MAX_SKEW_MS = 5 * 60 * 1000; // 5 minutes

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, timestampMsString, secret) {
  if (!signatureHex || !secret) return false;
  let dataToSign = rawBodyBuffer;
  if (timestampMsString) {
    // Validate timestamp window
    const ts = Number(timestampMsString);
    if (!Number.isFinite(ts)) return false;
    const age = Math.abs(Date.now() - ts);
    if (age > MAX_SKEW_MS) return false;
    dataToSign = Buffer.concat([
      Buffer.from(String(timestampMsString) + '.', 'utf8'),
      rawBodyBuffer,
    ]);
  }
  const computedHex = crypto
    .createHmac('sha256', secret)
    .update(dataToSign)
    .digest('hex');
  return constantTimeEqualHex(computedHex, signatureHex);
}

export const zellifyWebhook = onRequest(async (req, res) => {
  // Firebase provides req.rawBody as a Buffer of the exact bytes
  const rawBody = req.rawBody;
  const signature = req.headers['x-zellify-signature'];
  const timestamp = req.headers['x-zellify-timestamp'];

  if (!verifySignature(rawBody, String(signature || ''), timestamp && String(timestamp), WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Parse the JSON after verifying signature
  let parsed;
  try {
    parsed = JSON.parse(rawBody.toString('utf8'));
  } catch {
    return res.status(400).json({ error: 'Invalid JSON body' });
  }

  const { email } = parsed;
  if (!email) return res.status(400).json({ error: 'Email missing' });

  const userRecord = await auth.createUser({ email, password: 'TempPass123!' });

  return res.status(200).json({
    uid: String(userRecord.uid),
    crm_id: '12345',
    eligibility: 'vip',
  });
});

Best Practices

Keep your API idempotent Ensure multiple calls with the same email don’t create duplicate accounts.

Respond quickly Aim for < 1 second response time.

Return only simple key-value pairs Metadata values should be strings (stringify numbers/booleans) — avoid nested objects.

Secure your endpoint Always verify the x-zellify-signature header before processing.

For Paddle Classic Treat passthrough as your source of truth in webhooks since there is no customer-level persistence.


Failure Behavior

  • Any 2xx response → metadata is applied.

  • If your API returns a non-2xx status or times out, Zellify logs the error and proceeds without your metadata. Payments will still go through, but your pre-payment logic will be skipped.

  • Recommended behavior on Zellify side: 2s timeout, up to 2 retries with jitter. If all attempts fail, continue without metadata.


FAQ

Q: Is the Pre-Payment API required? No. If the URL or secret is not configured in your organization settings, Zellify skips this step entirely.

Q: Can I rely only on processor webhooks (Stripe/Paddle) instead? Yes, but you won’t get the early pre-payment trigger or have your metadata set before payment.

Q: What’s a good example response?

{ "crm_id": "98765", "tier": "gold", "affiliate": "john_doe" }

Local Testing

Compute signature (Node one-liner)

node -e "const c=require('crypto');const raw=Buffer.from(JSON.stringify({email:'[email protected]'}));const ts=Date.now();const sig=c.createHmac('sha256', process.env.WEBHOOK_SECRET).update(Buffer.concat([Buffer.from(String(ts)+'.'),raw])).digest('hex');console.log('ts='+ts);console.log('sig='+sig);console.log('body='+raw.toString());"

Send request (cURL)

curl -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "x-zellify-timestamp: $TS" \
  -H "x-zellify-signature: $SIG" \
  --data "$BODY"

Last updated