Backend SDK

Webhooks

Stripe, GitHub, Slack and the rest tell your app what happened by POSTing to a URL you give them. A @Webhook class receives those deliveries at POST /webhooks/<name> on your machine: the framework checks each one's signature against the bytes as they arrived, under a secret it reads from the machine's environment, and hands it to the @On method of its event type. A delivery that is not signed with that secret never reaches your code.

This page is about a third party's deliveries. The platform's own events (user.deleted, file.uploaded, …) arrive at /webhooks/palbase and are handled by @On on an ordinary provider class: see Event Hooks. A server that is not a palbase/backend app receives the platform's events with verifyWebhook: see Server SDK: Webhooks.

Quick example

// src/modules/billing/stripe.webhook.ts
import { Database, Log, On, Webhook, type WebhookMeta } from "palbase/backend";

interface StripeEvent<T> {
  id: string;
  type: string;
  data: { object: T };
}

interface Invoice {
  id: string;
  customer: string;
  amount_paid: number;
}

@Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } })
export class StripeWebhook {
  @On("invoice.paid")
  async invoicePaid(event: StripeEvent<Invoice>, meta: WebhookMeta): Promise<void> {
    const invoice = event.data.object;
    // A delivery runs as no user: what it writes, it writes as the service.
    await Database.$asService().$query("update accounts set paid_until = now() + interval '1 month' where stripe_customer = $1", [invoice.customer]);
    Log.info("invoice paid", { invoice: invoice.id, delivery: meta.requestId });
  }

  @On("customer.subscription.deleted")
  async cancelled(event: StripeEvent<{ customer: string }>): Promise<void> {
    await Database.$asService().$query("update accounts set paid_until = null where stripe_customer = $1", [event.data.object.customer]);
  }
}
// src/modules/billing/billing.module.ts
import { Module } from "palbase/backend";
import { StripeWebhook } from "./stripe.webhook";

@Module({ providers: [StripeWebhook] })
export class BillingModule {}

Then, in Stripe's dashboard, add an endpoint at https://<ref>.app.palbase.studio/webhooks/stripe for invoice.paid and customer.subscription.deleted, and copy its signing secret (whsec_…) into the environment's secrets as STRIPE_WEBHOOK_SECRET (The secret). From the next deploy on, a paid invoice runs invoicePaid.

@Webhook(options)

OptionWhat it is
nameThe last segment of the URL, /webhooks/<name>: what the sender is configured with. Lowercase letters, digits and dashes, starting and ending with a letter or digit. Not palbase, which is the platform's own. Two @Webhook classes of one app cannot share a name.
providerA sender whose signature scheme the framework knows: stripe, github, slack, discord, twilio, sendgrid or livekit (Providers).
signatureA scheme spelled out, for any other sender (A signature of your own).
secret{ env: "VAR_NAME" }: the environment variable that holds the signing secret. Its name, never its value.

A webhook takes exactly one of provider and signature: one with neither would take forged deliveries, and one with both would verify with one and leave the other unread. It needs at least one @On, and an event type is taken by one method. Every one of these mistakes throws where the class is declared.

The class is a provider: its module lists it in providers, and it takes its dependencies by constructor, as a controller does (Modules & Dependency Injection). It needs no @Injectable().

Providers

Each provider's scheme is the one its own documentation describes. What it is checked with, what the framework reads as the event's type (the name an @On takes), and what it remembers a delivery by (Duplicates and replays):

