Inbound webhooks on Base44 are backend functions accepting POST requests. The reliability checklist is non-negotiable: validate the signature header against your shared secret, handle idempotency by tracking event_id in a WebhookLog entity (senders retry), return 200 on success and 5xx on processing failure for proper retry behavior, retain raw payloads for at least 90 days, and run a daily or hourly reconciliation job, as a Base44 scheduled automation, that polls the sender's API and re-applies any events your app missed. Outbound webhooks are fetch calls from backend functions; persist them in an entity-backed queue with exponential backoff retries up to 8 attempts, sign every payload with HMAC and a timestamp so receivers can validate origin, and surface delivery state to an admin UI. Watch for the 405 routing bug on /functions/* paths that collide with frontend routes.
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, written by the lead engineer at Base44Devs, 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
// base44/functions/stripeWebhook/entry.ts
import { createClientFromRequest } from "npm:@base44/sdk";
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
// A webhook call has no signed-in user, so entity calls use the service role.
const db = createClientFromRequest(req).asServiceRole.entities;
// 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: skip only events that finished processing
const [existing] = await db.WebhookLog.filter({ event_id: event.id }, undefined, 1);
if (existing?.status === "processed") {
return new Response("Already processed", { status: 200 });
}
// 3. Log receipt (a retry of a failed event reuses its log record)
const log = existing ?? await db.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 db.WebhookLog.update(log.id, {
status: "processed",
processed_at: new Date().toISOString(),
});
} catch (err) {
await db.WebhookLog.update(log.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));
}
Reconciliation for missed events
Even a correct handler misses events: a deploy breaks the endpoint for an hour, the sender gives up on a delivery, or the handler throws on a payload it did not expect. Why a Base44 webhook doesn't arrive covers the causes.
The mitigation: a reconciliation job that polls the sender's API for recent events and re-applies any missed ones.
// base44/functions/reconcileStripeWebhooks/entry.ts
import { createClientFromRequest } from "npm:@base44/sdk";
import Stripe from "npm:stripe@14";
Deno.serve(async (req) => {
const db = createClientFromRequest(req).asServiceRole.entities;
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 db.WebhookLog.filter({ event_id: event.id }, undefined, 1);
if (seen?.status === "processed") continue;
// Process the missed or failed event, then mark it processed
await processEvent(event);
const done = { status: "processed", processed_at: new Date().toISOString() };
if (seen) await db.WebhookLog.update(seen.id, done);
else await db.WebhookLog.create({ event_id: event.id, event_type: event.type, source: "reconcile", ...done });
recovered++;
}
if (!events.has_more) break;
cursor = events.data[events.data.length - 1].id;
}
return new Response(JSON.stringify({ recovered }), { status: 200 });
});
Run it from a Base44 scheduled automation: add a cron expression or a simple interval to the function's function.jsonc. If your app uses workflows, use a scheduled workflow instead, because apps with workflows can't deploy function automations. Run hourly for time-critical events, daily otherwise. Each run is still a backend function with the 5-minute limit, so on a busy app process a batch per run and let the next run pick up the rest.
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. base44.entities.Order.update(id, { status: "paid" }) is idempotent; base44.entities.Order.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 — the same mechanism you use to connect Base44 to an external API. 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; no signed-in user, so use the service role)
import { createClientFromRequest } from "npm:@base44/sdk";
Deno.serve(async (req) => {
const db = createClientFromRequest(req).asServiceRole.entities;
const now = new Date().toISOString();
const pending = await db.OutboundWebhook.filter(
{ 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 db.OutboundWebhook.update(job.id, {
status: "delivered",
delivered_at: new Date().toISOString(),
});
} catch (err) {
const attempts = job.attempts + 1;
if (attempts >= job.max_attempts) {
await db.OutboundWebhook.update(job.id, {
status: "dead",
last_error: String(err),
});
} else {
const backoffMs = Math.min(60_000 * 2 ** attempts, 24 * 60 * 60 * 1000);
await db.OutboundWebhook.update(job.id, {
attempts,
next_attempt_at: new Date(Date.now() + backoffMs).toISOString(),
last_error: String(err),
});
}
}
}
return new Response("OK", { status: 200 });
});
Run the drain function from a Base44 scheduled automation every few 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.
Why a Base44 webhook doesn't arrive
Nothing in Base44's docs ties webhook delivery to whether anyone is using your app. A webhook handler is an ordinary backend function: "Each function also gets its own HTTP endpoint for webhooks and external integrations" (Base44 docs). When an event goes missing, check these causes in order.
The sender has an old URL. The endpoint is https://<your-app-domain>/functions/<function-name>, so renaming the function or moving the app to a new domain changes it while the sender keeps posting to the old one. Base44's payments troubleshooting says to "verify that your webhook URL is correct and that the endpoint is deployed and responding."
The signature check fails. A test-mode secret on a live endpoint, or a body parsed before verification, rejects every real event. Base44's docs add two traps: Stripe's synchronous constructEvent() throws in Base44 functions, so call await stripe.webhooks.constructEventAsync(...), and secrets.get() returns undefined at module load, so read secrets inside the handler.
The handler throws. A webhook call has no signed-in user, so Base44 says to "use asServiceRole for all operations"; without it, entity calls run with no user and access rules can deny them. Find the error in the function's logs (Dashboard, then Code, then Functions), with base44 logs in the CLI, or on the Logs page, which records function calls and automation runs.
The work runs too long. Backend functions "have a maximum execution time of 5 minutes", and longer requests are terminated. Verify, record the event, return a 2xx, and hand slow work to a scheduled automation.
A retry was skipped or applied twice. Senders retry failed deliveries. A handler that is not idempotent double-processes them; one that records an event as done before processing finishes ignores the retry. Mark it processed last, as the handler above does.
An entity automation never fired. Entity automations "only fire for single-record create, update, and delete calls made through the entities API", so bulk writes such as bulkUpdate() and by-query writes such as updateMany() don't trigger them (automations docs).
Whatever slips past all six, the reconciliation job catches.
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. Deliveries fail for the ordinary reasons above; without reconciliation, 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.
- Entity calls in the handler go through
asServiceRole(a webhook call has no signed-in user). - 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. 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.