Server SDK

Webhooks

When something happens in a project — a user is deleted, a file is uploaded, a secret changes, an email bounces — the platform POSTs an event to each of the project's webhook endpoints that takes its type, signed with that endpoint's secret. palbase/server checks the signature: verifyWebhookRequest(request, secret) answers the event, or throws. A palbase/backend app does not call it at all — its @On handlers receive the events already checked (see Event Hooks).

Quick example

// server.ts
import { verifyWebhookRequest, WebhookSignatureError } from "palbase/server";

const secret = process.env.PALBASE_WEBHOOK_SECRET!; // whsec_…, shown once when the endpoint was made
const handled = new Set<string>();

export async function handle(request: Request): Promise<Response> {
  let event;
  try {
    event = await verifyWebhookRequest<{ user_id?: string }>(request, secret);
  } catch (e) {
    if (e instanceof WebhookSignatureError) return new Response(e.code, { status: 401 });
    throw e;
  }
  // A delivery may come twice: the event's id says it is the same one.
  if (handled.has(event.id)) return new Response(null, { status: 204 });
  if (event.type === "user.deleted") {
    console.log(`forget everything about ${event.data.user_id}`);
  }
  handled.add(event.id);
  return new Response(null, { status: 204 });
}

Any 2xx is delivery. Anything else — another status, a timeout, a refused connection — and the platform sends the event again.

Endpoints

An endpoint is an https URL on the public internet and the event types it takes — up to 50 of them, each dotted lowercase words such as user.deleted; an endpoint that names none takes every type. A project has 5 endpoints at most, besides the ones the platform keeps for its machines. When an endpoint is made, its secret (whsec_ and 64 hex characters) is answered once; keep it in the receiving server's secrets.

Manage a project's endpoints in Studio, under an environment's Settings › Webhooks: add an endpoint (its secret is shown once, with a copy button), change its address or events, or delete it. Reading the list is open to the organization's members; adding, changing and deleting are an admin's — a person, or an admin access token. Each endpoint shows who made it: a person, an access token or an environment token, by the name it had then. An endpoint stays when the token that made it is deleted. Palbase's own environment is shown read-only. A palbase/backend app on a machine needs none of this: the platform gives the machine its PALBASE_WEBHOOK_SECRET and keeps an endpoint for exactly the events its app takes, listed as the machine's — not changed or deleted from here, and gone with the machine (Event Hooks).

The CLI does the same:

palbase webhooks:create https://api.example.com/palbase --events user.deleted,file.uploaded --secret-to RECEIVER_WEBHOOK_SECRET
palbase webhooks:list
palbase webhooks:update <id> --events user.deleted
palbase webhooks:delete <id>

--secret-to keeps the new endpoint's secret in the environment's secrets (production, or development with --dev) instead of printing it, so a receiver that is itself a machine of the environment reads it by name. See the CLI reference.

Failed deliveries

A delivery is tried 12 times, the wait growing up to five minutes between tries. When the endpoint still has not taken it, Palbase gives up and keeps the delivery for 30 days under Settings › Webhooks › Failed deliveries: the event, the endpoint, how many times it was tried and why the last try failed. Redeliver sends the same event, with the same Palbase-Event-Id and body, to that endpoint again within seconds, signed afresh, as Stripe's Resend does. Your receiver already handles an event more than once by its id, so a redelivery is safe. Members read the list; an admin redelivers, and the redelivery is written to the organization's audit log. Studio's API does the same: GET /v1/environments/{ref}/webhooks/deliveries?failed=true and POST /v1/environments/{ref}/webhooks/deliveries/{id}/redeliver. A delivery whose endpoint was deleted is sent nowhere (409 endpoint_gone).

What a delivery is

A delivery is a POST with a JSON body — the event — and three headers:

HeaderWhat it carries
Palbase-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>
Palbase-Event-IdThe event's id (also in the body): the same id twice is the same event delivered again.
Palbase-Event-TypeThe event's type (also in the body).
{
  "id": "6f2c1c9e-…",
  "type": "file.uploaded",
  "project_id": "2b7d…",
  "created_at": "2026-10-08T09:41:22Z",
  "data": { "bucket": "avatars", "path": "usr_123/face.png", "size": 48211, "content_type": "image/png", "owner_id": "usr_123" }
}

The platform waits 10 seconds for an answer, follows no redirect, and tries an event up to 12 times, waiting 1 s, 2 s, 4 s … up to five minutes between attempts. One project's slow or silent receiver holds back only that project's events.

Delivery is at least once. An event that reached your endpoint may be sent again — a retry after a timeout your server answered too late, or the platform's sender starting again — so key the work on the event's id, not on its arrival. An event is never dropped because an endpoint was down for a while: it is retried until it is taken or its attempts run out.

