Skip to content

Inbound Integration

Inbound Integration is coming-soon: the Builder now generates handler files (gap #1 closed), but the platform queue-consumer dispatch path that actually invokes handlers is still being wired (gap #2). In practice that means the code below will compile, type-check, survive K27 cross-check, and be deployed in the Worker bundle — but a webhook sent to your inbound URL will not yet invoke your handler on the platform. Read this page for the contract you will build against; do not expect an end-to-end delivery to actually fire yet.

Inbound Integration lets your App receive webhooks sent by external providers — Stripe, Paddle, GitHub, or any service that signs its requests with HMAC-SHA256. The platform sits in front of the raw HTTP request: it rate-limits, verifies the provider’s signature, deduplicates replays, and only then delivers a structured WebhookEvent to the named handler in your App source. You never parse a raw signature header yourself — you supply the correct signing secret and register the correct inbound URL, and the platform does the verification.

Inbound Integration is turned on per App through the plan, not a plan flag you set by hand. When you describe receiving external callbacks to the Builder — phrasing like “receive Stripe events”, “handle GitHub push notifications”, or “accept a signed POST from a third-party service” — the Builder enables inbound delivery for the App and registers each receiver for you.

Registration is a two-step action you complete with the Builder:

  1. The Builder calls its registration tool to create a receiver. It returns the inbound URL and the signing-secret key name.
  2. You take the inbound URL to your provider’s dashboard, retrieve the provider’s signing secret from that same dashboard, and hand the secret value to the Builder to store. The Builder never invents a signing-secret value — every secret originates from the provider and is supplied by you.

The Builder registers each receiver during generation. Conceptually, one registration takes this shape:

set_webhook({
provider: "stripe" | "paddle" | "github" | "hmac",
id: string, // short lowercase kebab-case slug, e.g. "main", "payment-hook"
handlerName: string, // must match an exported async function in your App source
verificationKind: "stripe-signature" | "paddle-signature" | "github-x-hub-signature-256" | "hmac-sha256",
headerName?: string, // required when provider is "hmac"
toleranceSeconds?: number,
})
// → { inboundUrl: string, signingSecretKey: string }
  • inboundUrl — the URL you register at your provider’s webhook dashboard. The id slug becomes part of the URL path, so a clear slug like "payment-hook" makes it easy to tell which endpoint maps to which provider.
  • signingSecretKey — the key name under which you store the provider’s signing secret. See the naming convention below.

Your App source must follow the register-function dual-export shape. The handler file lives at src/handlers/<handlerName>.ts:

import type { AppSdk } from "@anyknown/app-server"
import type { WebhookEvent } from "@anyknown/app-server"
// Named export — the function the platform imports and calls when a webhook fires.
// The handler body has no `sdk` in v1; it receives only the verified WebhookEvent.
export async function onPayment(event: WebhookEvent) {
// event.body is the raw ArrayBuffer; event.headers is read-only
}
// Never-invoked register function — exists only to make the registration typed
// and scanner-visible. The platform never calls registerOnPayment at runtime.
export function registerOnPayment(sdk: AppSdk) {
sdk.webhook.on("onPayment", onPayment)
}

The first string argument to sdk.webhook.on(...) must equal handlerName verbatim — in the example above, "onPayment" must match the handlerName the Builder registered. The scanner matches on this string to resolve the registration; a mismatch causes the row to be marked stale and disabled at cross-check time.

The handler name is the routing key end to end. The name the Builder registers, the first string arg of sdk.webhook.on(...), and the exported async function name must all match exactly — a mismatch means the platform cannot route the invocation.

You store each provider’s signing secret under a key that follows a fixed convention:

<PROVIDER>_WEBHOOK_SECRET_<ID>

The <ID> segment matches the id you passed when registering the receiver. Examples:

ReceiverSecret key name
Stripe, id mainSTRIPE_WEBHOOK_SECRET_main
Stripe, id payment-hookSTRIPE_WEBHOOK_SECRET_payment-hook
GitHub, id pushGITHUB_WEBHOOK_SECRET_push
Generic HMAC, id ordersHMAC_WEBHOOK_SECRET_orders

You retrieve the secret value from your provider’s dashboard and store it under exactly this key. The platform reads it when verifying each incoming request, so the key name returned as signingSecretKey and the key you store the value under must be identical.

Your handler receives a verified, deduplicated WebhookEvent. The in-process binding point is sdk.webhook.on:

type WebhookProvider = "stripe" | "paddle" | "github" | "hmac"
type WebhookEvent = {
id: string
webhookId: string
provider: WebhookProvider
externalEventId: string
body: ArrayBuffer
headers: Readonly<Record<string, string>>
receivedAt: Date
}
type WebhookHandler = (event: WebhookEvent) => Promise<void> | void
type WebhookCapability = {
on(name: string, handler: WebhookHandler): void
}

By the time your handler runs, the request has already passed rate-limiting, signature verification, and replay deduplication. event.body is the raw request body as an ArrayBuffer, and event.headers is a read-only snapshot of the request headers.

on binds a handler under a given name. It is synchronous and returns void.

on(name: string, handler: WebhookHandler): void
  • name — must be a non-empty string, or the handler must be a named function whose .name is used as the key. If both are absent, on throws WebhookError.
  • handler — receives the WebhookEvent after the platform verifies and deduplicates the request. Registering the same key again overwrites the prior handler.

on is the in-process binding for the dispatched call — it does not persist a handler mapping across requests. The platform’s inbound pipeline resolves which handler to invoke by handlerName from your registered App source, not from on calls made at request time.

Verification runs on the platform before your handler is ever invoked. You only choose the verification kind at registration time and supply the matching signing secret. The supported kinds are:

  • "stripe-signature"
  • "paddle-signature"
  • "github-x-hub-signature-256"
  • "hmac-sha256"

The platform reads the stripe-signature header, whose format is t=<unix-timestamp>,v1=<hex-hmac-sha256> (comma-separated; multiple v1 values are accepted to support rotation). The signature is an HMAC-SHA256 over <timestamp>.<raw-body> keyed with your signing secret. Replay protection rejects any request where |now − t| exceeds toleranceSeconds. Verification fails with one of missing_header, malformed, timestamp_expired, or signature_mismatch.

The platform reads the paddle-signature header, format ts=<unix-timestamp>;h1=<hex-hmac-sha256> (semicolon-separated). The signature is an HMAC-SHA256 over <timestamp>:<raw-body>, with the same replay-protection window and failure-reason set as Stripe.

The platform reads the x-hub-signature-256 header, format sha256=<hex-hmac-sha256>. The signature is an HMAC-SHA256 over the raw body — there is no timestamp and no platform-side replay window, because GitHub’s own delivery-ID deduplication is the primary idempotency mechanism. Verification fails with missing_header, malformed, or signature_mismatch.

For any provider that is not Stripe or GitHub, register it as provider: "hmac" and supply the headerName the provider uses for its signature. The header value must be a hex-encoded HMAC-SHA256 digest of the raw body. Both the shared secret and the header name come from the provider’s documentation — supply both when registering. If headerName is missing at verification time, the request fails with malformed. There is no timestamp-based replay protection at this tier.

Internally each verification resolves to a discriminated result you can reason about when matching a provider’s behavior:

type VerifyResult =
| { ok: true; externalEventId: string }
| {
ok: false
reason:
| "signature_mismatch"
| "missing_header"
| "timestamp_expired"
| "malformed"
| "unsupported_kind"
}

On success, externalEventId is the dedup key derived from the verified payload — it is what flows through to event.externalEventId. An unrecognized verification kind returns unsupported_kind; the platform never silently falls back to a default.

What the platform does before your handler runs

Section titled “What the platform does before your handler runs”
  • Rate-limiting (defense-in-depth). Each request passes a per-IP limit (default 30 req/min) and a per-App limit (default 600 req/min) before signature verification. Both are fail-open — a rate-limit store outage never blocks legitimate delivery — and a request that exceeds a limit receives a 429 before any handler is invoked.
  • Replay deduplication. The platform deduplicates replays using the externalEventId derived from verification, giving you a first-pass dedup before your handler runs. Keep your handler idempotent anyway: dedup is a guard, not a guarantee.
  • Retry and dead-letter. A handler that throws or returns a non-success is retried with exponential backoff (base 30 s, capped at 1 h) on the platform’s queue. Persistently failing deliveries land in a dead-letter queue rather than retrying forever.

When you rotate a provider’s signing secret, the platform applies a grace window (default 24 h) during which both the old and the new secret verify successfully. This prevents delivery failures while your provider is mid-rotation — register the new secret, update the provider dashboard, and let the old secret expire. The Builder’s rotation tool manages this flow for you.

  • The handler name is the contract, end to end. The Builder registration, the handlerName, and the exported async function name must match exactly, or the platform cannot route the delivery.
  • Pick a clear id slug. It becomes part of both the inbound URL path and the secret key name, so a descriptive slug ("payment-hook", "push") keeps your receivers legible.
  • provider: "hmac" requires headerName. Any provider that is not Stripe or GitHub goes through the generic HMAC tier — you must supply both the shared secret and the header name the provider signs with.
  • Never share a secret value with the Builder unless it came from the provider. Every signing-secret value originates from your provider’s dashboard. The Builder generates the key name; you supply the value.