providerSignatureThe secret isEvent typeRemembered bySender's largest body
stripeStripe-Signature: HMAC-SHA256 of "<t>.<body>", any of its v1s; t within five minutesthe endpoint's signing secret (whsec_…)the body's type: invoice.paidthe body's id512 KiB
githubX-Hub-Signature-256: sha256= HMAC-SHA256 of the bodythe webhook's secretX-GitHub-Event (unsigned, see below) and the body's action: pull_request.opened; the event alone when there is no action: push, pingX-GitHub-Delivery with the body's SHA-25625 MB
slackX-Slack-Signature: v0= HMAC-SHA256 of "v0:<ts>:<body>"; X-Slack-Request-Timestamp within five minutesthe app's signing secretthe body's type: url_verification, event_callback; an interactivity form's payload.type: block_actions, view_submission; "" for a slash commandthe body's event_id64 KiB
discordX-Signature-Ed25519: Ed25519 of "<X-Signature-Timestamp><body>"; the timestamp within five minutesthe application's public key (64 hex characters)PING for type 1, INTERACTION_CREATE for type 2, type_<n> for any otherthe body's id64 KiB
twilioX-Twilio-Signature: base64 HMAC-SHA1 of the URL Twilio called and its signed parameters; no timethe account's auth tokenthe EventType parameter; "" when there is none (a plain incoming message or call)I-Twilio-Idempotency-Token with the SHA-256 of the URL and parameters signed64 KiB
sendgridX-Twilio-Email-Event-Webhook-Signature: ECDSA P-256 of "<timestamp><body>"; the timestamp within five minutesthe Event Webhook's verification key (the base64 public key SendGrid shows)always event_batcheach event's sg_event_id1 MiB
livekitAuthorization: a JWT signed HS256 with the API secret, unexpired (with a minute's leeway), whose sha256 claim is the body's hashthe API secretthe body's event: room_startedthe body's id512 KiB

A delivery's body is capped at the smaller of its sender's largest (the last column) and the app's own bodyLimit (PalbaseFactory.create(…, { bodyLimit }), 1 MiB unless you set it). With the default, a Stripe, LiveKit, Slack, Discord or Twilio delivery is held to what that sender sends, and a GitHub or SendGrid one to 1 MiB: an app that takes GitHub's larger pushes raises its bodyLimit, which raises it for every route too. A body over the cap is refused 413 payload_too_large, before more of it is read than the cap.

The secrets of discord and sendgrid are public keys, which cannot forge a delivery. One that is not a key of the right kind stops the boot, naming the variable.

Twilio signs the URL it called. The framework rebuilds it as https:// with the host the request came to, its path and its query, so give Twilio the machine's address exactly as it serves it. It tries the URL with and without the port, as Twilio's own library does. A form body's parameters, decoded and sorted, are part of what is signed, and the handler is given those same parameters: { From: "+1555…", Body: "hi", … }, a name sent twice as an array. A JSON body is signed through the bodySHA256 parameter of the URL instead.

Twilio's signed string has nothing between a name and its value, or between one parameter and the next. So a captured request can be split again into other parameters that verify just the same (Body=HiFrom+1555 for Body=Hi&From=+1555). Twilio's own libraries accept such a request too, and no check can tell it from the original. A Twilio handler checks what it acts on, such as a MessageSid or CallSid it can look up, before trusting the parameters' split.

GitHub's event name is not signed. X-GitHub-Event is a header, outside what GitHub's signature covers, so whoever holds a GitHub delivery can send it again under another event's name, to that event's @On. GitHub signs nothing else that names the event, and every receiver routes on that header, Octokit's included. A GitHub handler checks that the payload is the one it takes: a pull_request has a pull_request, a push has its ref and commits.

Slack's form or JSON is read from the signed bytes. A body that is not JSON but is a form with a payload field is an interactivity payload, whatever its Content-Type, because no signature covers that header.

A signature of your own

A sender with no preset is described by its scheme: an HMAC over a template of the body and, optionally, a timestamp.

// src/modules/shipping/carrier.webhook.ts
import { Log, On, Webhook } from "palbase/backend";

interface Shipment {
  type: string;
  tracking_number: string;
  status: string;
}

@Webhook({
  name: "carrier",
  signature: {
    header: "x-carrier-signature",
    prefix: "sha256=",
    algo: "hmac-sha256",
    encoding: "hex",
    // What the HMAC covers: {body} is the bytes as they arrived, {ts} the timestamp header.
    signs: "{ts}.{body}",
    timestampHeader: "x-carrier-timestamp",
  },
  secret: { env: "CARRIER_WEBHOOK_SECRET" },
})
export class CarrierWebhook {
  @On("shipment.delivered")
  delivered(event: Shipment): void {
    Log.info("delivered", { tracking: event.tracking_number });
  }
}
signature fieldWhat it is
headerThe header the signature arrives in. A delivery without it is refused before its body is read.
prefixText before the signature in that header (sha256=): stripped before comparing. A header without it is refused.
algohmac-sha256 or hmac-sha1.
encodinghex or base64.
signsThe template the HMAC covers. It must contain {body}; {ts} puts in the timestamp header's value; everything else is literal text.
timestampHeaderWhere {ts} is read. With {ts} in the template, the timestamp is whole seconds and must be within five minutes of the machine's clock.

The event's type is the body's type. A body may be as large as the app's own bodyLimit (1 MiB unless you set it): there is no sender's largest to hold it to. A spelled-out scheme names no delivery id, so the framework cannot recognise a delivery sent again: every one runs. Without {ts} in signs, a captured delivery also verifies whenever it is sent again, at any time (Duplicates and replays). Use the sender's timestamp when it sends one.

The secret

secret: { env } names an environment variable, and the framework reads it when the app is built. Keep it with the project's other secrets:

  1. Environment › Variables, Add secret: the name you gave env (STRIPE_WEBHOOK_SECRET), and the sender's secret as its value.
  2. Cloud › Machines, the machine, Edit settings: start it with the set that holds it (Secrets: Production secrets). A machine already started with it rolls out with the new value.
  3. List the name in the machine's .env.example. A build whose variables the machine does not have waits for them instead of rolling out an app that cannot start (Builds, Logs & Rollback).

An app whose webhook's variable is unset, or does not hold a key its provider can use, does not start, and says which:

[palbase] the app cannot start:
  - @Webhook "stripe" (StripeWebhook) reads its signing secret from STRIPE_WEBHOOK_SECRET, which is not set

This is not PALBASE_WEBHOOK_SECRET. That one is the platform's endpoint secret for its own events; an app with @Webhook classes and no platform @On does not need it.

Events and @On

Inside a @Webhook class, @On("<type>") takes that sender's events, under whatever name the sender gives them: invoice.paid, pull_request.opened, PING, block_actions. (Outside one, @On takes the platform's events, whose types are dotted lowercase words; Event Hooks.) A handler is called as (event, meta):

