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
signaturescheme 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
| Part | Value |
|---|---|
| Method | POST only |
| Host | https://<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:
nameis 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 answer404immediately 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:
| Option | Type | Description |
|---|---|---|
name | string | Required. The path segment the endpoint is served at, /webhooks/<name>. Lowercase letters, digits and dashes. |
provider | WebhookProvider | Which 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. |
signature | SignatureSpec | An explicit scheme, for a service with no preset. Mutually exclusive with provider. |
What each preset checks, and where the @On key comes from:
| Provider | Header(s) | Signed payload | Encoding | Event key from |
|---|---|---|---|---|
stripe | stripe-signature (t=, v1=) | {t}.{body}, HMAC-SHA256 | hex | JSON type |
github | x-hub-signature-256 (sha256=) | body, HMAC-SHA256 | hex | x-github-event, plus . and the body's action when present |
slack | x-slack-signature (v0=) + x-slack-request-timestamp | v0:{ts}:{body}, HMAC-SHA256 | hex | JSON type |
twilio | x-twilio-signature | the URL plus sorted form key/value pairs, HMAC-SHA1 | base64 | form field EventType |
livekit | authorization: Bearer <token> | token compared against the hex HMAC-SHA256 of the body | — | JSON event |
Three of them are not HMAC at all, and for those secret.env holds a public key rather than a shared secret:
| Provider | Header(s) | Verification | secret.env holds |
|---|---|---|---|
discord | x-signature-ed25519 + x-signature-timestamp | Ed25519 over ts + body | the application's hex Ed25519 public key |
sendgrid | x-twilio-email-event-webhook-signature + …-timestamp | ECDSA P-256 / SHA-256 over ts + body | the 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:
| Field | Type | Description |
|---|---|---|
header | string | The header carrying the signature. |
prefix | string (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. |
signs | string | Template for what the HMAC is computed over. Exactly two placeholders are recognised, {body} and {ts}; everything else is literal. Must contain {body}. |
timestampHeader | string (optional) | The header carrying the timestamp. Required when signs contains {ts}, which also brings the 300-second replay window. |
Warning: A
signaturewebhook takes its@Onkey from the body's top-leveltypefield, 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 totype. A body that carrieseventbut notypeproduces no event key at all: the delivery verifies, no handler matches, and it is acknowledged with200 {"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@Onhandler keyed on the value oftype, or ask them for atype.
signature is an escape hatch, not a way to express every scheme: it covers HMAC over a template only.
signs template | Providers 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:
@Webhookrequires exactly one ofproviderorsignature. Declaring neither refuses the build, because an unverified webhook endpoint can no longer be expressed —secretwith no way to check it was worth removing. Declaring both refuses the build too: onlyproviderwould run, so thesignaturebeside 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'sWebhookProviderunion, soprovider: "standard"is a TypeScript error today. Until the union widens, describe that scheme with an explicitsignatureblock.
WebhookMeta
Every handler's second argument. What the runtime actually passes is smaller than the type suggests:
| Field | Type | Populated? |
|---|---|---|
requestId | string | Yes — a per-delivery id, for log correlation. |
env | Record<string, string> | No. Declared by the type, undefined at run time. |
environmentId | string | No. 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:
| Situation | Response |
|---|---|
| Verified, handler returned | 200 {"ok":true,"handled":true} |
| Verified, handler threw | 500 {"error":"handler_failed","error_description":"the handler for this event failed"} — the provider retries |
Verified, but no @On matches the arriving event | 200 {"ok":true,"handled":false} — logged with the event key that arrived |
| Bad or missing signature, stale timestamp, or unset secret | 401 {"error":"unauthorized","error_description":"webhook signature rejected"} |
| No webhook deployed under that name | 404 {"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
/webhookseither.
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
Related
- 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