Palbase
Sign inGet started

Backend SDK

Webhooks

Webhooks let a third-party service — Stripe, GitHub, Twilio and others — call into your backend when something happens on their side. Decorate a class with @Webhook, give it the name its URL is served at, bind its methods to event names with @On, list it in a module's providers, and the runtime handles the URL, the signature check and the dispatch. For a service with no built-in preset, spell the same scheme out yourself with signature. Verification always runs before your code: the bytes are read, the signature is checked, and only then is a handler looked up.

Quick example: Stripe

1. Store the signing secret. Copy the webhook signing secret (whsec_…) from the Stripe dashboard into the Environment's vault. palbase secret set takes one positional argument, so use the NAME=value form or — better, because it keeps the value out of your shell history — read it from stdin:

palbase secret set STRIPE_WEBHOOK_SECRET --stdin
# or: palbase secret set STRIPE_WEBHOOK_SECRET=whsec_xxx

Secrets belong to one Environment. A second Environment needs its own copy, and it is usually a different secret anyway, because it is registered with a different endpoint on the provider's side. See Secrets.

2. Define the webhook:

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

@Webhook({
  name: "stripe",                             // the path segment: /webhooks/stripe
  provider: "stripe",
  secret: { env: "STRIPE_WEBHOOK_SECRET" },   // the NAME of a secret, never the value
})
export class StripeWebhook {
  @On("checkout.session.completed")
  async checkoutCompleted(event: unknown, meta: WebhookMeta): Promise<void> {
    // The payload is `unknown` on purpose — narrow what you read, and nothing else.
    const session = (event as {
      data?: { object?: { client_reference_id?: string; amount_total?: string } };
    }).data?.object;
    if (!session?.client_reference_id) return;

    await Database.$asService().public.orders.insert({
      user_id: session.client_reference_id,
      status: "paid",
      amount: session.amount_total ?? "0",
    });
  }

  @On("invoice.payment_failed")
  async paymentFailed(event: unknown, meta: WebhookMeta): Promise<void> {
    // `meta` carries `requestId`, `environmentId` and the environment's env vars.
    Log.error(`payment failed (${meta.requestId})`, event);
  }
}

3. List it in the module that owns it. A @Webhook class no module lists is refused at build, by name — it would otherwise deploy and never be mounted:

// modules/billing/billing.module.ts
import { Module } from "@palbase/backend";

import { StripeWebhook } from "./stripe.webhook.ts";

@Module({
  controllers: [],
  providers: [StripeWebhook],   // surfaces go in `providers`
  exports: [],
  imports: [],
})
export class BillingModule {}

A webhook is built by the container like any other provider, so it may take constructor dependencies — mark it @Injectable() as well when it does, and list what it asks for in the same module.

4. Register the URL. The path is the name you declared; the Environment is selected by the host, and nothing in the path is ref-specific:

POST https://<ref>.palbase.studio/webhooks/stripe

A ref is nine characters — eight drawn from [a-z0-9], then the letter m — minted when the Environment is created, carrying no part of your project's name. palbase project list prints it. So a real URL looks like this:

https://k3xq81w4m.palbase.studio/webhooks/stripe

Paste that into the provider's settings, push, and authentic deliveries reach your handlers.

Note: Webhook endpoints take no API key — an external provider cannot send one. The provider's signature, or the signature scheme you spelled out, is the authentication, and a webhook that can express neither cannot be deployed at all. There is no user-supplied verification function.

Webhook URLs

PartValue
MethodPOST only
Hosthttps://<ref>.palbase.studio — this is what selects the Environment
Path/webhooks/<name>
<name>The name you declared on @Webhook: name: "stripe"/webhooks/stripe

Each Environment has its own ref and therefore its own webhook URLs — same path, different host. Point the provider's test mode at one Environment and its live mode at another; they are two independent Environments with two independent vaults, not two modes of one thing.