ArgumentWhat it is
eventThe body's JSON. A body that is not JSON is given as its text. For Slack's interactivity (a form of one field, payload=<json>), the parsed payload. For Twilio's form, the signed parameters.
meta.requestIdThe delivery's request id: the app's log lines of it carry it.
meta.envThe app's environment as it started, without PALBASE_SECRET_KEY, PALBASE_WEBHOOK_SECRET and PALBASE_PLATFORM_SECRET.
meta.environmentIdThe project's id.
// src/modules/slack/slack.webhook.ts
import { Log, On, Webhook } from "palbase/backend";

@Webhook({ name: "slack", provider: "slack", secret: { env: "SLACK_SIGNING_SECRET" } })
export class SlackWebhook {
  // Slack checks the URL once when it is set, and expects the challenge back.
  @On("url_verification")
  verify(event: { challenge: string }): { challenge: string } {
    return { challenge: event.challenge };
  }

  @On("event_callback")
  message(event: { event: { type: string; user?: string; text?: string } }): void {
    Log.info("slack event", { type: event.event.type, user: event.event.user });
  }

  // A button: Slack's interactivity form, its payload already parsed.
  @On("block_actions")
  clicked(event: { user: { id: string }; actions: { action_id: string }[] }): void {
    Log.info("clicked", { user: event.user.id, action: event.actions[0]?.action_id });
  }
}

A delivery whose type no @On takes is acknowledged with 200 { ok: true, handled: false } and runs nothing: answering an error would make the sender retry it, and some senders turn off an endpoint that keeps failing.

@On("") takes the deliveries that name no type. Two senders' deliveries name none:

  • Twilio's plain incoming message or call: a form with no EventType. The handler is given its signed parameters.
  • A Slack slash command: a form with no payload. The handler is given the form's text, which new URLSearchParams(event) reads.

@On("") is a @Webhook's alone. On any other class, @On takes the platform's events, which always name their type, and an empty one stops the boot.

A SendGrid delivery is a batch: its one event type is event_batch, and the handler is given the array of events.

The event is the sender's data: the database takes values from it, never a filter's structure (Database). A delivery runs inside one database transaction, as no user (anon), as the platform's events do: what it writes, it writes through Database.$asService(). All of it commits when the handler returns, or none of it when it throws.

