Why this matters
Webhooks are the connective tissue of any integrated app. They are how Stripe tells you a payment came through, how Auth0 tells you a user logged in from a new device, how GitHub tells you a PR opened. They are also how your app tells third parties about events you generate.
Base44 supports both directions, with caveats. The caveats matter because every one of them maps to a production incident we have seen: missed renewals, duplicate processing, forged events, lost events. This guide covers the patterns that prevent each.
Direction 1: receiving inbound webhooks
A backend function with a route like /functions/stripeWebhook accepts POST requests from external services.
Minimum viable inbound webhook handler
// backend/functions/stripeWebhook.ts
export default async function handler(req: Request): Promise<Response> {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
// 1. Validate the signature
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const body = await req.text();
let event;
try {
event = await verifyStripeSignature(body, signature);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// 2. Idempotency check
const existing = await base44.entities.WebhookLog.list(
{ event_id: event.id },
null,
1
);
if (existing.length > 0) {
return new Response("Already processed", { status: 200 });
}
// 3. Log receipt
await base44.entities.WebhookLog.create({
event_id: event.id,
event_type: event.type,
payload: body,
received_at: new Date().toISOString(),
status: "received",
});
// 4. Process
try {
await processEvent(event);
await base44.entities.WebhookLog.update(event.id, {
status: "processed",
processed_at: new Date().toISOString(),
});
} catch (err) {
await base44.entities.WebhookLog.update(event.id, {
status: "failed",
error: String(err),
});
// Return 500 so the sender retries
return new Response("Processing error", { status: 500 });
}
// 5. Acknowledge success
return new Response("OK", { status: 200 });
}
Five things this does:
- Method check. Reject anything other than POST.
- Signature validation. Reject anything without a valid signature.
- Idempotency. Skip events we've already processed.
- Logging. Persist the payload and processing state.
- Error handling. Return 500 on failure so the sender retries.
Every inbound webhook handler should follow this pattern. Different senders have different signature schemes; the rest is identical.
Signature validation per sender
Each sender uses a different signature scheme. Common ones:
| Sender | Header | Algorithm | Library |
|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 + timestamp | Stripe SDK |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | Custom (10 lines) |
| Auth0 | Auth0-Signature | HMAC-SHA256 | Auth0 SDK |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | Twilio SDK |
| Slack | X-Slack-Signature | HMAC-SHA256 + timestamp | Slack SDK |
For custom or in-house senders, use HMAC-SHA256 with a shared secret. Sample implementation:
async function verifyHmacSha256(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const sigBytes = hexToBytes(signature);
return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(body));
}
The active-users issue and reconciliation
Multiple Base44 users have reported webhooks firing only when the app has active sessions. Subscription renewals at off-hours can fail to process promptly.
The mitigation: a reconciliation job that polls the sender's API for recent events and re-applies any missed ones.
// backend/functions/reconcileStripeWebhooks.ts
import Stripe from "https://esm.sh/stripe@14";
export default async function handler(req: Request): Promise<Response> {
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
// Look back 25 hours, overlapping with the previous run for safety
const since = Math.floor((Date.now() - 25 * 60 * 60 * 1000) / 1000);
let cursor: string | undefined;
let recovered = 0;
while (true) {
const events = await stripe.events.list({
created: { gte: since },
limit: 100,
starting_after: cursor,
});
for (const event of events.data) {
const seen = await base44.entities.WebhookLog.list(
{ event_id: event.id },
null,
1
);
if (seen.length > 0) continue;
// Process the missed event
await processEvent(event);
await base44.entities.WebhookLog.create({
event_id: event.id,
event_type: event.type,
received_at: new Date().toISOString(),
source: "reconcile",
});
recovered++;
}
if (!events.has_more) break;
cursor = events.data[events.data.length - 1].id;
}
return new Response(JSON.stringify({ recovered }), { status: 200 });
}
Schedule this externally — cron-job.org, GitHub Actions scheduled workflows, or a third-party scheduler. Run hourly for time-critical events, daily otherwise.
Idempotency in detail
Webhooks retry. Your handler must be idempotent. Three patterns work:
Event-ID dedup. Track every processed event ID in an entity. Skip duplicates. Used in the example above.
Natural-key dedup. Use a domain-specific unique identifier (Stripe payment_intent_id, Auth0 user_id) as the dedup key. Useful when the sender doesn't provide event IDs.
Idempotent operations. Make every operation in the handler idempotent. Entity.update(id, {status: "paid"}) is idempotent; Entity.create({status: "paid"}) is not. Prefer updates over creates where possible.
Combine all three for resilience. Cost: a few extra entity reads per webhook. Worth it.
Direction 2: sending outbound webhooks
Outbound webhooks are fetch calls from a backend function. Three places this matters:
- Customer-facing webhooks. Your customers subscribe to events your app generates.
- Internal integrations. You notify a Slack channel, ping a logging system, sync to an external database.
- Workflow triggers. You kick off an external workflow (Zapier, n8n) on user actions.
Sending a single webhook
async function sendWebhook(url: string, payload: any, secret: string) {
const body = JSON.stringify(payload);
const signature = await hmacSha256(body, secret);
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-webhook-signature": signature,
"x-webhook-timestamp": String(Date.now()),
},
body,
});
if (!response.ok) {
throw new Error(`Webhook failed: ${response.status}`);
}
}
Sign every outgoing webhook so receivers can validate it. Include a timestamp to prevent replay attacks.
Retry and queue logic
Networks fail. Receivers go down. A naive await sendWebhook(...) inside a request handler will block the user, and a single transient failure means the event is lost.
Better pattern: persist the outgoing webhook in an entity, return immediately, and process the queue from a scheduled function with retries.
// 1. Enqueue the webhook
await base44.entities.OutboundWebhook.create({
url,
payload,
status: "pending",
attempts: 0,
next_attempt_at: new Date().toISOString(),
max_attempts: 8,
});
// 2. Drain the queue (scheduled function)
export default async function drainQueue(req: Request) {
const now = new Date().toISOString();
const pending = await base44.entities.OutboundWebhook.list(
{ status: "pending", next_attempt_at: { $lte: now } },
"next_attempt_at",
50
);
for (const job of pending) {
try {
await sendWebhook(job.url, job.payload, getSecretFor(job.url));
await base44.entities.OutboundWebhook.update(job.id, {
status: "delivered",
delivered_at: new Date().toISOString(),
});
} catch (err) {
const attempts = job.attempts + 1;
if (attempts >= job.max_attempts) {
await base44.entities.OutboundWebhook.update(job.id, {
status: "dead",
last_error: String(err),
});
} else {
const backoffMs = Math.min(60_000 * 2 ** attempts, 24 * 60 * 60 * 1000);
await base44.entities.OutboundWebhook.update(job.id, {
attempts,
next_attempt_at: new Date(Date.now() + backoffMs).toISOString(),
last_error: String(err),
});
}
}
}
return new Response("OK", { status: 200 });
}
Schedule the drain function externally to run every 1–5 minutes. The exponential backoff caps at 24 hours after 8 attempts, then marks the job dead. Dead jobs surface to an admin UI for manual replay or abandonment.
For higher scale (thousands of webhooks per minute), use a dedicated webhook delivery service (Svix, Hookdeck) instead of building this on Base44 entities. The entity-backed queue is fine for hundreds per minute.
Customer-facing webhooks
If your customers can subscribe to events from your app, you need a few additional pieces:
Webhook subscriptions entity. Customers register URLs and event types they want.
Per-customer signing secrets. Each customer's webhooks signed with their own secret so they can validate origin.
Delivery dashboard. Customers see delivery status, recent attempts, errors. Lets them debug their own integration.
Replay capability. Customers can re-trigger a delivery for an event they missed.
Versioning. When you change the event payload format, customers need a way to opt into the new version without breaking their existing receivers.
This is real engineering work. Budget 2–6 weeks to build it correctly the first time. For most apps, defer customer-facing webhooks until you have customer demand justifying the work, then either build on the platform or use Svix/Hookdeck.
Common webhook mistakes
Skipping signature validation. Already covered. The most consequential mistake.
Not idempotent handlers. Sender retries cause duplicate processing.
Returning 200 on processing failure. Sender thinks it succeeded; event is lost.
Synchronous retries inside the request handler. Blocks the sender, exceeds their timeout, looks like a failure even when it works.
No reconciliation for missed events. The platform's webhook quirk requires it; without it, you'll silently lose state.
Trusting outbound delivery without retries. Networks fail. Build the queue.
No payload retention. Forensic debugging requires the original payload.
Routing collisions. Webhook URLs that conflict with frontend routes return 405. Use distinct paths.
Webhook checklist (inbound)
- Method check returns 405 for non-POST.
- Signature validated against shared secret.
- Event ID stored in WebhookLog entity for idempotency.
- Raw payload retained for at least 90 days.
- Successful processing returns 200; errors return 5xx for retry.
- Reconciliation job catches missed events daily or hourly.
- Function URL doesn't collide with frontend routes (avoids 405 bug).
- Alerting on dead events that exceed retry limits.
Webhook checklist (outbound)
- Outgoing payloads signed with HMAC.
- Timestamp included to prevent replay.
- Persisted queue with retry logic.
- Exponential backoff on retries.
- Dead-letter handling for unrecoverable failures.
- Per-customer secrets if customer-facing.
- Delivery state surfaced to admin or customer UI.
- Versioning strategy for payload changes.
Want us to audit your webhook setup?
Our $497 audit reviews every inbound and outbound webhook for signature validation, idempotency, retry logic, and delivery reliability. Most apps have 2–5 fixable issues. Order an audit or book a free 15-minute call.
Related reading
- Base44 Stripe Integration Guide — the canonical inbound-webhook integration with concrete patterns.
- Base44 Error Reference — the integration-specific errors that surface during webhook development.
- Base44 SDK Reference — the SDK calls that webhook handlers depend on.