Skip to content

How I pipe Base44 Partner leads into my own CRM with a signed webhook

My Base44 Partner leads land in my own CRM through a backend function with a public URL, so it checks every delivery first. Signing is required (no secret, 503), the HMAC is checked on raw bytes in constant time, retries are skipped by event id, and a failed save returns 500 so Base44 tries again.

Aatman (AJ) Jain9 min readUpdated 4 Oct 2026
A white envelope with a papaya orange wax seal slides into a cream card-index box full of blank cards, while a magnifying glass checks the seal, on periwinkle paper.
A white envelope with a papaya orange wax seal slides into a cream card-index box full of blank cards, while a magnifying glass checks the seal, on periwinkle paper.

To pipe Base44 Partner leads into your own CRM, add a Partner webhook that points at a Base44 backend function, leave signing on, and have the function check the HMAC signature on the raw body before it saves a thing. Then plan for the boring failures as well: refuse everything when the secret is missing, skip retries you've already handled, and answer with a 5xx when a save breaks so Base44 sends it again.

Why my leads needed a pipe

I'm a Base44 Partner. Clients message me through Base44's partner hub, which works fine. But on 4 Oct I built a proper pipeline into my site's admin, with stages and follow-ups for everyone I'm talking to. Two inboxes is one too many.

Base44 lets partners connect their own CRM now: in the docs' words, "a webhook for messages going out to your system, and an API key for replies coming back." So new leads and client messages come in through a webhook, and I reply from the client's sheet in my admin.

The catch is the webhook's address. It's a backend function, and every deployed function gets a public HTTP endpoint. Mine is https://aatmanjain.com/functions/partnerWebhook. It's printed right here, in a blog post. Anyone can POST to it, so the function's whole job is to work out which requests really came from Base44, and to behave when something goes wrong.

What Base44 sends

The Developer API page in the partner dashboard lists two events: contact_request.created (a new lead) and contact_message.created (a client wrote on a thread). Each delivery is a POST with a JSON body and four headers:

  • X-Base44-Event says which event it is.
  • X-Base44-Delivery identifies this particular attempt.
  • X-Base44-Event-Id is a stable id for the event. Retries share it.
  • X-Base44-Signature is sha256= plus the hex HMAC-SHA256 of the raw body, made with the webhook's signing secret. It's only there when signing is on.
for your function to answer each delivery
10 s
failed deliveries in a row before Base44 turns a webhook off
20
webhooks per partner profile
5
checks in my Deno tests, all passing
40

Signing is a switch you can turn off. Base44's docs say to "Leave Sign deliveries on so your endpoint can confirm each request really came from Base44", and the secret is shown once. Mine is signed, and my function refuses to work any other way.

Here's everything a delivery goes through, in order:

Every check runs before anything is saved. The order matters as much as the checks.

Rule 1: signing is required, so fail closed

The first thing my function checks isn't the signature. It's whether it has a secret to check with.

ts
if (!secret) {
  return { status: 503, body: { error: 'Signing secret not set. Turn on signing for this webhook and add it as the BASE44_PARTNER_WEBHOOK_SECRET secret.' } };
}
if (!(await validSignature(raw, headers.get('x-base44-signature'), secret))) {
  return { status: 401, body: { error: 'Invalid signature.' } };
}

Looks obvious, right? Loads of webhook code does the opposite: verify if there's a secret, carry on if not. Even Stripe's first example endpoint wraps its check in if endpoint_secret. Fine for a quickstart. A trap in production.