Warning: name is required and has no default — it used to be derived from the file name, which made a public URL a property of where a class happened to sit, so renaming a file silently changed the address a sender was configured with. Declaring it puts the identity beside the provider and the secret. It must be lowercase letters, digits and dashes, starting and ending with one of those, because it reaches a URL and a log line; anything else is refused at build, naming the value. Changing it still changes the public URL, so update the provider's configuration in the same change and expect deliveries to the old URL to answer 404 immediately after the push.

/webhooks is a reserved first path segment. It is matched before your controllers, so a controller route that resolved under it could never receive a request — @Controller("/webhooks/…") and @Controller("") with @Post("/webhooks/stripe") are both refused at build rather than silently shadowed. Only the first segment is reserved: /api/webhooks/stripe and /webhooksy are yours.

Provider presets

A provider preset carries the whole verification scheme for you.

type WebhookProvider =
  | "stripe" | "github" | "twilio" | "sendgrid"
  | "slack" | "discord" | "livekit";

@Webhook options:

OptionTypeDescription
namestringRequired. The path segment the endpoint is served at, /webhooks/<name>. Lowercase letters, digits and dashes.
providerWebhookProviderWhich provider's signature scheme to verify.
secret{ env: string }The name of the vault entry holding the verification value. The value is read from process.env at request time — the runtime mirrors the Environment's vault into it — so it never appears in your code or your repository.
signatureSignatureSpecAn explicit scheme, for a service with no preset. Mutually exclusive with provider.

What each preset checks, and where the @On key comes from:

ProviderHeader(s)Signed payloadEncodingEvent key from
stripestripe-signature (t=, v1=){t}.{body}, HMAC-SHA256hexJSON type
githubx-hub-signature-256 (sha256=)body, HMAC-SHA256hexx-github-event, plus . and the body's action when present
slackx-slack-signature (v0=) + x-slack-request-timestampv0:{ts}:{body}, HMAC-SHA256hexJSON type
twiliox-twilio-signaturethe URL plus sorted form key/value pairs, HMAC-SHA1base64form field EventType
livekitauthorization: Bearer <token>token compared against the hex HMAC-SHA256 of the bodyJSON event

Three of them are not HMAC at all, and for those secret.env holds a public key rather than a shared secret:

ProviderHeader(s)Verificationsecret.env holds
discordx-signature-ed25519 + x-signature-timestampEd25519 over ts + bodythe application's hex Ed25519 public key
sendgridx-twilio-email-event-webhook-signature + …-timestampECDSA P-256 / SHA-256 over ts + bodythe base64 SPKI public key

discord's event key is its numeric type field: 1 maps to PING, 2 to INTERACTION_CREATE, and anything else to the literal type_<n>. sendgrid always yields the single key event_batch, because it posts events in arrays — register one event_batch handler and iterate the batch inside it rather than writing per-event-type handlers.

Note: Every scheme that carries a timestamp also enforces a replay window of 300 seconds. A delivery older than five minutes is refused before its signature is even compared.

Handling events with @On

Bind one method to one event name with @On("event.name"); a webhook needs at least one, and declaring the same event twice on one class refuses the build. @On takes any string — the keys above are what each provider commonly sends, not an enforced set, so you can subscribe to an event this page does not list without any extra step.

Services without a built-in preset

For a service with no preset, describe the same HMAC scheme with signature:

// modules/acme/acme.webhook.ts
import { Log, On, Webhook, type WebhookMeta } from "@palbase/backend";

@Webhook({
  name: "acme",
  secret: { env: "ACME_WEBHOOK_SECRET" },
  signature: {
    header: "X-Acme-Signature",
    prefix: "sha256=",
    algo: "hmac-sha256",
    encoding: "hex",
    signs: "{body}",
  },
})
export class AcmeWebhook {
  // The event key of a `signature` webhook is the body's top-level `type`.
  @On("order.created")
  async orderCreated(event: unknown, meta: WebhookMeta): Promise<void> {
    Log.info("acme order created", event);
  }
}

