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
Customer enters email → Payment page loads Zellify captures the email address and immediately sends it to your Pre-Payment API endpoint.
Your API runs pre-payment logic Create accounts, assign IDs, or fetch data from your systems.
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" }
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
orPaymentIntent.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
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.
Paddle Billing (recommended)
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