diff --git a/README.md b/README.md
index d4d7c2ce..30cbf000 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ When you add a new sample, make sure to add any config vals to the `deploy-chang
| [BullMQ & BullBoard & Redis](./samples/bullmq-bullboard-redis) | A sample project with BullMQ, BullBoard, and Redis. | BullMQ, BullBoard, Redis, Express, Node.js, Message Queue, JavaScript | nodejs |
| [Crew.ai Django Sample](./samples/crewai) | A sample application that uses Crew.ai to summarize text in a background task, streamed to the user in real-time. | Django, Celery, Redis, Postgres, AI, ML | Python |
| [C# & ASP.NET Core](./samples/csharp-dotnet) | A simple task manager application using C# and ASP.NET Core. | ASP.NET Core, JavaScript, C# | C# |
+| [Customer Installation Handoff](./samples/customer-handoff) | A server-backed foundation for handing customer cloud setup and deployment to Defang. | Defang, Customer Onboarding, Cloud, GitHub, Sample | nodejs, html, css, javascript |
| [Defang Provider Handoff Sample](./samples/defang-provider-handoff) | A sample application that demonstrates how to provide a white-labeled static site for customers to configure their cloud accounts for your deployment. | Defang, Cloud, Deployment, Static Site | HTML, CSS, JavaScript |
| [Django](./samples/django) | A simple Django app that uses SQLite as the database. | Django, SQLite, Python | python |
| [Django Celery](./samples/django-celery) | A Django application that uses Celery for background tasks, Postgres as the database, and Redis as the message broker. | Django, Celery, Postgres, Redis | python, sql |
diff --git a/samples/customer-handoff/README.md b/samples/customer-handoff/README.md
new file mode 100644
index 00000000..e9be86cf
--- /dev/null
+++ b/samples/customer-handoff/README.md
@@ -0,0 +1,152 @@
+# Customer Installation Handoff
+
+[](https://portal.defang.io/sample/customer-handoff)
+
+This server-backed sample starts a Defang Installation handoff after a
+developer-owned signup, payment, or entitlement event. It keeps the Portal
+credential on the server, creates or reuses a pending installation, and sends
+the customer to the exact hosted setup URL returned by Defang.
+
+> [!IMPORTANT]
+> The merged Portal contract currently requires a short-lived developer bearer
+> token. Durable project-scoped machine credentials, credential rotation, a
+> public status API, signed callbacks, and provider-verified revocation are
+> tracked in [Portal issue #1086](https://github.com/DefangLabs/portal/issues/1086)
+> and [Portal issue #1087](https://github.com/DefangLabs/portal/issues/1087);
+> they are not available contracts yet. This sample is a current-contract
+> foundation for evaluation, not an
+> unattended production integration.
+
+## Responsibility boundary
+
+- Your application owns signup or payment, the customer relationship, the
+ external order/entitlement reference, and the database mapping that reference
+ to the returned `installationId`.
+- The sample calls Defang only after that business event has been verified. It
+ sends no payment amount, product, receipt, or payment-provider data to Defang.
+- Defang hosts customer sign-in and cloud trust setup. After setup completes,
+ Portal creates the stack and automatically dispatches the developer project's
+ deployment workflow.
+- The customer owns the cloud account and approves the OIDC trust that the
+ deployment workflow uses.
+
+The public form exists only to make the boundary visible. A real application
+must take the email and external reference from its authenticated backend and
+persist the correlation before redirecting the customer.
+
+## Current behavior
+
+The sample derives a stable, opaque installation name from the external
+reference. Retrying with the same customer and configuration therefore sends an
+identical request; Portal returns the existing installation and handoff URL.
+The raw external reference remains in the developer's system.
+
+The create response contains only the initial installation `status`. Portal does
+not yet expose a supported third-party polling or callback contract. Deployment
+is dispatched after the customer finishes setup, but later workflow transitions
+are not continuously reconciled, so use Portal and GitHub Actions to inspect the
+result during evaluation.
+
+## Prerequisites
+
+1. Install Docker Compose for the container workflow, or Node.js 22 or newer for
+ the focused contract tests.
+2. Have a Defang developer workspace with a project whose deployment workflow is
+ configured for the chosen GitHub repository.
+3. Obtain a current, short-lived Portal bearer token for that developer account.
+4. Use a customer email inbox and test cloud account you control.
+
+## Configuration
+
+Set configuration in the server environment. Never put `PORTAL_ACCESS_TOKEN` in
+client-side code, a committed file, a URL, or logs.
+
+| Variable | Required | Purpose |
+| --- | --- | --- |
+| `PORTAL_ACCESS_TOKEN` | Yes | Current short-lived developer bearer token; server only |
+| `DEFANG_TENANT_ID` | Yes | Developer workspace UUID |
+| `DEFANG_PROJECT_ID` | Yes | Project UUID owned by that workspace |
+| `GITHUB_ORG` | Yes | GitHub owner of the configured repository |
+| `GITHUB_REPOSITORY` | Yes | Exact repository name; wildcards are not used |
+| `PORTAL_GRAPHQL_URL` | No | Defaults to `https://graphql.defang.io/v1/graphql` |
+| `PORTAL_HANDOFF_ORIGIN` | No | Allowed redirect origin; defaults to `https://portal.defang.io` |
+| `DEFANG_CLOUD_PROVIDER` | No | `aws`, `gcp`, or `azure`; defaults to `aws` |
+| `DEFANG_RECIPE` | No | Defaults to `default` |
+| `DEFANG_STACK_NAME` | No | Defaults to `production` |
+| `GITHUB_REF_TYPE` | No | `all`, `branch`, or `environment`; defaults to `environment` |
+| `GITHUB_REF_PATTERN` | For branch/environment | Defaults to `defang-production`, matching stack `production` |
+| `INSTALLATION_NAME_PREFIX` | No | Non-secret installation-name prefix; defaults to `customer` |
+
+For Portal's dev environment, use these matching endpoint values together:
+
+```sh
+export PORTAL_GRAPHQL_URL=https://graphql.dev.gnafed.click/v1/graphql
+export PORTAL_HANDOFF_ORIGIN=https://portal.dev.gnafed.click
+```
+
+## Development
+
+Export all required values in your shell, then run:
+
+```sh
+docker compose up --build
+```
+
+Open `http://localhost:8080`, enter a test customer email and external
+reference, and submit the form. The backend will create or reuse the handoff and
+respond with `303 See Other` to the exact `handoffUrl` returned by Portal.
+
+To test safely without a live credential or cloud account, run the contract tests.
+They replace Portal at the HTTP boundary and cover request shape, idempotent
+replay, redirect preservation, allowed-origin validation, and log redaction:
+
+```sh
+cd app
+npm test
+```
+
+## End-to-end evaluation
+
+1. Configure the sample for a real project and a test customer email.
+2. Submit one external reference and record the returned redirect.
+3. Submit the same reference and email again. Portal should reuse the same
+ installation and return the same redirect.
+4. Sign in at the returned URL as the customer and complete cloud trust setup.
+5. Confirm the configured deployment workflow starts in GitHub Actions.
+6. Inspect the installation in Portal for the initial deployment result.
+
+Known current limitations:
+
+- The developer token expires and cannot be rotated or scoped as a machine
+ credential. Do not deploy this sample as an unattended service yet.
+- Idempotency is based on the customer workspace plus derived installation name,
+ not a first-class vendor key. Enforce uniqueness and payload consistency in
+ your own database too.
+- The external reference is not stored by Defang. Persist the returned
+ `installationId` in the same transaction as your local handoff state.
+- There is no supported partner callback or polling endpoint yet.
+- Setup links have no partner-controlled expiry/cancellation contract.
+- Deployment dispatch is at-most-once, but `dispatching`/`unknown` outcomes do
+ not yet have an automatic reaper, and later workflow status is not reconciled.
+- Customer self-service trust revocation and uninstall are not yet available.
+
+## Deployment
+
+The Compose file is deployable, but the current short-lived authentication
+contract makes an unattended deployment unsuitable. When project-scoped machine
+credentials ship, set the credential through environment configuration rather
+than changing the browser or checking a secret into the repository:
+
+```sh
+defang compose up
+```
+
+---
+
+Title: Customer Installation Handoff
+
+Short Description: A server-backed foundation for handing customer cloud setup and deployment to Defang.
+
+Tags: Defang, Customer Onboarding, Cloud, GitHub, Sample
+
+Languages: nodejs, html, css, javascript
diff --git a/samples/customer-handoff/app/Dockerfile b/samples/customer-handoff/app/Dockerfile
new file mode 100644
index 00000000..d9fb89ca
--- /dev/null
+++ b/samples/customer-handoff/app/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:22-alpine
+
+WORKDIR /app
+
+COPY --chown=node:node package.json server.js portal-client.js ./
+COPY --chown=node:node public ./public
+
+ENV NODE_ENV=production
+USER node
+
+EXPOSE 8080
+
+CMD ["node", "server.js"]
diff --git a/samples/customer-handoff/app/package.json b/samples/customer-handoff/app/package.json
new file mode 100644
index 00000000..d51c48b0
--- /dev/null
+++ b/samples/customer-handoff/app/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "customer-handoff",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node server.js",
+ "test": "node --test"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/samples/customer-handoff/app/portal-client.js b/samples/customer-handoff/app/portal-client.js
new file mode 100644
index 00000000..cb133d75
--- /dev/null
+++ b/samples/customer-handoff/app/portal-client.js
@@ -0,0 +1,296 @@
+import { createHash } from "node:crypto";
+
+export const CREATE_HANDOFF_MUTATION = `
+ mutation CreateInstallationHandoff($input: CreateInstallationHandoffInput!) {
+ createInstallationHandoff(input: $input) {
+ installationId
+ status
+ handoffUrl
+ }
+ }
+`;
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const PROVIDERS = new Set(["aws", "gcp", "azure"]);
+const REF_TYPES = new Set(["all", "branch", "environment"]);
+const INITIAL_STATUSES = new Set(["pending", "ready"]);
+
+export class HandoffError extends Error {
+ constructor(message, status = 502) {
+ super(message);
+ this.name = "HandoffError";
+ this.status = status;
+ }
+}
+
+function requireText(value, name, maxLength = 256, status = 500) {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new HandoffError(`${name} is required.`, status);
+ }
+ const normalized = value.trim();
+ if (normalized.length > maxLength) {
+ throw new HandoffError(`${name} is too long.`, status);
+ }
+ return normalized;
+}
+
+function requireUuid(value, name) {
+ const normalized = requireText(value, name, 36);
+ if (!UUID_PATTERN.test(normalized)) {
+ throw new HandoffError(`${name} must be a UUID.`, 500);
+ }
+ return normalized;
+}
+
+function requireHttpsUrl(value, name, { localhost = false } = {}) {
+ let url;
+ try {
+ url = new URL(requireText(value, name, 2_048));
+ } catch {
+ throw new HandoffError(`${name} must be a valid URL.`, 500);
+ }
+ if (
+ url.protocol !== "https:" &&
+ !(localhost && ["localhost", "127.0.0.1"].includes(url.hostname))
+ ) {
+ throw new HandoffError(`${name} must use HTTPS.`, 500);
+ }
+ return url;
+}
+
+export function readConfig(env = process.env) {
+ const graphqlUrl = requireHttpsUrl(
+ env.PORTAL_GRAPHQL_URL ?? "https://graphql.defang.io/v1/graphql",
+ "PORTAL_GRAPHQL_URL",
+ { localhost: true },
+ );
+ const handoffOrigin = requireHttpsUrl(
+ env.PORTAL_HANDOFF_ORIGIN ?? "https://portal.defang.io",
+ "PORTAL_HANDOFF_ORIGIN",
+ { localhost: true },
+ );
+ const cloudProvider = requireText(
+ env.DEFANG_CLOUD_PROVIDER ?? "aws",
+ "DEFANG_CLOUD_PROVIDER",
+ 16,
+ );
+ const refType = requireText(
+ env.GITHUB_REF_TYPE ?? "environment",
+ "GITHUB_REF_TYPE",
+ 16,
+ );
+ const stackName = requireText(
+ env.DEFANG_STACK_NAME ?? "production",
+ "DEFANG_STACK_NAME",
+ 128,
+ );
+ if (!PROVIDERS.has(cloudProvider)) {
+ throw new HandoffError(
+ "DEFANG_CLOUD_PROVIDER must be aws, gcp, or azure.",
+ 500,
+ );
+ }
+ if (!REF_TYPES.has(refType)) {
+ throw new HandoffError(
+ "GITHUB_REF_TYPE must be all, branch, or environment.",
+ 500,
+ );
+ }
+
+ const refPattern =
+ refType === "all"
+ ? null
+ : requireText(
+ env.GITHUB_REF_PATTERN ??
+ (refType === "environment" ? `defang-${stackName}` : undefined),
+ "GITHUB_REF_PATTERN",
+ );
+
+ return Object.freeze({
+ graphqlUrl: graphqlUrl.toString(),
+ handoffOrigin: handoffOrigin.origin,
+ accessToken: requireText(
+ env.PORTAL_ACCESS_TOKEN,
+ "PORTAL_ACCESS_TOKEN",
+ 16_384,
+ ),
+ tenantId: requireUuid(env.DEFANG_TENANT_ID, "DEFANG_TENANT_ID"),
+ projectId: requireUuid(env.DEFANG_PROJECT_ID, "DEFANG_PROJECT_ID"),
+ recipe: requireText(env.DEFANG_RECIPE ?? "default", "DEFANG_RECIPE", 128),
+ stackName,
+ cloudProvider,
+ githubOrg: requireText(env.GITHUB_ORG, "GITHUB_ORG", 128),
+ githubRepository: requireText(
+ env.GITHUB_REPOSITORY,
+ "GITHUB_REPOSITORY",
+ 256,
+ ),
+ refType,
+ refPattern,
+ installationPrefix: requireText(
+ env.INSTALLATION_NAME_PREFIX ?? "customer",
+ "INSTALLATION_NAME_PREFIX",
+ 48,
+ ),
+ });
+}
+
+export function validateBusinessEvent(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new HandoffError("Customer details are required.", 400);
+ }
+ const externalReference = requireText(
+ value.externalReference,
+ "External reference",
+ 256,
+ 400,
+ );
+ const customerEmail = requireText(
+ value.customerEmail,
+ "Customer email",
+ 320,
+ 400,
+ ).toLowerCase();
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerEmail)) {
+ throw new HandoffError("Enter a valid customer email.", 400);
+ }
+ return { customerEmail, externalReference };
+}
+
+/**
+ * Portal does not yet accept a vendor idempotency key or external reference.
+ * A stable digest gives identical retries the same installation name without
+ * disclosing the developer's order/entitlement identifier to Defang.
+ */
+export function installationNameFor(prefix, externalReference) {
+ const digest = createHash("sha256")
+ .update(externalReference, "utf8")
+ .digest("hex")
+ .slice(0, 24);
+ return `${prefix}-${digest}`;
+}
+
+export function buildHandoffInput(config, event) {
+ const { customerEmail, externalReference } = validateBusinessEvent(event);
+ return {
+ tenantId: config.tenantId,
+ customerEmail,
+ projectId: config.projectId,
+ installationName: installationNameFor(
+ config.installationPrefix,
+ externalReference,
+ ),
+ recipe: config.recipe,
+ stackName: config.stackName,
+ cloudProvider: config.cloudProvider,
+ githubOrg: config.githubOrg,
+ repoPattern: config.githubRepository,
+ refType: config.refType,
+ refPattern: config.refPattern,
+ };
+}
+
+function safePortalMessage(errors) {
+ const message = Array.isArray(errors)
+ ? errors.find((error) => typeof error?.message === "string")?.message
+ : null;
+ if (message?.startsWith("This installation name already exists")) {
+ return `${message.slice(0, 300)} Confirm that this retry uses the original customer and configuration.`;
+ }
+ if (message?.startsWith("Project not found in this tenant")) {
+ return "The configured project is not available in the developer workspace. Check DEFANG_PROJECT_ID and DEFANG_TENANT_ID.";
+ }
+ if (message?.startsWith("Forbidden")) {
+ return "The configured Portal credential cannot create handoffs for this workspace.";
+ }
+ if (message?.startsWith("Unauthorized")) {
+ return "Portal rejected the configured credential. Replace the expired credential and retry.";
+ }
+ return "Portal could not create the handoff. Check the server configuration and retry.";
+}
+
+async function portalGraphql(config, query, variables, fetchImpl) {
+ let response;
+ try {
+ response = await fetchImpl(config.graphqlUrl, {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ authorization: `Bearer ${config.accessToken}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ query, variables }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ } catch {
+ throw new HandoffError(
+ "Portal did not respond. Check PORTAL_GRAPHQL_URL and retry.",
+ );
+ }
+
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ throw new HandoffError(
+ "Portal rejected the configured credential. Replace the expired credential and retry.",
+ response.status,
+ );
+ }
+ throw new HandoffError(
+ "Portal could not create the handoff.",
+ response.status,
+ );
+ }
+ if (!payload || typeof payload !== "object") {
+ throw new HandoffError("Portal returned an invalid response.");
+ }
+ if (payload.errors?.length) {
+ throw new HandoffError(safePortalMessage(payload.errors), 422);
+ }
+ return payload.data;
+}
+
+export async function createInstallationHandoff({
+ config,
+ event,
+ fetchImpl = fetch,
+}) {
+ const input = buildHandoffInput(config, event);
+ const data = await portalGraphql(
+ config,
+ CREATE_HANDOFF_MUTATION,
+ { input },
+ fetchImpl,
+ );
+ const handoff = data?.createInstallationHandoff;
+ if (
+ !handoff ||
+ !UUID_PATTERN.test(handoff.installationId ?? "") ||
+ !INITIAL_STATUSES.has(handoff.status) ||
+ typeof handoff.handoffUrl !== "string"
+ ) {
+ throw new HandoffError("Portal returned an invalid handoff.");
+ }
+
+ let handoffUrl;
+ try {
+ handoffUrl = new URL(handoff.handoffUrl);
+ } catch {
+ throw new HandoffError("Portal returned an invalid handoff URL.");
+ }
+ if (handoffUrl.origin !== config.handoffOrigin) {
+ throw new HandoffError(
+ "Portal returned a handoff URL outside PORTAL_HANDOFF_ORIGIN.",
+ );
+ }
+
+ return {
+ installationId: handoff.installationId,
+ status: handoff.status,
+ // Preserve the exact literal returned by Portal. The redirect below must
+ // not normalize, rebuild, or decode this customer-facing identifier.
+ handoffUrl: handoff.handoffUrl,
+ installationName: input.installationName,
+ };
+}
diff --git a/samples/customer-handoff/app/portal-client.test.js b/samples/customer-handoff/app/portal-client.test.js
new file mode 100644
index 00000000..e479bb74
--- /dev/null
+++ b/samples/customer-handoff/app/portal-client.test.js
@@ -0,0 +1,196 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ buildHandoffInput,
+ createInstallationHandoff,
+ HandoffError,
+ installationNameFor,
+ readConfig,
+} from "./portal-client.js";
+
+const TENANT_ID = "00000000-0000-4000-8000-000000000001";
+const PROJECT_ID = "00000000-0000-4000-8000-000000000002";
+const INSTALLATION_ID = "00000000-0000-4000-8000-000000000003";
+const LITERAL_HANDOFF_URL = `https://portal.dev.gnafed.click/clients/login?redirect=%2Finstallations%2F${INSTALLATION_ID}%2Fsetup`;
+
+function config() {
+ return readConfig({
+ PORTAL_GRAPHQL_URL: "https://graphql.dev.gnafed.click/v1/graphql",
+ PORTAL_HANDOFF_ORIGIN: "https://portal.dev.gnafed.click",
+ PORTAL_ACCESS_TOKEN: "test-token-value",
+ DEFANG_TENANT_ID: TENANT_ID,
+ DEFANG_PROJECT_ID: PROJECT_ID,
+ DEFANG_CLOUD_PROVIDER: "aws",
+ GITHUB_ORG: "example-org",
+ GITHUB_REPOSITORY: "example-app",
+ GITHUB_REF_TYPE: "environment",
+ GITHUB_REF_PATTERN: "defang-production",
+ });
+}
+
+function event(overrides = {}) {
+ return {
+ customerEmail: "Cloud.Owner@Example.com",
+ externalReference: "order_demo_001",
+ ...overrides,
+ };
+}
+
+function jsonResponse(payload, init = {}) {
+ return new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ ...init,
+ });
+}
+
+describe("handoff input", () => {
+ it("derives a stable opaque installation name from the external reference", () => {
+ const first = installationNameFor("customer", "order_demo_001");
+ const retry = installationNameFor("customer", "order_demo_001");
+ const different = installationNameFor("customer", "order_demo_002");
+
+ assert.equal(first, retry);
+ assert.notEqual(first, different);
+ assert.match(first, /^customer-[0-9a-f]{24}$/);
+ assert.equal(first.includes("order_demo_001"), false);
+ });
+
+ it("keeps the payment reference out of the Portal request", () => {
+ const input = buildHandoffInput(config(), event());
+ assert.equal(input.customerEmail, "cloud.owner@example.com");
+ assert.equal(input.repoPattern, "example-app");
+ assert.equal(JSON.stringify(input).includes("order_demo_001"), false);
+ });
+
+ it("rejects missing business input as a client error", () => {
+ assert.throws(
+ () =>
+ buildHandoffInput(config(), {
+ customerEmail: "cloud-owner@example.com",
+ }),
+ (error) =>
+ error instanceof HandoffError &&
+ error.status === 400 &&
+ error.message === "External reference is required.",
+ );
+ });
+});
+
+describe("Portal client", () => {
+ it("uses the server credential and returns Portal's exact handoff URL", async () => {
+ let request;
+ const handoff = await createInstallationHandoff({
+ config: config(),
+ event: event(),
+ fetchImpl: async (url, init) => {
+ request = { url, init };
+ return jsonResponse({
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "pending",
+ handoffUrl: LITERAL_HANDOFF_URL,
+ },
+ },
+ });
+ },
+ });
+
+ assert.equal(request.url, "https://graphql.dev.gnafed.click/v1/graphql");
+ assert.equal(request.init.headers.authorization, "Bearer test-token-value");
+ assert.equal(handoff.status, "pending");
+ assert.equal(handoff.handoffUrl, LITERAL_HANDOFF_URL);
+ });
+
+ it("sends the same request and gets the same installation on a retry", async () => {
+ const requests = [];
+ const fetchImpl = async (_url, init) => {
+ requests.push(JSON.parse(init.body));
+ return jsonResponse({
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "pending",
+ handoffUrl: LITERAL_HANDOFF_URL,
+ },
+ },
+ });
+ };
+
+ const first = await createInstallationHandoff({
+ config: config(),
+ event: event(),
+ fetchImpl,
+ });
+ const retry = await createInstallationHandoff({
+ config: config(),
+ event: event(),
+ fetchImpl,
+ });
+
+ assert.deepEqual(requests[0], requests[1]);
+ assert.equal(first.installationId, retry.installationId);
+ assert.equal(first.handoffUrl, retry.handoffUrl);
+ });
+
+ it("rejects a handoff URL outside the configured Portal origin", async () => {
+ await assert.rejects(
+ createInstallationHandoff({
+ config: config(),
+ event: event(),
+ fetchImpl: async () =>
+ jsonResponse({
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "pending",
+ handoffUrl: "https://attacker.example/handoff",
+ },
+ },
+ }),
+ }),
+ (error) =>
+ error instanceof HandoffError &&
+ error.message ===
+ "Portal returned a handoff URL outside PORTAL_HANDOFF_ORIGIN.",
+ );
+ });
+
+ it("rejects a status outside the current create-handoff contract", async () => {
+ await assert.rejects(
+ createInstallationHandoff({
+ config: config(),
+ event: event(),
+ fetchImpl: async () =>
+ jsonResponse({
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "succeeded",
+ handoffUrl: LITERAL_HANDOFF_URL,
+ },
+ },
+ }),
+ }),
+ (error) =>
+ error instanceof HandoffError &&
+ error.message === "Portal returned an invalid handoff.",
+ );
+ });
+
+ it("does not expose an unexpected upstream error", async () => {
+ await assert.rejects(
+ createInstallationHandoff({
+ config: config(),
+ event: event(),
+ fetchImpl: async () =>
+ jsonResponse({ errors: [{ message: "database detail: secret" }] }),
+ }),
+ (error) =>
+ error instanceof HandoffError &&
+ error.message ===
+ "Portal could not create the handoff. Check the server configuration and retry.",
+ );
+ });
+});
diff --git a/samples/customer-handoff/app/public/index.html b/samples/customer-handoff/app/public/index.html
new file mode 100644
index 00000000..f88bb2d8
--- /dev/null
+++ b/samples/customer-handoff/app/public/index.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ Customer installation handoff
+
+
+
+
+
+
+ D
+ Defang Deploy
+
+ Integration sample
+ Hand cloud setup to your customer
+
+ This form stands in for your trusted backend after signup, payment, or
+ another entitlement event. Your server creates one repeat-safe handoff;
+ Defang hosts login, cloud trust setup, and deployment.
+
+
+ 1 Entitlement confirmed
+
+ 2 Customer setup
+
+ 3 Deploy
+
+
+
+
+
+
+
Simulated business event
+
Continue to hosted setup
+
+
Server-authenticated
+
+
+
+
+
+ Current contract: the server credential is an existing,
+ short-lived developer bearer token. Durable machine credentials,
+ callbacks, and a public status endpoint are forthcoming; do not run this
+ sample unattended until those contracts ship.
+
+
+
+
+
diff --git a/samples/customer-handoff/app/public/styles.css b/samples/customer-handoff/app/public/styles.css
new file mode 100644
index 00000000..2be8de1a
--- /dev/null
+++ b/samples/customer-handoff/app/public/styles.css
@@ -0,0 +1,487 @@
+:root {
+ color-scheme: dark;
+ --background: #09090b;
+ --panel: rgba(24, 24, 27, 0.94);
+ --panel-soft: #202024;
+ --border: #343438;
+ --border-strong: #4b4b52;
+ --text: #fafafa;
+ --muted: #a1a1aa;
+ --accent: #d6ff57;
+ --accent-ink: #171b08;
+ --danger: #ffb4ab;
+ --danger-bg: rgba(127, 29, 29, 0.24);
+ --success: #b9f6ca;
+}
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 12% 5%, rgba(214, 255, 87, 0.09), transparent 28rem),
+ radial-gradient(circle at 90% 24%, rgba(91, 111, 255, 0.08), transparent 26rem),
+ var(--background);
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.shell {
+ width: min(1100px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 36px 0 72px;
+}
+
+.hero {
+ padding: 0 4px 30px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 650;
+ text-decoration: none;
+}
+
+.brand-mark {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border-radius: 7px;
+ color: var(--accent-ink);
+ background: var(--accent);
+ font-weight: 900;
+}
+
+.eyebrow,
+.section-label {
+ margin: 0 0 8px;
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 750;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.eyebrow {
+ margin-top: 58px;
+}
+
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ max-width: 760px;
+ margin-bottom: 16px;
+ font-size: clamp(36px, 7vw, 66px);
+ line-height: 0.99;
+ letter-spacing: -0.055em;
+}
+
+.lede {
+ max-width: 680px;
+ color: #c9c9d0;
+ font-size: 18px;
+ line-height: 1.65;
+}
+
+.flow {
+ display: flex;
+ align-items: center;
+ max-width: 760px;
+ margin-top: 34px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.flow-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+}
+
+.flow-step b {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ border: 1px solid var(--border-strong);
+ border-radius: 999px;
+ font-size: 11px;
+}
+
+.flow-step.current {
+ color: var(--text);
+}
+
+.flow-step.current b {
+ border-color: var(--accent);
+ color: var(--accent-ink);
+ background: var(--accent);
+}
+
+.flow-line {
+ width: 56px;
+ height: 1px;
+ margin: 0 12px;
+ background: var(--border);
+}
+
+.panel {
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--panel);
+ box-shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
+ backdrop-filter: blur(18px);
+}
+
+.panel-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 28px 30px 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.panel-heading h2,
+.result h2 {
+ margin-bottom: 0;
+ font-size: 23px;
+ letter-spacing: -0.025em;
+}
+
+.environment {
+ margin: 1px 0 0;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--muted);
+ background: #151517;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+}
+
+.form-section {
+ margin: 0;
+ padding: 28px 30px 30px;
+ border: 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.form-section:last-of-type {
+ border-bottom: 0;
+}
+
+.form-section:disabled {
+ opacity: 0.44;
+}
+
+legend {
+ padding: 0;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.field-help,
+.request-note,
+.field small,
+.result p {
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.field-help {
+ margin: 6px 0 18px;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+ margin-top: 20px;
+}
+
+.field {
+ display: grid;
+ gap: 8px;
+}
+
+.field > span {
+ color: #dedee3;
+ font-size: 13px;
+ font-weight: 620;
+}
+
+.full-width {
+ grid-column: 1 / -1;
+}
+
+input,
+select {
+ width: 100%;
+ min-height: 44px;
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ outline: none;
+ color: var(--text);
+ background: #111113;
+ padding: 10px 12px;
+ transition: border-color 140ms ease, box-shadow 140ms ease;
+}
+
+input:focus,
+select:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(214, 255, 87, 0.12);
+}
+
+input::placeholder {
+ color: #66666f;
+}
+
+.button {
+ display: inline-flex;
+ min-height: 42px;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ padding: 10px 15px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 720;
+ text-decoration: none;
+}
+
+.button:disabled {
+ cursor: wait;
+ opacity: 0.64;
+}
+
+.button.primary {
+ color: var(--accent-ink);
+ background: var(--accent);
+}
+
+.button.secondary {
+ border-color: var(--border-strong);
+ color: var(--text);
+ background: var(--panel-soft);
+}
+
+.form-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ margin-top: 28px;
+ padding-top: 24px;
+ border-top: 1px solid var(--border);
+}
+
+.request-note {
+ max-width: 560px;
+ margin: 0;
+}
+
+.notice {
+ margin: 0;
+ padding: 18px 30px;
+ border-top: 1px solid rgba(214, 255, 87, 0.2);
+ color: #d4d4d8;
+ background: rgba(214, 255, 87, 0.06);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.notice strong {
+ color: var(--accent);
+}
+
+.error-page {
+ padding: 30px;
+}
+
+.error-page p:not(.section-label) {
+ max-width: 680px;
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.message {
+ margin: 0 30px 30px;
+ padding: 14px 16px;
+ border-radius: 10px;
+ font-size: 14px;
+}
+
+.message.error {
+ border: 1px solid rgba(255, 180, 171, 0.36);
+ color: var(--danger);
+ background: var(--danger-bg);
+}
+
+.result {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 18px;
+ padding: 30px;
+ border-top: 1px solid rgba(185, 246, 202, 0.22);
+ background: rgba(20, 83, 45, 0.16);
+}
+
+.success-icon {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border-radius: 999px;
+ color: #052e16;
+ background: var(--success);
+ font-weight: 900;
+}
+
+.section-label.success {
+ color: var(--success);
+}
+
+.result h2 {
+ margin-bottom: 8px;
+}
+
+.result-meta {
+ display: flex;
+ gap: 36px;
+ margin: 22px 0;
+}
+
+.result-meta div {
+ display: grid;
+ gap: 4px;
+}
+
+.result-meta dt {
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.result-meta dd {
+ margin: 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px;
+}
+
+.link-box {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 18px;
+ padding: 11px 12px;
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ background: #111113;
+}
+
+.link-box code {
+ min-width: 0;
+ flex: 1;
+ overflow-wrap: anywhere;
+ color: #d4d4d8;
+ font-size: 12px;
+}
+
+.copy-button {
+ border: 0;
+ cursor: pointer;
+ color: var(--accent);
+ background: transparent;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.result-link {
+ width: fit-content;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+@media (max-width: 720px) {
+ .shell {
+ width: min(100% - 20px, 1100px);
+ padding-top: 22px;
+ }
+
+ .eyebrow {
+ margin-top: 42px;
+ }
+
+ .flow {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 9px;
+ }
+
+ .flow-line {
+ width: 1px;
+ height: 18px;
+ margin: -3px 0 -3px 11px;
+ }
+
+ .panel-heading,
+ .form-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .panel-heading,
+ .form-section,
+ .notice,
+ .result {
+ padding: 22px 18px;
+ }
+
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .full-width {
+ grid-column: auto;
+ }
+
+ .result {
+ grid-template-columns: 1fr;
+ }
+
+ .result-meta {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 12px;
+ }
+}
diff --git a/samples/customer-handoff/app/server.js b/samples/customer-handoff/app/server.js
new file mode 100644
index 00000000..ed87ff82
--- /dev/null
+++ b/samples/customer-handoff/app/server.js
@@ -0,0 +1,164 @@
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { createServer } from "node:http";
+import { extname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ createInstallationHandoff,
+ HandoffError,
+ readConfig,
+} from "./portal-client.js";
+
+const publicDirectory = fileURLToPath(new URL("./public", import.meta.url));
+const contentTypes = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+};
+
+function securityHeaders(contentType) {
+ return {
+ "cache-control": "no-store",
+ "content-security-policy":
+ "default-src 'self'; style-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
+ "content-type": contentType,
+ "referrer-policy": "no-referrer",
+ "x-content-type-options": "nosniff",
+ "x-frame-options": "DENY",
+ };
+}
+
+function escapeHtml(value) {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function sendError(response, status, message) {
+ response.writeHead(status, securityHeaders("text/html; charset=utf-8"));
+ response.end(`
+Handoff failed
+Handoff not created
Check the request and try again ${escapeHtml(message)}
Back to the sample `);
+}
+
+async function readForm(request) {
+ const contentType = request.headers["content-type"] ?? "";
+ if (!contentType.startsWith("application/x-www-form-urlencoded")) {
+ throw new HandoffError("Submit the handoff form to continue.", 415);
+ }
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > 16 * 1024) {
+ throw new HandoffError("The handoff request is too large.", 413);
+ }
+ chunks.push(chunk);
+ }
+ const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
+ return {
+ customerEmail: form.get("customerEmail"),
+ externalReference: form.get("externalReference"),
+ };
+}
+
+async function serveStatic(pathname, response) {
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(relativePath)) return false;
+
+ const filePath = join(publicDirectory, relativePath);
+ if (!filePath.startsWith(`${publicDirectory}/`)) return false;
+ const fileStat = await stat(filePath).catch(() => null);
+ if (!fileStat?.isFile()) return false;
+
+ response.writeHead(
+ 200,
+ securityHeaders(
+ contentTypes[extname(filePath)] ?? "application/octet-stream",
+ ),
+ );
+ createReadStream(filePath).pipe(response);
+ return true;
+}
+
+export function createAppServer({
+ config,
+ fetchImpl = fetch,
+ logger = console,
+} = {}) {
+ const resolvedConfig = config ?? readConfig();
+ return createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://localhost");
+
+ try {
+ if (request.method === "GET" && url.pathname === "/health") {
+ response.writeHead(
+ 200,
+ securityHeaders("application/json; charset=utf-8"),
+ );
+ return response.end(JSON.stringify({ status: "ok" }));
+ }
+
+ if (request.method === "POST" && url.pathname === "/handoff") {
+ // This route represents the trusted boundary after the developer's own
+ // signup/payment/entitlement check. Payment data is never sent to Defang.
+ const event = await readForm(request);
+ const handoff = await createInstallationHandoff({
+ config: resolvedConfig,
+ event,
+ fetchImpl,
+ });
+ logger.info(
+ JSON.stringify({
+ level: "info",
+ message: "Installation handoff created or reused",
+ installationId: handoff.installationId,
+ installationName: handoff.installationName,
+ status: handoff.status,
+ }),
+ );
+ response.writeHead(303, {
+ "cache-control": "no-store",
+ location: handoff.handoffUrl,
+ "referrer-policy": "no-referrer",
+ });
+ return response.end();
+ }
+
+ if (
+ request.method === "GET" &&
+ (await serveStatic(url.pathname, response))
+ ) {
+ return;
+ }
+
+ sendError(response, 404, "Page not found.");
+ } catch (error) {
+ const status = error instanceof HandoffError ? error.status : 500;
+ const message =
+ error instanceof HandoffError
+ ? error.message
+ : "The handoff could not be created. Check the server logs and retry.";
+ if (!(error instanceof HandoffError)) {
+ logger.error("Unexpected handoff error", error);
+ }
+ sendError(response, status, message);
+ }
+ });
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ try {
+ const port = Number.parseInt(process.env.PORT ?? "8080", 10);
+ createAppServer().listen(port, "0.0.0.0", () => {
+ console.info(`Customer handoff sample listening on port ${port}`);
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Invalid configuration";
+ console.error(`Customer handoff sample could not start: ${message}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/samples/customer-handoff/app/server.test.js b/samples/customer-handoff/app/server.test.js
new file mode 100644
index 00000000..4ae96b37
--- /dev/null
+++ b/samples/customer-handoff/app/server.test.js
@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import { after, before, describe, it } from "node:test";
+import { readConfig } from "./portal-client.js";
+import { createAppServer } from "./server.js";
+
+const TENANT_ID = "00000000-0000-4000-8000-000000000001";
+const PROJECT_ID = "00000000-0000-4000-8000-000000000002";
+const INSTALLATION_ID = "00000000-0000-4000-8000-000000000003";
+const HANDOFF_URL = `https://portal.dev.gnafed.click/clients/login?redirect=%2Finstallations%2F${INSTALLATION_ID}%2Fsetup`;
+const SECRET_TOKEN = "never-log-this-token";
+
+const config = readConfig({
+ PORTAL_GRAPHQL_URL: "https://graphql.dev.gnafed.click/v1/graphql",
+ PORTAL_HANDOFF_ORIGIN: "https://portal.dev.gnafed.click",
+ PORTAL_ACCESS_TOKEN: SECRET_TOKEN,
+ DEFANG_TENANT_ID: TENANT_ID,
+ DEFANG_PROJECT_ID: PROJECT_ID,
+ DEFANG_CLOUD_PROVIDER: "aws",
+ GITHUB_ORG: "example-org",
+ GITHUB_REPOSITORY: "example-app",
+ GITHUB_REF_TYPE: "environment",
+ GITHUB_REF_PATTERN: "production",
+});
+
+const logs = [];
+let upstreamRequest;
+let server;
+let baseUrl;
+
+function jsonResponse(payload) {
+ return new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+describe("sample server", () => {
+ before(async () => {
+ server = createAppServer({
+ config,
+ logger: {
+ info: (message) => logs.push(message),
+ error: (message) => logs.push(message),
+ },
+ fetchImpl: async (url, init) => {
+ upstreamRequest = { url, init };
+ return jsonResponse({
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "pending",
+ handoffUrl: HANDOFF_URL,
+ },
+ },
+ });
+ },
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ baseUrl = `http://127.0.0.1:${address.port}`;
+ });
+
+ after(async () => {
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ );
+ });
+
+ it("serves health and the form without exposing the server credential", async () => {
+ const health = await fetch(`${baseUrl}/health`);
+ assert.equal(health.status, 200);
+ assert.deepEqual(await health.json(), { status: "ok" });
+
+ const page = await fetch(baseUrl);
+ const html = await page.text();
+ assert.equal(page.status, 200);
+ assert.match(html, /Create handoff and continue/);
+ assert.equal(html.includes(SECRET_TOKEN), false);
+ });
+
+ it("redirects to the exact literal returned by Portal and logs no secret", async () => {
+ const response = await fetch(`${baseUrl}/handoff`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ customerEmail: "cloud-owner@example.com",
+ externalReference: "order_demo_001",
+ }),
+ redirect: "manual",
+ });
+
+ assert.equal(response.status, 303);
+ assert.equal(response.headers.get("location"), HANDOFF_URL);
+ assert.equal(
+ upstreamRequest.init.headers.authorization,
+ `Bearer ${SECRET_TOKEN}`,
+ );
+ const serializedLogs = logs.join("\n");
+ assert.equal(serializedLogs.includes(SECRET_TOKEN), false);
+ assert.equal(serializedLogs.includes("cloud-owner@example.com"), false);
+ assert.equal(serializedLogs.includes("order_demo_001"), false);
+ assert.equal(serializedLogs.includes(HANDOFF_URL), false);
+ assert.match(serializedLogs, new RegExp(INSTALLATION_ID));
+ });
+});
diff --git a/samples/customer-handoff/compose.yaml b/samples/customer-handoff/compose.yaml
new file mode 100644
index 00000000..415b11f2
--- /dev/null
+++ b/samples/customer-handoff/compose.yaml
@@ -0,0 +1,36 @@
+name: customer-handoff
+
+services:
+ app:
+ restart: unless-stopped
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ environment:
+ PORT: 8080
+ PORTAL_GRAPHQL_URL: ${PORTAL_GRAPHQL_URL:-https://graphql.defang.io/v1/graphql}
+ PORTAL_HANDOFF_ORIGIN: ${PORTAL_HANDOFF_ORIGIN:-https://portal.defang.io}
+ PORTAL_ACCESS_TOKEN:
+ DEFANG_TENANT_ID:
+ DEFANG_PROJECT_ID:
+ DEFANG_RECIPE: ${DEFANG_RECIPE:-default}
+ DEFANG_STACK_NAME: ${DEFANG_STACK_NAME:-production}
+ DEFANG_CLOUD_PROVIDER: ${DEFANG_CLOUD_PROVIDER:-aws}
+ GITHUB_ORG:
+ GITHUB_REPOSITORY:
+ GITHUB_REF_TYPE: ${GITHUB_REF_TYPE:-environment}
+ GITHUB_REF_PATTERN: ${GITHUB_REF_PATTERN:-defang-production}
+ INSTALLATION_NAME_PREFIX: ${INSTALLATION_NAME_PREFIX:-customer}
+ ports:
+ - mode: ingress
+ target: 8080
+ published: 8080
+ deploy:
+ resources:
+ reservations:
+ memory: 256M
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
diff --git a/samples/defang-provider-handoff/README.md b/samples/defang-provider-handoff/README.md
index 538a5996..2083f965 100644
--- a/samples/defang-provider-handoff/README.md
+++ b/samples/defang-provider-handoff/README.md
@@ -1,5 +1,12 @@
# Defang Provider Handoff Sample
+> [!WARNING]
+> This is the legacy AWS-only manual-invite prototype. It does not use the
+> current Installation handoff contract and should not be the basis of a new
+> integration. Use the maintained
+> [Customer Installation Handoff](https://github.com/DefangLabs/samples/tree/main/samples/customer-handoff)
+> sample instead.
+
[](https://portal.defang.io/sample/defang-provider-handoff)
If you are using Defang to deploy your application into your customer's cloud accounts, you may want to provide a white-labeled static site that your customers can use to configure their cloud account for your deployment. This sample demonstrates how to do that.