SignatureSpec:

FieldTypeDescription
headerstringThe header carrying the signature.
prefixstring (optional)Stripped from the header value before comparison, e.g. sha256=. Omit when the provider sends the raw signature.
algo"hmac-sha256" | "hmac-sha1"HMAC algorithm.
encoding"hex" | "base64"How the signature is encoded in the header.
signsstringTemplate for what the HMAC is computed over. Exactly two placeholders are recognised, {body} and {ts}; everything else is literal. Must contain {body}.
timestampHeaderstring (optional)The header carrying the timestamp. Required when signs contains {ts}, which also brings the 300-second replay window.

Warning: A signature webhook takes its @On key from the body's top-level type field, and from nothing else. The presets each name events their own way, but a spelled-out scheme has no catalog to consult, so the runtime falls back to type. A body that carries event but no type produces no event key at all: the delivery verifies, no handler matches, and it is acknowledged with 200 {"ok":true,"handled":false} — a webhook that looks healthy from both ends and does nothing. If the service you are integrating names its events in another field, map it inside a single @On handler keyed on the value of type, or ask them for a type.

signature is an escape hatch, not a way to express every scheme: it covers HMAC over a template only.

signs templateProviders with this shape
{body}GitHub, LiveKit
{ts}.{body}Stripe
v0:{ts}:{body}Slack

Twilio (URL plus sorted form parameters, not a raw-body HMAC), Discord (Ed25519) and SendGrid (ECDSA) do not fit the template at all and remain named presets only — use provider for those three.

Warning: @Webhook requires exactly one of provider or signature. Declaring neither refuses the build, because an unverified webhook endpoint can no longer be expressed — secret with no way to check it was worth removing. Declaring both refuses the build too: only provider would run, so the signature beside it would be a check that never executes and nobody outside could tell which one guarded the endpoint.

Note: The runtime also implements the Standard Webhooks scheme — webhook-signature, webhook-id, webhook-timestamp, signing {id}.{ts}.{body} — but "standard" is not in the SDK's WebhookProvider union, so provider: "standard" is a TypeScript error today. Until the union widens, describe that scheme with an explicit signature block.

WebhookMeta

Every handler's second argument. What the runtime actually passes is smaller than the type suggests:

FieldTypePopulated?
requestIdstringYes — a per-delivery id, for log correlation.
envRecord<string, string>No. Declared by the type, undefined at run time.
environmentIdstringNo. Declared by the type, undefined at run time.

The runtime constructs the meta itself as { requestId, event }, where event is the resolved event key — useful when one method is bound to several names. Read configuration with Secrets.get(name) or process.env.<NAME> instead of meta.env; both work in a webhook handler.

Services are not on meta. The platform services — Database, Log, Notifications, Storage, and the rest — are ambient: import them from @palbase/backend as in any other handler, rather than asking for them in the constructor. Since 2026-08-25 webhook handlers run inside the request scope, which is what makes those ambient services resolve at all; before that a handler died on its first Database call with "Palbase services accessed outside a request scope".

Note: A webhook has no signed-in user, so owner-scoped Row-Level Security matches nothing. Use Database.$asService(), as in the examples above.

Response behaviour

What the provider sees:

SituationResponse
Verified, handler returned200 {"ok":true,"handled":true}
Verified, handler threw500 {"error":"handler_failed","error_description":"the handler for this event failed"} — the provider retries
Verified, but no @On matches the arriving event200 {"ok":true,"handled":false} — logged with the event key that arrived
Bad or missing signature, stale timestamp, or unset secret401 {"error":"unauthorized","error_description":"webhook signature rejected"}
No webhook deployed under that name404 {"error":"not_found","error_description":"no webhook named \"<name>\""}

Two of those rows are worth reading twice.

The 401 is deliberately opaque. Missing signature, bad signature, stale timestamp, an unsupported scheme and an unset secret all collapse into the same sentence, because the difference between "no secret configured" and "signature did not match" is exactly the difference an attacker wants. The real reason goes to your log, one line per refusal:

[runtime] webhook stripe: refused (bad_signature)

The runtime also checks at boot and names what is missing, so an unset secret does not have to be diagnosed one failed delivery at a time:

[runtime] webhook stripe: the secret STRIPE_WEBHOOK_SECRET is not set — every delivery will be refused. Put it in the vault: PUT /admin/vault/backend/STRIPE_WEBHOOK_SECRET

Use palbase secret list in a checkout linked to that project to see which names the environment holds — never the values — with --env <name> for the environment that logged the line. Another project is reached by linking a checkout to it.

A throwing handler answers 5xx on purpose. The provider's own retry is this design's durability story, because there is no queue on this side. That cuts both ways: an event is not lost when your handler fails, and a handler that fails consistently produces a retry storm and, with some providers, a disabled endpoint. So keep handlers short — validate, persist, return — and let a scheduled job do the work:

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

// Acknowledge fast, fulfil later: the row is the durable thing.
@Webhook({
  name: "stripe-fulfilment",
  provider: "stripe",
  secret: { env: "STRIPE_WEBHOOK_SECRET" },
})
export class FulfilmentWebhook {
  @On("checkout.session.completed")
  async checkoutCompleted(event: unknown, meta: WebhookMeta): Promise<void> {
    const ref = (event as { data?: { object?: { client_reference_id?: string } } })
      .data?.object?.client_reference_id;
    if (!ref) return;

    await Database.$asService().public.orders.insert({
      user_id: ref,
      status: "pending_fulfilment",
      amount: "0",
    });
  }
}

Note: There is no body-size cap on this path. The runtime reads the whole delivery into memory before verifying it, because every provider signs the exact octets it sent and a re-serialisation would break every signature. Nothing in front of it caps /webhooks either.

What ships

A @Webhook class travels because a module lists it, and for no other reason. Discovery is not a directory: palbase push walks the project for *.module.ts files, builds the container they describe, and the bundle's webhook export is that container's @Webhook classes. So the file lives wherever the domain that owns it lives — modules/billing/stripe.webhook.ts — and nothing about its path decides anything.

That replaced two silent failures at once. @Controller pushes itself into a registry as it decorates; @Webhook does not, it only stamps metadata, so while a directory glob was the discovery a class carrying @Webhook outside that folder never runs, and one inside it ran whether or not anything claimed it. Nothing reads such a directory today. Both are now the same question, with one answer, and a class no module lists is refused at build:

OrphanWebhook is decorated as an entry point but listed in no module, so nothing
decides whether it should be served.
  → add it to a module's `controllers` (for @Controller) or `providers`
    (for @Room/@Job/@Hook/@Webhook)

Webhooks travel as code only — unlike jobs and hooks they need no manifest, because a webhook's URL is the name on its decorator and its verification lives in the class.

my-project/
├── db/public.ts
└── modules/
    └── billing/
        ├── billing.module.ts     ← providers: [StripeWebhook, AcmeWebhook]
        ├── stripe.webhook.ts     ← @Webhook({ name: "stripe", provider: "stripe", … })
        └── acme.webhook.ts       ← @Webhook({ name: "acme", signature: { … } })

The build prints what the bundle carries, read out of the container rather than off the disk:

bundled 2 webhook(s) → /webhooks/{stripe, acme}

and at boot the runtime prints what it mounted, which is the fastest confirmation that a deploy carried them:

[runtime] webhooks: stripe, acme
  • Secrets — the vault a signing secret lives in, and how it reaches process.env
  • Scheduled Jobs — do the real work behind a fast acknowledgement
  • Event Hooks — react to the events this stack raises, instead of a third party's
  • Responses & Errors — error handling in ordinary request handlers
  • Deploying — the push gate that makes a declared webhook real
  • Introduction — refs, addresses, and why each Environment needs its own secret