What a handler returns

What the handler returns is what the sender gets:

The handler returnsThe sender gets
nothing (undefined or null)200 { ok: true, handled: true }
a Responsethat response, as it is: its status, headers and body
a string200 with it as the body: text/xml for twilio (TwiML), text/plain for any other
anything else200 with it as JSON: Slack's { challenge }, Discord's { type: 1 } to a PING
// src/modules/discord/discord.webhook.ts
import { On, Webhook } from "palbase/backend";

@Webhook({ name: "discord", provider: "discord", secret: { env: "DISCORD_PUBLIC_KEY" } })
export class DiscordWebhook {
  // Discord pings the URL when it is set, and keeps it only if it answers a PONG.
  @On("PING")
  ping(): { type: number } {
    return { type: 1 };
  }

  @On("INTERACTION_CREATE")
  interaction(event: { data?: { name?: string } }): { type: number; data: { content: string } } {
    return { type: 4, data: { content: `You ran /${event.data?.name ?? "?"}` } };
  }
}

A Twilio number's incoming messages carry no EventType, so @On("") takes them, and a string it returns goes back to Twilio as TwiML (text/xml):

// src/modules/sms/sms.webhook.ts
import { Log, On, Webhook } from "palbase/backend";

interface IncomingSms {
  MessageSid: string;
  From: string;
  Body: string;
}

@Webhook({ name: "sms", provider: "twilio", secret: { env: "TWILIO_AUTH_TOKEN" } })
export class SmsWebhook {
  // A plain incoming message names no event type: "" takes it.
  @On("")
  incoming(event: IncomingSms): string {
    Log.info("sms in", { sid: event.MessageSid, from: event.From });
    return '<?xml version="1.0" encoding="UTF-8"?><Response><Message>Thanks, we got it.</Message></Response>';
  }
}

Give Twilio https://<ref>.app.palbase.studio/webhooks/sms as the number's A message comes in webhook, method POST.

What the sender sees

The sender decides from the status whether to deliver again, so every answer says what happened:

StatusWhenThe sender
200Handled; or no @On takes its type (handled: false); or a delivery already run (handled: false, duplicate: true)stops
401 invalid_signatureThe signature does not check: its error_description ends with missing_signature, malformed_signature, stale_timestamp or signature_mismatchretries, and a forger gets nowhere
405 method_not_allowedNot a POST—
409 in_progressThe same delivery (by its id) is being handled right now; Retry-After: 5delivers it again
413 payload_too_largeThe body is over its cap: the smaller of what that sender sends and the app's bodyLimit (Providers)—
500 handler_failedThe handler threwdelivers it again, later
503What the handler needed of the platform did not answer: auth_unavailable, service_unavailable (a module call that answered 429, 502, 503 or 504, or never arrived), or the database's database_unavailable; with Retry-Afterdelivers it again, after the wait

A delivery needs no API key and no user token: the signature is its only credential, and nothing else is checked. A missing signature header is refused before a byte of the body is read.

The reason for a failure stays in the app's log, under the request id: the 500 says only The event could not be handled, because a sender may show its answers to anyone who can open its dashboard. Refusals are counted, and the counts logged at most once a minute per webhook, so a stream of forged deliveries cannot fill the log.

Duplicates and replays

Senders deliver at least once: a delivery whose answer was lost is sent again. The framework remembers the deliveries it ran, up to the last 10,000 per webhook, and acknowledges one sent again with duplicate: true instead of running it twice. What it remembers a delivery by comes from what the sender signed, never from a header alone:

SenderRemembered byA captured delivery sent again by someone else
Stripe, Slack, Discord, LiveKit, SendGridthe id inside the signed body (id, event_id, each event's sg_event_id), when it has one: a Slack interactivity payload or url_verification has noneis not run again within five minutes, and is refused after: they sign a time
GitHubX-GitHub-Delivery with the SHA-256 of the bodyis run again under a fresh X-GitHub-Delivery: GitHub signs neither a time nor an id
TwilioI-Twilio-Idempotency-Token with the SHA-256 of the URL and parameters signedis run again under a fresh token: Twilio signs neither a time nor an id
a spelled-out signaturenothingis run again every time, and without {ts} it verifies whenever it is sent

So the sender's own retry is run once: GitHub keeps the delivery's GUID on a redelivery, and Twilio's token is there to tell its retries apart. A delivery replayed under another id by whoever holds a copy of it, from a log or a proxy, is run again for GitHub, Twilio and a template without {ts}. For a SendGrid batch the framework keeps the events it has not run and hands the handler those alone.

That memory is one copy's, in its memory. Another copy of the machine, or the same one after a restart, runs a delivery again. So:

  • Write handlers to be idempotent. Key what they write on the event's own ids (invoice.id, a MessageSid, a payload's ids) with a unique constraint or an upsert, so a second run changes nothing.
  • Check what a handler acts on. For GitHub and Twilio, a delivery's authenticity does not say it is new, and their event name or parameters are not wholly signed (Providers). Look up the object the event names before you act on it.

The contract

A @Webhook is part of the app's contract — written by its build, kept by the platform with it — which Studio's backend screens read: POST /webhooks/<name>, with x-palbase-webhook saying who sends it (provider, or the signatureHeader of a spelled-out scheme), the variable its secret is read from (secretEnv, the name alone) and the event types its @On take (events).

The webhooks path segment

webhooks is the platform's first path segment, never a controller's. @Controller refuses a base path that starts with it, the app does not start with a route whose path begins with it, and a request to a /webhooks/<name> no @Webhook has answers 404. /webhooks/palbase is the platform's own events (Event Hooks); every other /webhooks/<name> belongs to the @Webhook of that name.

Testing

A delivery is a signed POST, so a test signs one the way the sender does and hands it to the app. With the webhook's secret in the app's env:

// src/modules/billing/stripe.webhook.test.ts
import { createHmac } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { Module, On, PalbaseFactory, Webhook, type PalbaseApplication } from "palbase/backend";

const seen: string[] = [];

@Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } })
class TestStripeWebhook {
  @On("invoice.paid")
  paid(event: { id: string }): void {
    seen.push(event.id);
  }
}

@Module({ providers: [TestStripeWebhook] })
class TestModule {}

const secret = "whsec_test_0123456789";
const env = {
  PALBASE_URL: "https://abcd1234.palbase.studio",
  PALBASE_PROJECT_ID: "11111111-2222-3333-4444-555555555555",
  PALBASE_SECRET_KEY: "pb_abcd1234_s0123456789abcdef0123456789abcdef",
  STRIPE_WEBHOOK_SECRET: secret,
};

/** A delivery as Stripe signs one: HMAC-SHA256 of "<t>.<body>". */
function stripeDelivery(event: { id: string; type: string }, key = secret): Request {
  const body = JSON.stringify(event);
  const t = Math.floor(Date.now() / 1000);
  const v1 = createHmac("sha256", key).update(`${t}.${body}`).digest("hex");
  return new Request("http://app.test/webhooks/stripe", { method: "POST", body, headers: { "stripe-signature": `t=${t},v1=${v1}` } });
}

let app: PalbaseApplication;
beforeAll(async () => {
  app = await PalbaseFactory.create(TestModule, { env, port: 0, host: "127.0.0.1", exitOnFatal: false });
});
afterAll(() => app.close());

describe("the Stripe webhook", () => {
  it("runs a paid invoice once, however often it is delivered", async () => {
    expect((await app.handle(stripeDelivery({ id: "evt_1", type: "invoice.paid" }))).status).toBe(200);
    const again = await app.handle(stripeDelivery({ id: "evt_1", type: "invoice.paid" }));
    expect(await again.json()).toMatchObject({ handled: false, duplicate: true });
    expect(seen).toEqual(["evt_1"]);
  });

  it("refuses a delivery signed with another secret", async () => {
    const res = await app.handle(stripeDelivery({ id: "evt_2", type: "invoice.paid" }, "whsec_someone_else"));
    expect(res.status).toBe(401);
    expect(await res.json()).toMatchObject({ error: "invalid_signature" });
  });
});

getWebhookManifest(StripeWebhook) reads what a class declares (its name, provider, secretEnv and events) without building it, for a test that pins the wiring. See Testing for the rest of a backend's tests.