Because one day the secret goes missing. Someone renames it, or the code reads it at the wrong moment (Base44's docs say secrets.get() returns undefined at module load). The check quietly switches itself off and every request gets in.

Fail open

  • No secret? Skip the check and save the lead.
  • A typo in the secret's name turns your security off without a sound.
  • You find out when the fake leads do.

Fail closed

  • No secret? Answer 503 and save nothing.
  • A missing secret breaks loudly, on the very first delivery.
  • Add the secret and the next delivery goes straight through.

Why 503 and not 401? A 401 says "you're not allowed in", and Base44 is. The problem is on my side, and a 503 ("I can't do this right now") is the honest answer.

Rule 2: check the raw bytes, then parse

An HMAC is a fingerprint of exact bytes. Change one space and the signature comes out completely different. So you check the body exactly as it arrived, before anything turns it into an object.

ts
export async function validSignature(raw: Uint8Array, header: string | null, secret: string) {
  if (!header) return false;
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const expected = `sha256=${hex(await crypto.subtle.sign('HMAC', key, new Uint8Array(raw)))}`;
  return sameString(header.trim().toLowerCase(), expected);
}

The handler reads the body once, as bytes, with new Uint8Array(await req.arrayBuffer()). Only after both checks pass does it call JSON.parse(new TextDecoder().decode(raw)). Web Crypto is built into Deno, so there's nothing to install.

The trap is await req.json(). Every tutorial uses it, and once you've called it, the original bytes are gone. Stringifying the object again can change spacing, key order and escaping. Stripe's troubleshooting page describes exactly this:

Some frameworks might edit the request body by doing things like adding or removing whitespace, reordering the key-value pairs, converting the string to JSON, or changing the encoding. All of these cases lead to a failed signature verification.

Stripe's docs, Resolve webhook signature verification errors

It's the same on Base44. Have a play: this runs the same kind of check in your browser, with made-up secrets. Try "Re-formatted JSON". Same data, different bytes, 401.

Interactive demo

Signature lab: does this delivery check out?

Base44 signs every Partner webhook delivery with HMAC-SHA256 over the exact bytes of the body, using the webhook's signing secret, and sends the result as X-Base44-Signature: sha256=<hex>. A receiving function signs the bytes it got with the secret it holds and compares the two.

Edit the body or the secret and the signature stops matching: a tampered body, a wrong secret or JSON that was parsed and re-stringified all fail with 401, a missing signature fails too, and a function with no secret set refuses everything with 503. It runs in the browser with Web Crypto and made-up secrets.

Rule 3: compare in constant time

That last line, sameString, is there on purpose:

ts
/** Constant-time comparison of two strings (lengths are checked first, like timingSafeEqual). */
function sameString(a: string, b: string) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

A plain === gives up at the first character that doesn't match. In theory, someone timing how long you take to say no learns how many characters they got right, and builds a valid signature one character at a time. Stripe's docs put it plainly: "To protect against timing attacks, use a constant-time-string comparison". My loop checks every character, every time.

Is anyone going to time my little function over the internet? Honestly, probably not. But it's six lines, and I'd rather not find out the hard way.

Rule 4: retries are normal, so de-duplicate

Webhooks get delivered more than once. A delivery can fail after your function already saved the lead (say it took longer than 10 seconds), and the retry brings the same X-Base44-Event-Id back. Skip this bit and one lead becomes two clients. So every delivery is logged as a PartnerEvent row, and the function checks that log first:

ts
const seen = await db.PartnerEvent.filter({ event_id: eventId }, '-created_date', 5);
if ((Array.isArray(seen) ? seen : []).some((e: Json) => e.outcome !== 'error')) {
  return { status: 200, body: { ok: true, outcome: 'duplicate' } };
}

Two details I like. A repeat gets a 200, not an error, because Base44 did nothing wrong and you want it to stop. And a logged error doesn't count as handled, so a failed save gets a proper second go.

Messages get the same treatment: each lands on the timeline once, matched by its message id. That covers replays too. A captured real delivery, sent again with its signature, would pass the HMAC check and then get skipped by the event log.

My delivery log, running locally with test data. The second New lead is a retry of the first, so it's marked Repeat and nothing is saved.

Rule 5: match the lead to a client you already have

People don't stick to one channel. Someone might use my contact form on Monday and message me on Base44 on Wednesday. That's one client, not two.

ts
async function findClient(db: Db, requestId: string, email: string) {
  if (requestId) {
    const rows = await db.Client.filter({ partner_request_id: requestId }, '-updated_date', 1);
    if (Array.isArray(rows) && rows[0]) return rows[0];
  }
  if (email) {
    const rows = await db.Client.filter({ email }, '-updated_date', 10);
    const match = (Array.isArray(rows) ? rows : []).find((c: Json) => !c.partner_request_id);
    if (match) return match;
  }
  return null;
}

The request id wins, because it's Base44's own id for the thread. Email comes second, and only for a client not tied to another thread yet, so each conversation keeps its own replies. No match means a new client: stage New lead, and a next step of "Reply on Base44" due today, London time. That puts it straight into my "Needs you" list.

Both tables are admin-only, and the function writes with the service role, because a webhook call has no signed-in user (Base44's docs say to use asServiceRole for direct HTTP calls). It's a stricter take on the letterbox pattern from my RLS post: nobody can touch the tables directly, and only a function with the service role can drop things in. The signature is the only thing that makes this function trustworthy.

What a new lead turns into, with test data: a client, their Base44 thread and a reply box that sends back through Base44.

Rule 6: a 5xx means "try again"

Status codes are how your function talks to Base44, so pick them on purpose:

  • 200 when it worked, or when you meant to ignore it (another event, another profile, a repeat). Nothing to retry.
  • 401 when the signature is wrong or missing. Real Base44 deliveries only hit this if your secret is wrong or signing is off.
  • 500 when the save itself failed. A retry might work, so ask for one.
ts
} catch (error) {
  const text = error instanceof Error ? error.message : String(error);
  await log('error', text);
  // 5xx: Base44 retries the delivery (and turns the webhook off after 20 failures in a row).
  return { status: 500, body: { error: 'Could not save the lead.', detail: clip(text, 300) } };
}

That comment matters. Base44 turns a webhook off after 20 failed deliveries in a row. Answer 500 for things a retry can never fix and you burn through those 20 for nothing. Paste the wrong secret and every real delivery gets a 401, same ending. Those 401s never reach my own log (the function stops before writing anything), so check the delivery list Base44 keeps on each webhook.

How I tested it

I didn't want to find bugs with real clients. So the logic lives in one function, handleDelivery, which takes the raw bytes, headers, secret and database as arguments. In tests, the database is a tiny in-memory fake.

That gave me 40 checks in one Deno script: missing and wrong signatures, a tampered body, no secret, retries, the same message under a new event id, email matching, and a database error followed by a retry that works. The reply side is covered too, including Base44's API answering 404 or 429. All 40 pass. Then I ran both functions' real entry points through the Base44 SDK against a mock API on localhost.

Then live. Once it was deployed, I tried to sneak in a fake lead:

Terminal
curl -s -X POST https://aatmanjain.com/functions/partnerWebhook -w " %{http_code}\n"{"error":"Invalid signature."} 401curl -s -X POST https://aatmanjain.com/functions/partnerWebhook -H "X-Base44-Signature: sha256=0000" -d '{"event":"contact_request.created"}' -w " %{http_code}\n"{"error":"Invalid signature."} 401curl -s https://aatmanjain.com/functions/partnerWebhook -w " %{http_code}\n"{"error":"POST only."} 405

That's what a stranger gets. No signature, no entry, nothing written. The 401 also proved the secret was being read: without it, the answer would have been a 503.

Last, the real thing. I pressed Send test on the webhook in the partner dashboard, and at 15:27 UTC a client called Test Client turned up in my pipeline, with the request id test_contact_request and an event id starting evt_test_.

My real Base44 Webhooks settings after Send test, with the empty middle of the dialog cut out.

Getting the functions deployed at all was a story of its own, and it gets its own post.

Replies go the other way

Quickly, since this post is about the way in. Replies go through a second function, partnerReply, which only admins can call. It uses a partner API key (they start with b44p_ and you see each one once), kept in Base44 secrets so it never reaches the browser. Base44 allows 60 requests a minute per key, and a key only reaches threads on its own profile.

The checklist

Wiring a webhook into a Base44 backend function? This is my list, and most of it works for Stripe too:

  1. Create the function

    base44/functions/<name>/entry.ts. Its URL is https://<your-app-domain>/functions/<name>, and anyone can call it.

  2. Turn signing on

    Leave Sign deliveries on when you add the webhook, and copy the secret straight away. Base44 shows it once.

  3. Store it as a secret

    Add it to your app's secrets, never to code or chat. Read it with secrets.get() inside the handler, not at module load.

  4. Fail closed

    No secret means 503 for everything. Never "skip the check if it's missing".

  5. Read raw bytes first

    await req.arrayBuffer(), check the HMAC, and only then JSON.parse. Don't call req.json() first.

  6. Compare in constant time

    Check every character, every time. No early exit.

  7. De-duplicate

    Log each event id and answer repeats with a 200. Add messages once, by message id.

  8. Write with the service role

    A webhook call has no user, so use asServiceRole, and keep the tables admin-only.

  9. Pick status codes on purpose

    200 for done or ignored, 401 for a bad signature, 5xx only when a retry could work.

  10. Answer fast

    Base44 gives you 10 seconds. Check, save, log, respond.

  11. Test the sad paths

    Fake the database, then try a tampered body, a wrong secret, a retry and a failed save.

  12. Prove it live

    An unsigned curl should get a 401. Then press Send test and check the row lands.

This took me longer than the CRM screens did, and nobody will ever see it. I'm fine with that. The best webhook is one I never think about, where every lead just turns up with "Reply on Base44" at the top of my day. Stuck on your own? Email hello@aatmanjain.com with what you've tried and I'll have a look.

FAQ

How do I verify a Base44 Partner webhook signature?

Work out the HMAC-SHA256 of the raw request body with your webhook's signing secret, hex-encode it and put sha256= in front. Compare that with the X-Base44-Signature header using a constant-time comparison, and do it before you parse the JSON. If the secret isn't set, refuse the request instead of skipping the check.

Why does my webhook signature check fail when the secret is right?

Usually because the body changed before you checked it. Calling req.json() and stringifying the result again can change spacing, key order or escaping, which gives a different signature. Read the body with req.arrayBuffer() and check those exact bytes.

How do I stop duplicate leads from webhook retries?

Log each delivery's X-Base44-Event-Id and skip any event id you've already handled, answering 200 so Base44 stops retrying. Add each message to the timeline only once, keyed by its message id. Don't count a failed attempt as handled, so a failed save still gets its retry.

Can a Base44 backend function receive webhooks?

Yes. Every deployed backend function gets an HTTP endpoint at https://<your-app-domain>/functions/<name>, and Base44's docs list webhooks as one of its uses. Those calls have no signed-in user, so the docs say to use asServiceRole for data access.

Keep reading

More posts on the same things.

Newsletter

Liked this one?

Get my Friday email: new posts, what I built and the Base44 and AI news worth knowing.

No spam. Unsubscribe whenever.

Got an app in mind?

I build and fix Base44 apps, and I'm looking for a remote internship. Tell me what you're working on.