BASE44DEVS

ARTICLE · 9 MIN READ

Base44 Webhooks Complete Guide: Receiving, Sending, and Reliability

Webhooks are how Base44 talks to the rest of the internet. Inbound webhooks arrive as POSTs to backend functions; outbound are fetch calls. The complications: Base44 has documented behavior where webhooks fire only when users are active, signature validation is your job, retries are your job, and the routing layer occasionally returns 405 on POST. This guide covers reliable patterns for both directions, plus the reconciliation that catches what the platform misses.

Last verified
2026-05-01
Published
2026-05-01
Read time
9 min
Words
1,671
  • WEBHOOKS
  • INTEGRATIONS
  • RELIABILITY

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:

  1. Method check. Reject anything other than POST.
  2. Signature validation. Reject anything without a valid signature.
  3. Idempotency. Skip events we've already processed.
  4. Logging. Persist the payload and processing state.
  5. 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:

SenderHeaderAlgorithmLibrary
StripeStripe-SignatureHMAC-SHA256 + timestampStripe SDK
GitHubX-Hub-Signature-256HMAC-SHA256Custom (10 lines)
Auth0Auth0-SignatureHMAC-SHA256Auth0 SDK
TwilioX-Twilio-SignatureHMAC-SHA1Twilio SDK
SlackX-Slack-SignatureHMAC-SHA256 + timestampSlack 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.

QUERIES

Frequently asked questions

Q.01Do Base44 webhooks fire reliably 24/7?
A.01

Inbound webhook delivery has a documented quirk: events may not process promptly when no users are actively using the app. This affects time-critical events like subscription renewals at 3am or failed-payment retries when customers are offline. Stripe and most senders retry for several days, so most events eventually land, but real-time state can drift. The mitigation is a reconciliation job that polls the sender's API for recent events and processes any your app missed.

Q.02Why am I getting 405 Method Not Allowed on my webhook URL?
A.02

A documented Base44 routing bug. The platform's request router occasionally treats /functions/* paths as frontend routes, returning 405 instead of forwarding to your Deno handler. Workarounds: place the function at a path that doesn't collide with your frontend routes, use a query-parameter trigger instead of distinct paths, or proxy the webhook through Cloudflare Workers and forward to the Base44 function. We cover the diagnosis in [the function routing fix](/fix/backend-functions-404-routing-broken).

Q.03How do I validate webhook signatures on Base44?
A.03

The same way you would anywhere else: read the signature header, recompute the expected signature using your shared secret, and compare. The library you use depends on the sender — Stripe has its own, Auth0 has its own, GitHub has its own. For custom inbound webhooks, use HMAC-SHA256 with a shared secret. The platform doesn't validate signatures for you; that's your responsibility. Without validation, anyone who knows your URL can forge events.

Q.04Should I store webhook payloads for replay?
A.04

Yes, at least for critical events. Store the raw body, the signature header, and the processing result in an entity. This lets you replay failed events, audit what was received, and debug issues post-incident. Base44's platform logs roll quickly, so without an entity-backed log, your forensic options are limited. Retain webhook payloads for at least 90 days, longer for regulated data.

Q.05How do I send outbound webhooks from Base44 reliably?
A.05

From a backend function, fetch to the receiver's URL with your payload, sign it with HMAC for the receiver to validate, and handle failures with exponential backoff retries. Log every attempt and the response in an entity so you can see delivery state. For high-volume or time-critical outbound webhooks, queue them externally (a backend function that publishes to a third-party queue, then a worker that drains the queue) rather than synchronously inside the request that triggered them.

Q.06Can I use Base44 webhooks for customer-facing webhooks (where my customers receive events from my app)?
A.06

Yes, but invest in the operational pieces. Customers expect webhook reliability — signed payloads, retries on failure, dead-letter queue for unrecoverable failures, replay capability. Build a Webhook Subscription entity, a Webhook Delivery entity logging every attempt, and a backend function that processes the delivery queue. For high-scale outbound webhook needs, consider Svix or Hookdeck instead of building it on Base44.

NEXT STEP

Need engineers who actually know base44?

Book a free 15-minute call or order a $497 audit.