The events

TypeSent whendata
user.deletedA user is deleted — by themselves, or with the secret key.{ user_id, is_test, deleted_at }
session.endedA user's session ends — they signed out, ended it from another device, it was revoked (by an admin, a ban, a password reset, its refresh token used twice), it ran out with no one back to renew it (ended_at is then the moment its time was up), or the user was deleted.{ user_id, session_id, ended_at }
file.uploadedAn object is written: a PUT, a copy, an upload through a signed URL.{ bucket, path, size, content_type, owner_id }
file.deletedAn object is deleted.the same fields
file.movedAn object is given another path.the same fields
secret.changedA secret is written, rolled back or deleted. The value is never in the event.{ environment, name, version, change }
notification.sent / notification.failedA message ended sent, or failed.{ message_id, channel, rail, template, … }
notification.receivedA user sent a WhatsApp message to the project's number.the message
notification.uninstalledA push service said no app holds a device's token any more: the app was taken off the phone. The device is forgotten.{ platform, provider, device_id, installation_id, user_id }
notification.push_failingA push provider started failing for its own fault — its APNs key refused, a topic the key may not send to: every push through it fails until its setting is fixed (Apple (APNs)). At most once a day a provider.{ project_id, channel, provider, error, failing_since }
journey.entered / journey.convertedA person entered a journey, or their run reached its goal. A try on a test user's events carry test: true (Journeys).{ journey, journey_id, run_id, user_id, test }
journey.stepA person's run took a step.{ journey, journey_id, run_id, user_id, step, kind, outcome, test }
journey.eventA journey's event step told the project's backend.{ journey, journey_id, run_id, user_id, step, name, data, test }
journey.endedA person's run ended: completed, converted, exited, stopped.{ journey, journey_id, run_id, user_id, reason, test }
messaging.report.createdA person reported a conversation, someone in it or messages of it. Read what it carries with the secret key (GET /messaging/admin/reports/{report_id}); an organization that runs its customers' apps passes it on to them from here (Messaging).{ report_id, conversation_id, reporter_id, reported_user_id, reason, message_count }
messaging.conversation.created / messaging.conversation.deletedA conversation was made — by a person, or the project's server — or deleted for everyone.{ conversation_id, kind, created_by, member_ids } / { conversation_id, deleted_by }
audience.entered / audience.exitedA person came to be in an analytics audience, or left it.{ audience_id, audience_name, person_id, user_id }
machine.created / machine.updated / machine.deletedA machine was opened, changed, or taken away.{ name, source, size, replicas, port, version }
stores.apple.changedThe Apple account was connected, checked (and Apple's answer changed) or disconnected. The key is never in the event.{ change, issuer_id, key_id, status, capabilities }
stores.app.changedAn app was linked to its store.{ change, app_id, bundle_id, asc_app_id }
stores.package.changedA package was made, or its build arrived.{ change, package_id, app_id, build_number, version }
stores.delivery.requestedA delivery to TestFlight was sent, or sent again.{ package_id, channel }
stores.delivery.changedA delivery moved: queued, signing, uploading, processing, compliance, ready or failed.{ package_id, channel, status, asc_build_id, error_code, error }
stores.group.changedA tester group was made, renamed, deleted, its testers set, or a build given to it.{ change, group_id, app_id, name, testers, package_id }
stores.action.changedA planned action (Beta App Review, export compliance, expiring a build, an App Store page or version step) was done, declined or failed.{ action_id, kind, package_id, app_id, status, summary }
stores.version.changedAn app's App Store version came to a new state: sent to App Review, in review, approved, rejected, withdrawn or released. result is App Review's answer, approved or rejected, or null; source who told it: apple_webhook, poll or action.{ app_id, bundle_id, name, version, asc_version_id, previous_state, state, result, source }
stores.google.changedThe Play connection was made, checked (and Play's answer changed) or disconnected.{ change, service_account, status, capabilities }
stores.webhook.changed / stores.build.changed / stores.beta_review.changed / stores.feedback.received / stores.product.changed / stores.play_release.changed / stores.vitals.changedAn app's App Store Connect webhook, its build uploads, Beta App Review, testers' feedback, its in-app purchases' review, its Play releases and its crash data — each one is in App Health.the event's own
database.created / database.updated / database.deleted / database.undeleted / database.purged / database.password_changed / database.restoringA database's life.{ name, size }
purchases.*A purchase, a renewal, a refund, an entitlement or credits changing — each one is in Purchases › Entitlements.the event's own
ai.response.startedA chat's turn began: a member's message, the server's, the chat's goal, or the late messages of the turn before opened it.{ response_id, status: "running", model, metadata, chat_id, user_id, message_metadata, goal_id, goal_turn }
ai.response.completed / ai.response.failedA response started with background: true ended, or a chat's turn did. A turn's status is completed, failed, stopped or budget_exceeded, its metadata the chat's labels, and one that failed says why in error.{ response_id, status, model, metadata, cost_nano_usd, error?: { code, message, retryable } }, a turn's with chat_id, user_id, message_metadata, goal_id, goal_turn
ai.goal.updatedA chat's goal was set, its status changed, or it was cleared.{ chat_id, goal_id, status, previous_status, stop_code, stop_reason, set_by, metadata }
ai.voice.started / ai.voice.closedA chat's voice began, or ended (closed, replaced, its member taken out, its chat deleted, lost).{ chat_id, voice_id, user_id, delegation, status, seconds, close_reason, metadata }
ai.voice.delegatedA person asked a voice of delegation: "events" for something out loud: have it done, and answer with the voice's speak, naming the delegation.{ chat_id, voice_id, delegation_id, text, earlier, user_id, metadata }
ai.workspace.publishedA chat's workspace published a tree (mid-turn, as a build writes its product, and once more as its machine lets it go): what changed against the tree it replaced. Read a file of it with GET /ai/chats/{id}/workspace/files?path=&manifest=.{ chat_id, manifest_id, previous_manifest_id?, changed: [{ path, change, size_bytes }], changed_count, changed_truncated, metadata }
ai.workspace.pushedA chat's agent pushed its work to the chat's branch.{ chat_id, repo, repository, branch, head_sha, commits: [{ sha, subject, author_name, author_email }], commits_truncated, response_id, user_id, metadata }

The modules' contracts say which of their operations emit which event; each is noted on the operation (for example, PUT /storage/buckets/{bucket}/objects/{path} "Emits file.uploaded").

Checking a delivery

The signature is an HMAC of the body exactly as it arrived — the bytes, before any JSON parsing. A parsed and re-serialised body is a different string and never verifies, so read the raw body first.

FunctionUse it for
verifyWebhookRequest(request, secret, { toleranceSeconds?, now?, maxBytes? })A fetch Request (Node, Bun, Deno, Workers, a Next.js route handler): reads its body — 1 MiB at most unless maxBytes says — and its header.
verifyWebhook({ secret, signature, body, toleranceSeconds?, now? })A body you already hold, as a string or bytes, and the header's value.
verifySignature({ secret, signature, body, … })The check alone, for a signed call whose body is not an event (see below).

Each throws a WebhookSignatureError whose code says why:

codeWhy
missing_signatureNo Palbase-Signature header.
malformed_signatureThe header is not t=…,v1=….
stale_timestampt is more than toleranceSeconds (300 by default) from now: an old delivery sent again is not taken.
signature_mismatchNo v1 is this body signed with this secret — a changed body, or another endpoint's secret.
invalid_bodyThe signed body is not JSON, or not an event (id, type, project_id, created_at, data).
body_too_largeThe body is larger than maxBytes.

verifyWebhook takes only an endpoint secret (whsec_…): an API key passed by mistake is a TypeError, not a check that quietly fails.

// app/api/palbase/route.ts
import { verifyWebhook, WebhookSignatureError } from "palbase/server";

export async function POST(request: Request): Promise<Response> {
  const body = await request.text(); // the raw body, before anything parses it
  try {
    const event = await verifyWebhook({
      secret: process.env.PALBASE_WEBHOOK_SECRET!,
      signature: request.headers.get("palbase-signature"),
      body,
    });
    console.log(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (e) {
    if (e instanceof WebhookSignatureError) return new Response(e.code, { status: 401 });
    throw e;
  }
}

The platform's own calls to a machine

The platform signs what it asks of a machine the same way, with the key it starts every machine with in PALBASE_PLATFORM_SECRET: the crons' and tasks' lists and calls (GET /_palbase/crons, POST /_palbase/crons/<name>, GET /_palbase/tasks, POST /_palbase/tasks/<name>). Those paths are reached only by the platform: a request from outside whose path names them never gets to the machine. A program that serves them checks the call with verifySignature, whose body is the request's (empty for a GET). See Machines.

  • Event Hooks — @On handlers in a palbase/backend app
  • Backend Webhooks — a third party's deliveries (Stripe, GitHub, …) to a palbase/backend app, with @Webhook
  • Server SDK Overview — createServer, refusals and retries
  • Storage — what emits file.uploaded, file.deleted and file.moved
  • Secrets — where a receiving server keeps its endpoint secret