Callbacks and webhooks

Follow a checked operation through signed HTTP delivery, an application handler, an SDK reply, tracked completion, and state inspection.

Provider integrations run in both directions. A callback-capable plugin sends provider-shaped HTTP to the application's normal receiver; localhost2137 does not replace the handler with a function call.

One scenario crosses both directions

The example application is an ordinary Slack Bolt receiver. Its handler replies through Bolt's normal Web API client:

src/bot.ts
import { App, LogLevel } from "@slack/bolt";

export interface PingPongBotOptions {
	readonly apiUrl: string;
	readonly botToken: string;
	readonly port: number;
	readonly signingSecret: string;
}

export interface PingPongBot {
	start(): Promise<void>;
	stop(): Promise<void>;
}

/** Builds an ordinary Bolt app wired only through localhost2137 connection metadata. */
export function buildPingPongBot(options: PingPongBotOptions): PingPongBot {
	const app = new App({
		clientOptions: { slackApiUrl: options.apiUrl },
		endpoints: "/slack/events",
		logLevel: LogLevel.ERROR,
		signingSecret: options.signingSecret,
		token: options.botToken,
	});
	app.message(/^ping$/, async ({ say }) => {
		await say("pong");
	});
	let phase: "new" | "running" | "stopped" = "new";
	return Object.freeze({
		async start() {
			if (phase !== "new") throw new Error("Ping-pong bot may be started exactly once.");
			await app.start({ host: "127.0.0.1", port: options.port });
			phase = "running";
		},
		async stop() {
			if (phase === "stopped") return;
			if (phase === "running") await app.stop();
			phase = "stopped";
		},
	});
}

The checked test starts that receiver before the trigger, sends ping as an external user, waits for tracked delivery, then observes the SDK-written pong in the same world:

test/ping-pong.test.ts
import { createServer } from "node:net";
import { slack } from "@localhost2137/slack";
import { defineConfig } from "localhost2137";
import { createTestRuntime } from "localhost2137/testing";
import { afterEach, describe, expect, it } from "vitest";
import { buildPingPongBot, type PingPongBot } from "../src/bot.js";

const runtimes: Array<Awaited<ReturnType<typeof createTestRuntime>>> = [];
const bots: PingPongBot[] = [];

afterEach(async () => {
	await Promise.all(bots.splice(0).map((bot) => bot.stop()));
	await Promise.all(runtimes.splice(0).map((runtime) => runtime.close()));
});

describe("official Slack Bolt ping-pong bot", () => {
	it("receives ping and posts pong without Slack credentials or a workspace", async () => {
		const botPort = await availablePort();
		const config = defineConfig({
			services: {
				slack: slack({
					config: {
						botToken: "xoxb-local-ping-pong",
						eventsUrl: `http://127.0.0.1:${botPort}/slack/events`,
						signingSecret: "local-ping-pong-signing-secret",
						workspaceName: "Ping Pong Local",
					},
				}),
			},
		});
		const runtime = await createTestRuntime({ config, port: 0, storage: "temporary" });
		runtimes.push(runtime);
		const instance = await runtime.createInstance();
		try {
			const ada = await instance.slack.createUser({ name: "Ada" });
			const channel = await instance.slack.createChannel({ name: "general" });
			await instance.slack.addUserToChannel({ channel: channel.id, user: ada.id });
			const bot = buildPingPongBot({
				apiUrl: instance.slack.connection.apiUrl,
				botToken: instance.slack.connection.botToken,
				port: botPort,
				signingSecret: instance.slack.connection.signingSecret,
			});
			bots.push(bot);
			await bot.start();

			await instance.slack.sendMessage({ channel: channel.id, from: ada.id, text: "ping" });
			await instance.idle();

			const messages = await instance.slack.listMessages({ channel: channel.id });
			expect(messages.map(({ text }) => text)).toEqual(["pong", "ping"]);
			expect(messages[0]).toMatchObject({ userId: "U000000" });
			expect(messages[0]).not.toHaveProperty("eventId");
		} finally {
			await instance.destroy();
		}
	});
});

