Lifecycle events
A webhook is an HTTPS endpoint of yours that Boat calls with each event you subscribe to. Every event is about one sandbox.
Each event is a sandbox changing state
Section titled “Each event is a sandbox changing state”A sandbox fires an event as it becomes ready, gets its files back, stops, fails, or drops out of reach:
Subscribe a Worker to events
Section titled “Subscribe a Worker to events”Subscribe a Worker to the events it needs, with a handler for each one:
import * as Boat from "@alchemy.run/boat";import * as Cloudflare from "@alchemy.run/cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/http/HttpServerResponse";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { yield* Boat.consumeSandboxEvents({ events: ["sandbox.ready", "sandbox.archived"] }, (event) => Effect.log(`${event.data.sandbox.id} is ${event.data.state}`), ); return { fetch: Effect.succeed(HttpServerResponse.text("ok")) }; }).pipe(Effect.provide(Boat.SandboxEventsLive)),) {}At deploy, Alchemy registers a webhook pointing at the Worker’s /__alchemy/boat/events, and gives the Worker its signing secret as a Cloudflare secret. Boat retries a delivery that fails, so make the handler safe to repeat, keyed on event.id. Destroying the Worker removes the webhook.
The Worker checks every delivery
Section titled “The Worker checks every delivery”Boat signs each delivery with the webhook’s secret:
POST /__alchemy/boat/eventsX-Ascii-Delivery: dlv_8a2cX-Ascii-Timestamp: 1762531200X-Ascii-Signature: v1=<HMAC-SHA256 of delivery.timestamp.body>The Worker checks every request before your handler runs. It refuses, with 401, a request without Boat’s signature, one signed with another secret, one whose body was changed after signing, and one more than five minutes old, so a captured delivery can’t be replayed later. The signature covers the delivery id, the timestamp and the body, and the event’s id must be the signed delivery id. Requests over 1 MB are refused unread, and events you didn’t subscribe to are acknowledged without running the handler.
The Worker holds two keys
Section titled “The Worker holds two keys”The Worker holds two keys, both deployed as Cloudflare secrets, never as plain-text variables:
- The signing secret (
whsec_...). Boat creates it with the webhook and signs every delivery with it; the Worker refuses anything not signed with it. Boat shows it only once, so Alchemy keeps it in state as aRedactedvalue. Destroying the Worker deletes the webhook, and the secret with it. - A Boat API key for the Worker alone, which can only read sandboxes (
sandbox.read). The Worker uses it to look up which stack a sandbox belongs to. It reads any sandbox in the account, because Boat can’t narrow a key by label; it can’t create, change or delete anything. Destroying the Worker deletes it. Withscope: "account"there is no lookup, and no key.
The signing secret is what keeps others out: nothing reaches your handler without it.
Your handler sees only your stack’s sandboxes
Section titled “Your handler sees only your stack’s sandboxes”Boat sends a webhook the events of every sandbox in the account. The event names only the sandbox’s id, so the Worker reads its labels from Boat, with a key that can only read sandboxes, and hands your handler only the ones this stack and stage own: sandboxes the Stack declares, and the ones its bindings create.
The event your handler gets carries the labels, so you can route by your own labels too:
yield* Boat.consumeSandboxEvents({ events: ["sandbox.archived"] }, (event) => event.data.sandbox.labels?.user === undefined ? Effect.void : Effect.log(`${event.data.sandbox.labels.user}'s sandbox stopped`),);The scope is a filter, not an access boundary: anyone in your Boat account who can create sandboxes can label one with your stack’s labels. To receive every sandbox’s events, pass scope: "account". The Worker then skips the lookup, and labels is undefined. A sandbox that was deleted before its event arrived can’t be read, so scope: "stack" drops its events. When Boat can’t be reached for the lookup, the Worker answers 503 and Boat delivers the event again.
An account holds 10 webhooks
Section titled “An account holds 10 webhooks”An account has at most 10 webhooks, and each Worker that consumes events, in each stage, uses one. Destroy stages you no longer use. Boat delivers only to public HTTPS URLs on port 443, so under alchemy dev no webhook is registered.
Point a webhook at any endpoint
Section titled “Point a webhook at any endpoint”For an endpoint that isn’t an Alchemy host, declare the webhook directly:
const hook = yield* Boat.Webhook("Events", { url: "https://example.com/boat", events: ["sandbox.ready", "sandbox.archived"],});hook.secret is the signing secret, as a Redacted value: Boat shows it only when the webhook is created, so Alchemy keeps it in state. Check each delivery’s signature, as above, before acting on the event. Changing the URL or the events updates the webhook in place.