async function availablePort(): Promise<number> {
	const server = createServer();
	await new Promise<void>((resolve, reject) => {
		server.once("error", reject);
		server.listen({ host: "127.0.0.1", port: 0 }, resolve);
	});
	const address = server.address();
	if (!address || typeof address === "string") {
		server.close();
		throw new Error("Bot port reservation did not return an address.");
	}
	await new Promise<void>((resolve, reject) =>
		server.close((cause) => (cause ? reject(cause) : resolve())),
	);
	return address.port;
}
pnpm --filter @localhost2137/example-slack-ping-bot test

The observable sequence is fixed by the code:

  1. Operations create the user, channel, and membership.
  2. bot.start() makes the configured receiver ready before delivery is triggered.
  3. sendMessage represents Ada, persists ping, and schedules a signed Events API request.
  4. Bolt verifies and parses the HTTP request; the handler calls say("pong") through the Web API.
  5. instance.idle() waits for tracked plugin delivery to settle.
  6. listMessages proves the same workspace contains the user message and SDK reply.

Calling the handler directly would skip HTTP parsing and signature validation. Writing pong with an operation would skip the application's outbound SDK request. Either can serve a narrower test; neither establishes this complete claim.

The plugin chooses how to configure the receiver

There is no universal callback URL. The Slack plugin uses eventsUrl; Stripe uses webhookUrl. Both default to null, so installing them does not send callbacks. The receiver must be listening before the action that schedules delivery, as await bot.start() shows above.

Config-based destinations belong to the runtime template, not an instance. A second instance gets different plugin state, clock, logs, routes, and connection values, but the same configured callback URL. Changing that URL requires a daemon restart.

App-facing connection values configure requests the application sends. Plugin mount config chooses where plugin-initiated requests go. The runtime control token belongs to neither path.

The plugin owns delivery semantics

The runtime supplies tracked outbound fetch and task ownership, not a universal event protocol. Each plugin defines:

  • which transitions emit an event;
  • request body, headers, signature, identity, and timestamp;
  • success responses, timeout and retry behavior;
  • persistence, recovery, ordering, and duplicate-delivery rules;
  • operations needed to trigger and inspect those rules.

Those details are part of the plugin's compatibility surface. localhost2137 does not promise exactly-once delivery. Slack documents bounded attempts and later virtual-time deadlines; Stripe documents transactional event/outbox creation and stable redelivery identity. Neither policy is a runtime default for other plugins.

What idle() establishes

await instance.idle() waits until currently registered plugin work and nested work registered by it settles after a microtask turn. A tracked failure rejects instead of becoming an arbitrary sleep.

QuestionAnswer from idle()
Did the currently tracked delivery attempt settle?Yes.
Did tracked nested work settle?Yes.
Did a retry scheduled for later virtual time run?No; advance according to the plugin's documented policy.
Did application work continue after its HTTP response?Unknown; use the application's completion boundary.
Will an external provider/network behave identically?No.

An HTTP receiver can acknowledge before its own queue, transaction, or job finishes. First establish plugin-side settlement with idle(), then wait for the application's public effect when the claim includes later work. Virtual time and asynchronous work separates current tracked work from later deadlines.

Delivery may be repeated

At-least-once delivery can repeat a stable event after the receiver committed but its response was lost. Test deduplication only when the installed plugin documents that identity and redelivery rule. Inventing duplicate requests for a provider that does not promise them produces unrelated evidence.

Parallel receivers require a routing rule

Separate instance storage never proves callback routing. When an installed plugin does define one callback destination in mount config, all instances of that mount deliver to it. The runtime does not add an instance header, rewrite the URL, or teach a shared application database how to associate an event with a world.

Parallel callback scenarios therefore need one deliberate design:

  • correlate with provider-shaped data the plugin actually supplies;
  • serialize scenarios that cannot be correlated safely;
  • use separately configured runtimes and receivers.

Do not pass the runtime control token to the receiver as a routing workaround. Instances and isolation defines the shared-config boundary.

Read the result narrowly

The checked loop establishes the installed plugin's documented signed request, the application's normal HTTP handler, its normal SDK reply, and the observed local effect. It does not establish external DNS, TLS, dashboard configuration, account permissions, or omitted Slack behavior.

Read Slack events and retries or Stripe webhook delivery and recovery before turning a local delivery into a provider claim. Runtime boundaries lists the external properties that remain.