Skip to main content
A recipe is a verified http spec for one operation of one provider, shipped with Sponson: the line names it and fills in its params, and the requests, envelopes, ids and drift rules come from the recipe. Everything the generic adapter guarantees holds unchanged (intents before creates, idempotent apply, drift by hash, destroy only what Sponson created). Why and how: ADR 0020.
  • recipe: <provider>.<op> picks the operation; api defaults to <provider>, and so does its block under providers.http: write the block only to override base_url, base_url_env, auth, headers or encoding.
  • Every other key of the line is one of the recipe’s params. Values are literals, ${ctx.*}, { from }, { secret } or { keep: true }, as anywhere in a plan. An unknown param, a missing required one or a value of the wrong type is PARAM_INVALID naming it, before any request; an unknown recipe or op lists the known ones.
  • destroy: keep leaves what the line made in place when the scope is destroyed.
  • Identity: the ledger records the API block as resolved (base URL, credential variable, headers, encoding) and the keys and record ids of the expanded requests, never the recipe’s text. A line written by hand with the same values is the same resource, and a new Sponson version whose recipe changes only its docs, params or assumptions re-identifies nothing.
Every op below is tested by scenarios/recipes.test.ts against the local fake cloud (create, idempotent re-apply, drift, destroy), and live with pnpm test:live when its credential and the SPONSON_LIVE_* variables its recipe names are set.

Ably

Recipe file: packages/adapters/recipes/ably.yaml. Verified on 2026-10-12 against: Assumptions:
  • AB1 (verified, source): The Control API is at https://control.ably.net/v1 and takes an account access token as Authorization: Bearer; managing rules needs the read:rule and write:rule capabilities, and GET /me shows a token’s.
  • AB2 (verified, source): POST /apps/{app_id}/rules creates an integration rule; an http (webhook) rule takes ruleType: http, requestMode (single or batch), source (channelFilter, a regular expression over the whole channel name, and type: channel.message, channel.presence, channel.lifecycle or channel.occupancy), target (url, format json or msgpack, enveloped, headers, signingKeyId) and status, and answers 201 with the rule and its id.
  • AB3 (verified, source): GET /apps/{app_id}/rules answers a bare array of rules, GET /apps/{app_id}/rules/{rule_id} one rule; PATCH on the same path updates the fields sent (ruleType required) and answers 200; DELETE answers 204, and an unknown rule 404.
  • AB4 (unverified): A rule’s channel filter and source type identify the one this recipe made: Ably keeps rules with the same filter apart only by id, so a second rule with the same filter and type makes find refuse with PARAM_INVALID. The app must name a preview’s channels with a per-preview prefix (pr-42:…) for the filter to select them.
  • AB5 (unverified): Ably answers target.url, target.format, target.enveloped, requestMode and status as written, and a PATCH that sends target without headers keeps none; this recipe sets no headers.

ably.webhook_rule

Webhook rule (http.resource). An integration rule that posts the messages (or presence, lifecycle, occupancy events) of a preview’s channels, those matching channel_filter such as ^pr-42:, to the preview’s own URL, url + path. Identified by its channel filter and source type, so a new preview URL updates it in place; a console edit of its URL, format, envelope, request mode or status is drift. The app and its keys are shared and not managed. Covers: ably-webhook-rule in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

AWS Amplify Hosting

Recipe file: packages/adapters/recipes/amplify.yaml. Verified on 2026-10-11 against: Assumptions:
  • AM1 (verified, source): The Amplify API is regional (https://amplify.<region>.amazonaws.com) and signed with SigV4 for the service amplify. The recipe defaults to us-east-1; an app in another region sets base_url and auth (with its region) in the provider block, and temporary credentials (a CI role) add session_token_env: AWS_SESSION_TOKEN there.
  • AM2 (verified, source): POST /apps/{appId}/branches (CreateBranch) creates a branch from branchName (required) and optional description, stage (PRODUCTION, BETA, DEVELOPMENT, EXPERIMENTAL, PULL_REQUEST), enableAutoBuild, environmentVariables, …; it answers 200 with { branch }.
  • AM3 (verified, source): GET /apps/{appId}/branches/{branchName} (GetBranch) answers { branch } with branchName, stage, description, enableAutoBuild and environmentVariables (a map of string to string).
  • AM4 (verified, source): POST /apps/{appId}/branches/{branchName} (UpdateBranch) takes the same optional fields and answers { branch }; environmentVariables is the branch’s whole map of variables.
  • AM5 (verified, source): DELETE /apps/{appId}/branches/{branchName} (DeleteBranch) deletes a branch; an unknown app or branch is 404 NotFoundException.
  • AM6 (unverified): UpdateBranch leaves the fields it is not sent as they are (so the branch op’s update does not reset the variables, and the variable op’s write does not reset the branch’s settings), while an environmentVariables it is sent replaces the whole map, as the sim models it (assumption A4 of packages/sim/src/routes/aws.ts).
  • AM7 (unverified): A build that ran before a variable was written does not see it: Sponson does not start a new job (StartJob), so order the variable lines before the branch’s first build (enable_auto_build: false and a CI-started job, or the branch line, then the variables, then the push). The branches Amplify’s own pull request previews make are not modelled; this recipe manages a branch the plan names.

amplify.branch

Branch (http.resource). An Amplify branch named by the plan (the git branch of the pull request, ${ctx.git.branch}), created with CreateBranch and deleted on destroy. Its stage, description and auto-build setting are compared: a console edit is drift. Variables on it are amplify.branch_variable lines. Covers: amplify-branch in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

amplify.branch_variable

Branch environment variable (http.list_item). One entry of a branch’s environmentVariables, by read-modify-write of the whole map (UpdateBranch replaces the map it is sent); other variables are left alone, and the branch is locked while Sponson writes it. A branch that is not there yet reads as having no variables, so a plan that also creates the branch shows a create. Builds started before the write do not see it (AM7). Covers: amplify-branch in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Auth0

Recipe file: packages/adapters/recipes/auth0.yaml. Verified on 2026-10-11 against: Assumptions:
  • AU1 (verified, source): A machine-to-machine application gets a Management API token from POST https://<tenant>/oauth/token (form-encoded grant_type=client_credentials, client_id, client_secret, audience); the audience is the Management API’s identifier https://<tenant>/api/v2/. The answer has access_token, expires_in (86400 in the example) and token_type: Bearer. The recipe writes both as paths, so a plan’s base_url: https://<tenant>/api/v2 names the tenant once.
  • AU2 (verified, source): GET /api/v2/clients/{id} answers the application with its callbacks, allowed_logout_urls, web_origins and allowed_origins arrays, which only a token with read:clients (or read:client_keys) receives. fields with include_fields=true limits the answer to the listed fields, so the recipe never reads the client secret.
  • AU3 (verified, source): PATCH /api/v2/clients/{id} (scope update:clients) updates an application; callbacks, allowed_logout_urls, web_origins and allowed_origins are arrays of strings, and the body needs at least one property, so a write sends the one array alone.
  • AU4 (unverified): An array sent in a PATCH replaces the stored array whole (it is not merged), so the recipe sends the full list read just before with one URL added or removed. The reference does not say so for these fields.
  • AU5 (unverified): Updates have no precondition (no ETag or version): the line locks the application (lockOn, one lock per tenant and client id, shared by the four ops) against other Sponson runs, and a console edit made between the read and the write can still be lost (the next apply then reports it as drift).
  • AU6 (unverified): URLs are stored and read back exactly as written (no trailing slash added or case changed), so a URL identifies its entry.
  • AU7 (verified, source): AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET are the variables the Auth0 Terraform provider reads for a machine-to-machine client.
  • AU8 (unverified): The audience stays the tenant’s canonical domain when the tenant has a custom domain, so base_url names the canonical domain (<tenant>.<region>.auth0.com), not the custom one.

auth0.callback_url

Application callback URL (http.list_item). One URL in an Auth0 application’s Allowed Callback URLs (callbacks), such as the preview’s /callback; usually { from: <deploy line>.preview_url } joined with the path in the app. Added by read-modify-write of the list under a lock on the application, removed on destroy; every other URL is left as it is. A URL removed in the Dashboard is drift (missing) that the next apply puts back. Covers: auth0-callbacks in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

auth0.logout_url

Application allowed logout URL (http.list_item). One URL in an Auth0 application’s Allowed Logout URLs (allowed_logout_urls), where /v2/logout may return the user, such as the preview’s home page. Read-modify-write of the list under the application’s lock; removed on destroy; every other URL is left as it is. Covers: auth0-callbacks in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

auth0.web_origin

Application allowed web origin (http.list_item). One origin in an Auth0 application’s Allowed Web Origins (web_origins), which silent authentication and the SPA SDKs’ token refresh need for the preview’s origin. Read-modify-write of the list under the application’s lock; removed on destroy; every other origin is left as it is. Covers: auth0-callbacks in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

auth0.allowed_origin

Application allowed origin (CORS) (http.list_item). One origin in an Auth0 application’s Allowed Origins (CORS, allowed_origins), for a preview that calls Auth0 from the browser across origins (embedded login, cross-origin authentication). Read-modify-write of the list under the application’s lock; removed on destroy; every other origin is left as it is. Covers: auth0-callbacks in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Azure Static Web Apps

Recipe file: packages/adapters/recipes/azure_swa.yaml. Verified on 2026-10-11 against: Assumptions:
  • AZ1 (verified, source): PUT https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/staticSites/{name}/builds/{environmentName}/config/appsettings?api-version=2024-04-01 with { properties: { <name>: <value>, … } } sets the app settings of one environment (build) and answers 200 with the same properties.
  • AZ2 (verified, source): The settings are read with POST …/builds/{environmentName}/listAppSettings?api-version=2024-04-01 (no body), which answers a StringDictionary { id, properties }; there is no GET, so the recipe reads with parent.read_method: POST.
  • AZ3 (verified, source): Each environment is a builds/{environmentName} resource: production is default, a pull request’s preview environment is named by the pull request’s number (builds/12).
  • AZ4 (verified, source): A preview environment exists from the pull request’s first deployment and is deleted, with its settings, when the pull request is closed; app settings reach the app’s managed API (Functions), not the front-end build.
  • AZ5 (verified, source): az account get-access-token --query accessToken -o tsv prints an Azure Resource Manager access token for the signed-in account (the default resource is https://management.azure.com/), sent as a bearer token.
  • AZ6 (unverified): The PUT replaces the whole settings map (a setting it is not sent is removed), so the recipe sends the map as read with the one entry changed (send: collection); there is no ETag on this resource, so only Sponson’s own writers are serialised (the parent lock), and a setting added in the portal between the read and the write is lost.
  • AZ7 (unverified): Listing the settings of an environment that does not exist (not deployed yet, or deleted with its pull request) answers 404, which the recipe reads as no settings (if_missing: empty): plan shows a create before the first deploy, and destroy after the pull request closed is a no-op. A write before the first deploy fails with that 404; order the line after the deploy.

azure_swa.build_app_setting

App setting of a preview environment (http.list_item). One app setting of a Static Web App environment (a pull request’s preview environment is named by its number), by read-modify-write of the environment’s settings: read with listAppSettings, written whole with a PUT. Other settings are left alone and the environment is locked while Sponson writes it. Settings reach the app’s managed API, not the front-end build (AZ4); the environment exists only after the PR’s first deploy (AZ7). Covers: azure-swa-settings in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

BugSnag

Recipe file: packages/adapters/recipes/bugsnag.yaml. Verified on 2026-10-12 against: No API block: its ops send their request to an absolute URL (a param: the app’s own, or an endpoint that authenticates by a key in the body, passed as a { secret } param), with no API credential. Assumptions:
  • BS1 (verified, source): A build is reported with a JSON POST to https://build.bugsnag.com (https://build.bugsnag.smartbear.com for keys of BugSnag’s SmartBear-hosted instance; an on-premise endpoint otherwise), as BugSnag’s CLI sends it with Content-Type: application/json.
  • BS2 (verified, source): The body carries apiKey (the project’s notifier API key, required) and appVersion (required), and optionally releaseStage, builderName, sourceControl (provider, repository, revision), autoAssignRelease and metadata; the API key in the body is the only authentication.
  • BS3 (verified, source): autoAssignRelease: true associates the build with new error events and sessions received for its release stage until the next build is reported; without a release stage it applies to production, so the recipe always sends the stage.
  • BS4 (unverified): BugSnag’s public APIs delete no builds or releases, so destroy sends nothing and forgets the record: a release is history.
  • BS5 (unverified): Reporting the same app version and release stage twice (an action is sent at least once) updates or repeats the same release rather than failing.
  • BS6 (unverified): The notifier API key is also embedded in the app’s client bundle for browser error reporting, but it is still passed here as a { secret } so it never reaches the plan file, receipts or logs.

bugsnag.build

Build (release) (http.action). Reports a build of app_version for the environment’s release_stage (such as preview) to BugSnag’s Build API, once per version and stage, so errors from the preview are grouped under its release. The project’s notifier API key travels in the body (api_key, a { secret }); Sponson sends no credential of its own. Nothing to undo (BS4). Covers: bugsnag-build in the coverage matrix. Outputs: none. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.
What the line expands into: the http.action params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

Cloudflare

Recipe file: packages/adapters/recipes/cloudflare.yaml. Verified on 2026-10-11 against: Assumptions:
  • CF1 (verified, source): Records are created with POST /zones/{zone_id}/dns_records (type, name, content, ttl, proxied, comment), authenticated with an API token as Authorization: Bearer that has DNS Write on the zone.
  • CF2 (verified, source): GET /zones/{zone_id}/dns_records answers { result: [...] } and filters by type and name.exact (names compare case-insensitively).
  • CF3 (verified, source): Single-record answers (create, details, update) carry the record under result, with its id.
  • CF4 (verified, source): PATCH /zones/{zone_id}/dns_records/{id} updates a record; DELETE on the same path removes it permanently.
  • CF5 (unverified): A PATCH needs name and type alongside the changed fields (the edit schema does not mark them optional), so every update sends both.
  • CF6 (unverified): The API returns name lower-cased as the full name and content as written, so name must be given lower-case and fully qualified, or every plan finds no record and tries to create one.
  • CF7 (unverified): A proxied record reports ttl: 1 (automatic) whatever was sent, so ttl other than 1 with proxied: true shows as drift on every plan.
  • CF8 (verified, source): A CNAME cannot share its name with another record, so name and type identify it within the zone and a filtered list holds at most one, on its first page.

cloudflare.dns_cname

DNS CNAME record (http.resource). A CNAME record in a zone, such as pr-42.preview.example.com pointing at the preview deployment’s host. Located by name and type; a console edit of its target, proxying, TTL or comment is drift. Covers: cloudflare-dns in the coverage matrix. Outputs: id, hostname. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

CockroachDB Cloud

Recipe file: packages/adapters/recipes/cockroachdb.yaml. Verified on 2026-10-11 against: Assumptions:
  • CR1 (verified, source): The Cloud API authenticates with a service account’s secret key as Authorization: Bearer; the key has the service account’s roles.
  • CR2 (verified, source): Cc-Version pins the API version (YYYY-MM-DD); without it the latest version answers, which may change under a client. The guide documents 2024-09-16.
  • CR3 (verified, source): POST /clusters/{cluster_id}/databases with { name } creates a database and answers { name, table_count }; GET on the same path lists { databases: [{ name, table_count }], pagination: { next_page } }, paged with pagination.page; DELETE /clusters/{cluster_id}/databases/{name} deletes one. There is no GET of a single database.
  • CR4 (verified, source): POST /clusters/{cluster_id}/sql-users with { name, password } (both required) creates a SQL user; GET on the same path lists { users: [{ name }], pagination: { next_page } }; DELETE /clusters/{cluster_id}/sql-users/{name} deletes one. The password cannot be read back; PUT …/sql-users/{name}/password changes it.
  • CR5 (verified, source): A SQL user created through the API gets the SQL admin role by default, which the guide advises against for anything but administration.
  • CR6 (verified, source): Creating or deleting databases and SQL users needs a cluster-level role of the service account (Cluster Admin, Cluster Operator or Cluster Data Accessor for databases; Cluster Admin for SQL users).
  • CR7 (unverified): COCKROACH_API_KEY is the variable the CockroachDB Terraform provider and ccloud read the key from.
  • CR8 (unverified): A second create of an existing database or SQL user is refused with 409 (ALREADY_EXISTS), and deleting one that is gone answers 404.
  • CR9 (unverified): Deleting a database drops it with its tables (CASCADE); the API offers no way to refuse when it holds data.
  • CR10 (unverified): The API takes database and user names that need quoting in SQL (app_pr-42, from a scope with -) and quotes them itself.

cockroachdb.database

Database in a cluster (http.resource). A database per scope in a shared CockroachDB Cloud cluster, such as app_pr_42, instead of a cluster per preview. Located by its name in the cluster’s list; only its existence is compared (the API exposes nothing else to set); destroy drops it with its data (CR9). Covers: cockroachdb-database in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

cockroachdb.sql_user

SQL user in a cluster (http.resource). A SQL user per scope in a shared CockroachDB Cloud cluster, such as app_pr_42, with its password as an input ({ secret: … }, or a sensitive output of another line), sent on create only and never compared. It gets the SQL admin role (CR5): grant narrower privileges in SQL if previews must not administer the cluster. Located by its name; destroy deletes the user. Covers: — in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

ConfigCat

Recipe file: packages/adapters/recipes/configcat.yaml. Verified on 2026-10-11 against: Assumptions:
  • CC1 (verified, source): The API is at https://api.configcat.com, authenticated with Basic auth from Public API credentials (the Terraform provider’s CONFIGCAT_BASIC_AUTH_USERNAME / CONFIGCAT_BASIC_AUTH_PASSWORD).
  • CC2 (verified, source): GET /v2/environments/{environmentId}/settings/{settingId}/value answers the setting’s value in the environment bare: defaultValue, targetingRules, percentageEvaluationAttribute and read-only fields.
  • CC3 (verified, source): PUT on the same path replaces the value: only defaultValue, targetingRules and percentageEvaluationAttribute can be set, and one not sent is reset. So those three are sent, as read, with the rule list changed (parent.send).
  • CC4 (verified, source): A targeting rule is { conditions: [{ userCondition: { comparisonAttribute, comparator, comparisonValue: { stringValue, doubleValue, listValue: [{ value, hint }] } } }], value | percentageOptions }; rules have no id and are evaluated in order. isOneOf and sensitiveIsOneOf compare a user attribute with a list.
  • CC5 (verified, source): Answers carry every member of a value (boolValue, stringValue, intValue, doubleValue, predefinedVariationId) and of a comparison value, the unused ones null; the rule is written with the same nulls so that it reads back equal.
  • CC6 (unverified): A condition is read back with segmentCondition and prerequisiteFlagCondition null and each list value’s hint null, exactly as written; otherwise every plan shows the rule as changed.
  • CC7 (unverified): In a product with “Config changes require a reason” or approvals turned on, the PUT without reason / bypassApproval is refused (the query parameters are not sent); the apply then fails.

configcat.targeting_rule

Feature flag targeting rule (http.list_item). A targeting rule on a boolean feature flag in one environment that serves a value to the preview, such as IF Preview IS ONE OF [pr-42] THEN true. Added after the flag’s other rules (evaluated in order: an earlier matching rule wins); the default value and the other rules are written back as read. Identified by the matched value; a console edit of its condition or served value is drift. Destroy removes the rule. Covers: configcat-rule in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Convex

Recipe file: packages/adapters/recipes/convex.yaml. Verified on 2026-10-12 against: Assumptions:
  • CX1 (verified, source): The Management API at https://api.convex.dev/v1 authenticates with Authorization: Bearer and a team access token (Team Settings), an OAuth application token or, on preview deployments only, a preview deploy key. Convex names no environment variable for the team token; the recipe reads it from CONVEX_TEAM_ACCESS_TOKEN.
  • CX2 (verified, source): POST /projects/{project_id}/create_deployment with { type: "preview", reference } creates a deployment and answers it with name (such as playful-otter-123), deploymentUrl, reference and deploymentType. A reference is 3 to 100 lowercase letters, digits, - and /, unique in the project, not shaped like a deployment name and not a reserved word (preview, prod, dev, …).
  • CX3 (verified, source): GET /projects/{project_id}/list_deployments?deploymentType=preview answers a bare list of deployments with their name and reference; GET /deployments/{name} answers one.
  • CX4 (verified, source): Preview deployments are on every plan (Free included); each counts toward the team’s deployments (40 on Free, 300 on Professional), and Convex deletes one automatically after the plan’s retention (5 days, 14 on Professional and above) unless its expiresAt says otherwise. A preview deleted so is missing drift, created again by the next apply (empty: its data went with it).
  • CX5 (verified, source): POST /deployments/{name}/delete deletes the deployment with all its data and files; it takes no body (the recipe sends {}).
  • CX6 (verified, source): POST /deployments/{name}/create_deploy_key with { name } answers only { deployKey }, the key’s value, shown once; GET /deployments/{name}/list_deploy_keys lists the keys with their name but never their value; POST /deployments/{name}/delete_deploy_key with { id } deletes one, id being the key’s value or its unique name. A preview deploy key cannot call these: the team token can.
  • CX7 (verified, source): npx convex deploy reads its deploy key from CONVEX_DEPLOY_KEY and deploys to the deployment the key names; a preview deployment’s URL is what the frontend’s NEXT_PUBLIC_CONVEX_URL (or VITE_CONVEX_URL, CONVEX_URL) must hold.
  • CX8 (unverified): A deploy key made for a preview deployment with create_deploy_key lets npx convex deploy push code and set environment variables there, as the dashboard’s deployment keys do, and deleting the deployment deletes its keys (a key line is destroyed before the deployment line it references, so it never needs that).
  • CX9 (unverified): Deployment names are unique, and a reference names at most one deployment of the project, so find by reference sees one (two would be refused with PARAM_INVALID, never guessed).

convex.preview_deployment

Preview deployment (http.resource). A Convex preview deployment per scope, a backend with its own data, functions and URL, created through the Management API instead of by npx convex deploy with a preview deploy key, so that it exists, with its URL, before the preview builds and is deleted when the scope is destroyed (CX4: not left to expire). Located by reference; only its existence is compared for drift; destroy deletes it with its data. Covers: convex-preview-deployment in the coverage matrix. Outputs: id, name, url. On destroy: deleted (destroy: keep on the line leaves it). Console link in the PR comment: https://dashboard.convex.dev/d/{id} (pattern documented).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

convex.deploy_key

Deploy key of a deployment (http.resource). A deploy key for one deployment (such as a preview_deployment line’s name), so the preview’s build deploys its functions there with npx convex deploy (CONVEX_DEPLOY_KEY, CX7). Its value exists only in the create’s answer: a once-only output, for the deploy target’s variable in the same run. Located by its name; destroy deletes it (it goes with its deployment anyway). Covers: convex-preview-deployment in the coverage matrix. Outputs: id, deploy_key (sensitive, once: only from the create). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Datadog

Recipe file: packages/adapters/recipes/datadog.yaml. Verified on 2026-10-11 against: Assumptions:
  • DD1 (verified, source): Requests carry the API key in DD-API-KEY and the application key in DD-APPLICATION-KEY; Datadog’s clients read them from DD_API_KEY and DD_APP_KEY. Reading needs the application key too.
  • DD2 (verified, source): Each Datadog site has its own API host (https://api.datadoghq.eu, https://api.us3.datadoghq.com, …): outside US1, set base_url in the provider block.
  • DD3 (verified, source): POST /synthetics/tests/api creates an API test from name, type (api), config (request with method and url, assertions), locations, options and message (all required), with subtype (http), status (live or paused) and tags, answering it with its public_id.
  • DD4 (verified, source): GET /synthetics/tests/api/{public_id} answers one API test; PUT on the same path replaces its configuration with a whole test definition.
  • DD5 (verified, source): GET /synthetics/tests lists every test as { tests: [...] } (page_size and page_number are optional); POST /synthetics/tests/delete with { public_ids: [...] } deletes tests.
  • DD6 (unverified): Without page_size the list answers every test of the organisation, and a test name identifies one test (two with one name make find refuse with PARAM_INVALID).
  • DD7 (unverified): The fields compared (config.request, config.assertions, locations, options.tick_every, message, status, tags) are answered as they were sent, in order.
  • DD8 (unverified): Deleting a test that is already gone answers 404 (destroy treats it as done).
  • DD9 (unverified): Deployment events (datadog-ci deployment mark, the DORA deployments API) are not a recipe: they are listed with a POST over a time window and cannot be deleted, so a later run could not find the one it made.

datadog.synthetic_api_test

Synthetic API test against a preview (http.resource). A Synthetic HTTP test per scope that checks the preview’s URL from Datadog’s locations, such as preview pr-42 asserting status 200, with its own monitor. Located by its name; a console edit of its request, assertions, locations, frequency, message, status or tags is drift; destroy deletes the test (and its monitor). Covers: datadog-marker in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Firebase Authentication

Recipe file: packages/adapters/recipes/firebase.yaml. Verified on 2026-10-11 against: Assumptions:
  • FB1 (verified, source): GET https://identitytoolkit.googleapis.com/admin/v2/projects/{project}/config answers the project’s Auth Config.
  • FB2 (verified, source): PATCH …/admin/v2/projects/{project}/config?updateMask=authorizedDomains updates the config with a Config body; fields set in the body but not named in updateMask are ignored, so the write changes the authorized domains only. It needs one of the identitytoolkit, firebase or cloud-platform OAuth scopes.
  • FB3 (verified, source): authorizedDomains is a list of strings, the domains authorized for OAuth redirects; the masked field is replaced by the list sent, so the recipe sends the whole list read just before with one domain added or removed.
  • FB4 (verified, source): gcloud auth print-access-token prints an access token for the signed-in credentials, used as Authorization: Bearer. With user credentials some APIs also need the quota project in x-goog-user-project: add headers: { x-goog-user-project: <project> } to the providers.http.firebase block (not needed with a service account; a block’s headers are part of its identity).
  • FB5 (unverified): The config has no precondition the update honours (no etag in Config): the line locks the project’s config (lockOn) against other Sponson runs; a console edit made between the read and the write can still be lost.
  • FB6 (unverified): An authorized domain is a host name without scheme, port or path, and is read back exactly as written.
  • FB7 (unverified): The same admin v2 config holds the authorized domains of a Firebase Authentication project with or without the Identity Platform upgrade.

firebase.authorized_domain

Authorized domain (http.list_item). One domain in Firebase Authentication’s authorized domains (Authentication, Settings, Authorized domains), which OAuth sign-in redirects and email links require, such as the preview deployment’s host. Read-modify-write of the list with updateMask=authorizedDomains under a lock on the project’s config; removed on destroy; every other domain (localhost, the Firebase hosting domains) is left as it is. Covers: firebase-auth-domains in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Firebase Hosting

Recipe file: packages/adapters/recipes/firebase_hosting.yaml. Verified on 2026-10-12 against: Assumptions:
  • FH1 (verified, source): The API is served at https://firebasehosting.googleapis.com/v1beta1 with a Google OAuth access token (scope firebase or cloud-platform) as Authorization: Bearer; Firebase Hosting Admin is the role for writes. With user credentials some Google APIs also need the quota project in x-goog-user-project: add headers: { x-goog-user-project: <project> } to the providers.http.firebase_hosting block (unverified for this API).
  • FH2 (verified, source): POST /sites/{site}/channels?channelId=<id> creates a channel (the id is required and immutable, unique in the site) from a Channel body with optional ttl (input only, a duration such as 604800s), labels and retainedReleaseCount; GET and DELETE /sites/{site}/channels/{id} read and delete it; the live channel cannot be deleted.
  • FH3 (verified, source): A Channel answers name (sites/SITE_ID/channels/CHANNEL_ID), url (the channel’s web.app URL, also served on firebaseapp.com), expireTime, retainedReleaseCount (1 to 100, 10 by default) and labels; PATCH …/channels/{id}?updateMask= updates the masked fields.
  • FH4 (verified, source): Without ttl or expireTime a channel is never deleted automatically; the CLI’s default is 7 days and the most is 30. An expired channel is deleted within 24 hours, which Sponson sees as missing drift and creates again (empty) on the next apply.
  • FH5 (verified, source): firebase hosting:channel:deploy <id> deploys into an existing channel (creating it only when missing) and syncs the channel’s domain into Firebase Auth’s authorized domains; the GitHub integration’s action creates a channel per pull request but never deletes it when the pull request closes (only expiry does).
  • FH6 (unverified): Creating a channel whose id exists answers 409 (ALREADY_EXISTS), as Google APIs do; firebase-tools always reads first, which the recipe does too, so it only matters after a lost answer.
  • FH7 (unverified): A channel id is letters, digits, _, - and . (what the GitHub action keeps); the API documents no length limit, and live is the site’s own channel, refused here.

firebase_hosting.channel

Preview channel (http.resource). A Firebase Hosting preview channel per scope on a site, such as pr-42, that firebase hosting:channel:deploy then deploys the preview into (FH5): made before the deploy so its url is known to other lines, and deleted when the scope is destroyed instead of left to expire. A console edit of its retained release count is drift; destroy deletes the channel and its releases. Covers: firebase-hosting-channel in the coverage matrix. Outputs: id, url, expire_time. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Flagsmith

Recipe file: packages/adapters/recipes/flagsmith.yaml. Verified on 2026-10-11 against: Assumptions:
  • FS1 (verified, source): The Admin API authenticates with an organisation API key as Authorization: Api-Key <key>; Bearer is only for short-lived OIDC-exchanged tokens.
  • FS2 (verified, source): POST /environments/ creates an environment from name and project (both required) and optional description, answering it (201) with its integer id and its api_key, the client-side environment key SDKs use.
  • FS3 (verified, source): GET /environments/?project=<id> lists a project’s environments as { count, next, previous, results: [...] }.
  • FS4 (verified, source): GET, PATCH and DELETE /environments/{api_key}/ read, update and delete one environment (DELETE answers 204).
  • FS5 (unverified): A project has fewer environments than one page of the list holds, so find sees them all (next is a URL, which the http adapter does not follow).
  • FS6 (unverified): A new environment starts with every feature of the project at its default state, and environment names are unique in a project in practice (two with one name make find refuse with PARAM_INVALID).
  • FS7 (unverified): description is answered as sent; FLAGSMITH_MASTER_API_KEY is the variable Flagsmith’s Terraform provider reads the key from.
  • FS8 (unverified): Identity and segment overrides are not recipes: Edge projects (Flagsmith SaaS’s default) address identities by UUID through edge-identities, and a segment override is two objects (a feature segment, then a feature state) created in order.
  • FS9 (unverified): The create takes the project id as a string of digits ("1234") as well as a number, as Django REST Framework’s primary-key fields do.

flagsmith.environment

Environment per preview (http.resource). A Flagsmith environment per scope in a project, such as preview-pr-42, starting from the project’s default flag states; its client-side environment key is the output api_key for the preview’s SDK configuration. Located by name in the project; a console edit of its description is drift; destroy deletes the environment with its flag states and identities. Covers: flagsmith-override in the coverage matrix. Outputs: id, api_key, environment_id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Fly.io

Recipe file: packages/adapters/recipes/fly.yaml. Verified on 2026-10-11 against: Assumptions:
  • FS1 (verified, source): query ($appName: String!) { app(name: $appName) { secrets { name digest createdAt } } } lists an app’s secrets; values are never returned (Fly does not allow reading plain-text secret values), so the recipe locates a secret by name and compares nothing else.
  • FS2 (verified, source): mutation($input: SetSecretsInput!) { setSecrets(input: $input) { release { id version } } } with { appId, secrets: [{ key, value }] } sets secrets; unsetSecrets(input: { appId, keys }) removes them.
  • FS3 (verified, source): Plain-text values cannot be read back (fly secrets list shows a name, a digest and a date), so a value changed in the console, or in the plan, is not detected: drift is the secret’s presence only. To change a value, run sponson apply --destroy for the scope (or remove the line, apply, and add it back).
  • FS4 (verified, source): Machines read secrets as environment variables at boot: a secret set without updating the Machines (as fly secrets set --stage does) reaches only Machines started or updated afterwards. Order the line before the PR’s fly deploy; after a later change run fly secrets deploy.
  • FS5 (unverified): A token from fly tokens create (FlyV1 fm2_…) or fly auth token is accepted as Authorization: Bearer <token> by the GraphQL API, as the Machines API documents for the same FLY_API_TOKEN. (fly-go itself sends a macaroon as FlyV1 <token>; a block that needs that sends it with auth: { header: Authorization, value_env: FLY_API_TOKEN }.)
  • FS6 (unverified): Unsetting a secret of an app that no longer exists answers an error saying the app could not be found, which the adapter reads as already gone; unsetting an absent key of an existing app is not an error. setSecrets through GraphQL updates no Machines (FS4). Current flyctl sets secrets through the Machines API instead; the GraphQL mutations are not marked deprecated.

fly.secret

App secret (http.resource). One secret of a Fly app (typically the app made for the pull request, fly_machines.app), set with setSecrets and removed with unsetSecrets. Fly never returns secret values, so only its presence is compared: a secret unset in the console is recreated, a changed value is not noticed (FS3). Machines see it once started or updated after the write: order it before the PR’s deploy (FS4). Covers: fly-secrets in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Fly.io Machines API

Recipe file: packages/adapters/recipes/fly_machines.yaml. Verified on 2026-10-11 against: Assumptions:
  • FM1 (verified, source): The Machines API’s public base URL is https://api.machines.dev, its paths start with /v1, and requests carry Authorization: Bearer <fly_api_token>.
  • FM2 (verified, source): POST /v1/apps with app_name (unique) and org_slug (both required) and optional network creates an app and answers 201 with id and created_at.
  • FM3 (verified, source): GET /v1/apps/{app_name} answers the app (id, name, status, organization { name, slug }), so the name the plan chooses locates it.
  • FM4 (verified, source): DELETE /v1/apps/{app_name} answers 202; Machines should be stopped first, and ?force=true stops them and deletes the app at once, which is what the recipe’s destroy sends (a PR app has the PR’s Machines running).
  • FM5 (unverified): Deleting the app deletes its secrets and its Machines with it; an app name another organization holds is refused with a conflict, which the adapter then fails to read (403/404) and reports.

fly_machines.app

App (http.resource). A Fly app per pull request, such as demo-${ctx.scope}, in an organization: created with POST /v1/apps, located by its name, deleted with its Machines on destroy (?force=true). Its secrets are fly.secret lines; the Machines are what the PR’s fly deploy creates. Nothing about the app is compared besides its existence. Covers: fly-secrets in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Google Cloud Storage (Firebase Storage)

Recipe file: packages/adapters/recipes/gcs.yaml. Verified on 2026-10-11 against: Assumptions:
  • GC1 (verified, source): The JSON API takes an OAuth access token as Authorization: Bearer; Google’s own example gets it from gcloud auth print-access-token.
  • GC2 (verified, source): GET /b/{bucket}?fields=cors answers the bucket’s CORS configuration; PATCH /b/{bucket}?fields=cors with { cors: [...] } sets it, and { cors: [] } removes it.
  • GC3 (verified, source): A patch that contains a list replaces the whole list: individual entries cannot be added or removed, so the whole cors list is written back.
  • GC4 (verified, source): Each cors entry has origin (list), method (list), responseHeader (list) and maxAgeSeconds (integer).
  • GC5 (verified, source): PATCH takes ifMetagenerationMatch, a precondition on the bucket’s metageneration. The recipe does not send it (the http adapter has no preconditions); Sponson’s own writers are serialised by the parent lock, a writer outside Sponson can still race.
  • GC6 (verified, source): Changing a bucket’s CORS needs the storage.buckets.update permission.
  • GC7 (unverified): A bucket without a CORS configuration answers {} (no cors field), read as an empty list, and entries are answered as written, in order.
  • GC8 (unverified): An entry is identified by its first origin: the recipe writes one origin per entry, and no other entry of the bucket starts with the same origin.

gcs.bucket_cors

CORS entry of a bucket (http.list_item). An entry of a bucket’s cors list allowing one origin, such as the preview’s URL on the Firebase Storage bucket my-app.appspot.com, with its methods, response headers and max age. The rest of the list is left as it is; a console edit of the entry is drift, and destroy removes only this entry. Covers: gcs-cors in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

GitHub

Recipe file: packages/adapters/recipes/github.yaml. Verified on 2026-10-11 against: Assumptions:
  • GH1 (verified, source): POST /repos/{owner}/{repo}/hooks creates a repository webhook from name: web, config (url, content_type, secret, insecure_ssl), events and active, answering 201 with the hook (its integer id and config), authenticated with Authorization: Bearer <token>.
  • GH2 (verified, source): GET /repos/{owner}/{repo}/hooks answers a bare array of hooks, each with config.url; it pages with per_page (at most 100) and page.
  • GH3 (verified, source): PATCH /repos/{owner}/{repo}/hooks/{hook_id} updates config, events (replacing the list) and active; a secret set before is removed unless the same or a new one is sent, so every update sends the config’s url and secret again.
  • GH4 (verified, source): DELETE /repos/{owner}/{repo}/hooks/{hook_id} answers 204, and 404 when the hook is gone.
  • GH5 (verified, source): Each hook should have a unique config, and hooks may share one only when their events do not overlap. The recipe takes the URL as the hook’s identity; two hooks with the same URL make find refuse (PARAM_INVALID) instead of guessing.
  • GH6 (unverified): The secret is write-only: answers carry it masked or not at all, so it is sent on create and update but never compared, and a secret changed in the plan reaches GitHub only with the next change of a compared field. Keep it in a secret manager and reference it with { secret: … }.
  • GH7 (unverified): A repository has at most 100 hooks for this purpose (GitHub limits hooks per event), so the first page of ?per_page=100 holds the one find looks for.
  • GH8 (verified, source): config.content_type and config.insecure_ssl are answered as sent (json/form, the strings "0"/"1"), and Accept: application/json is served like application/vnd.github+json. Verified live 2026-10-11: create, then a re-apply that wrote nothing, then destroy, against api.github.com.
  • GH9 (unverified): The token needs the repository permission Webhooks (write) or the classic admin:repo_hook scope; the GITHUB_TOKEN of GitHub Actions does not have it, so use a GitHub App token or a fine-grained token. GITHUB_API_URL (set by Actions) points at GitHub Enterprise Server where that is the host.

github.repo_webhook

Repository webhook (http.resource). A repository webhook delivering to an environment’s URL, such as the preview deployment’s /api/github handler. Located by its URL; a console edit of its events, its active flag, its content type or its TLS check is drift. The secret is sent but not compared (GH6). Covers: github-webhook in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

GrowthBook

Recipe file: packages/adapters/recipes/growthbook.yaml. Verified on 2026-10-11 against: Assumptions:
  • GB1 (verified, source): GrowthBook Cloud’s API is at https://api.growthbook.io/api (self-hosted: its API host plus /api), authenticated with a secret key or personal access token as Authorization: Bearer.
  • GB2 (verified, source): The v1 feature endpoints (rules per environment) are deprecated; in v2 a feature’s rules are one top-level rules array, each rule naming its environments (or allEnvironments).
  • GB3 (verified, source): GET /v2/features/{id} answers { feature: { …, rules: [...] } }.
  • GB4 (verified, source): POST /v2/features/{id} patch-merges the top-level fields it is sent, and rules, when sent, replaces the whole array: so the array is read, the rule added or changed, and the whole array sent back. Rules read with GET may be posted back unchanged. The update publishes a new revision at once.
  • GB5 (verified, source): A force rule is { type: force, description, condition, value, enabled, allEnvironments, environments }; condition is a JSON query serialised as a string and value is a string ("true" for a boolean feature).
  • GB6 (unverified): Rule ids may be chosen by the client but what the server does with them is not documented, so the rule is identified by its description, which it stores as written; two rules with that description are one rule to Sponson.
  • GB7 (unverified): A rule is read back with condition exactly as sent (not reformatted), so the compared fields do not show as changed on every plan.
  • GB8 (verified, source): When the feature requires approval, the update answers 422 with the gates that blocked it, unless the organisation lets the REST API bypass approvals; the apply then fails.

growthbook.force_rule

Feature force rule (http.list_item). A force rule on an existing feature that serves a value to the preview in one environment, such as value "true" when {"preview": "pr-42"}. Added after the feature’s other rules (rules are evaluated in order: an earlier rule that matches the preview wins). A console edit of its condition, value, environments or enabled state is drift. Destroy removes the rule. Covers: growthbook-force-rule in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Heroku

Recipe file: packages/adapters/recipes/heroku.yaml. Verified on 2026-10-11 against: Assumptions:
  • HK1 (verified, source): Requests authenticate as Authorization: Bearer <token>; the Heroku CLI reads the token from HEROKU_API_KEY.
  • HK2 (verified, source): Every request must send Accept: application/vnd.heroku+json; version=3; without it the API refuses the request (documented as 406 not_acceptable; a request without it was answered 400 missing_version when checked).
  • HK3 (verified, source): GET /apps/{app_id_or_name}/config-vars answers the app’s config vars as one flat map of name to string value.
  • HK4 (verified, source): PATCH /apps/{app_id_or_name}/config-vars with a map sets the named vars and removes those set to null, leaving every other var as it is: one var is written without reading or rewriting the others, so concurrent writers of other vars cannot lose them (no lock needed).
  • HK5 (verified, source): Config var names match ^\w+$ and values are strings (null only as a delete in a PATCH).
  • HK6 (verified, source): Every change to config vars creates a new release and restarts the app, so a write takes effect without a separate deploy.
  • HK7 (verified, source): Review apps are created asynchronously (POST /review-apps answers pending with a nullable app), by Heroku itself when the pipeline has automatic review apps; this recipe does not create them, it writes vars on an app that exists (the review app’s name or id).
  • HK9 (unverified): A PATCH whose Content-Type is the same vendor media type (media_type sends it as both Accept and Content-Type) is accepted like application/json; the reference’s examples send application/json.
  • HK8 (unverified): A missing app answers 404 not_found on GET and PATCH, which destroy treats as the var being gone.

heroku.config_var

App config var (http.list_item). One config var on a Heroku app, such as a review app’s API_URL. Written as a merge patch of that one var (null deletes it), so other vars, including other scopes’, are never rewritten. A console edit of its value is drift; destroy removes only this var. Heroku restarts the app on every change (HK6). Covers: heroku-config-vars in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Honeycomb

Recipe file: packages/adapters/recipes/honeycomb.yaml. Verified on 2026-10-11 against: Assumptions:
  • HC1 (verified, source): POST /1/markers/{datasetSlug} creates a marker from start_time, end_time (Unix seconds), message, type and url (all optional; start_time defaults to the request’s arrival), answering 201 with the marker and its id; __all__ as the dataset slug makes an environment-wide marker. Requests carry a Configuration Key with the Manage Markers permission in X-Honeycomb-Team.
  • HC2 (verified, source): GET /1/markers/{datasetSlug} answers a bare array of the dataset’s markers (id, message, type, url, start_time, end_time, created_at, updated_at, color).
  • HC3 (verified, source): PUT /1/markers/{datasetSlug}/{markerId} replaces the marker: a field left out of the body is erased.
  • HC4 (verified, source): DELETE /1/markers/{datasetSlug}/{markerId} removes the marker and answers it (200).
  • HC5 (verified, source): The API is served from api.honeycomb.io (US) and api.eu1.honeycomb.io (EU); EU teams set base_url in the provider block.
  • HC6 (unverified): Message and type identify the marker this recipe created among the dataset’s markers (Honeycomb does not require them to be unique: two equal ones make find refuse with PARAM_INVALID), and the list holds every marker of the dataset in one answer.
  • HC7 (unverified): An update (a changed url) re-sends message, type and url but not start_time, which Honeycomb then sets to the time of the update (HC3), moving the marker; markers are rarely edited.
  • HC8 (unverified): A deleted marker answers 404 to DELETE, which destroy treats as already gone.

honeycomb.marker

Deploy marker (http.resource). A marker on an environment’s dataset (or on every dataset of the environment with __all__) for a deploy, such as deploy pr-42 0123abc. Located by its message and type; a console edit of its URL is drift. Destroying the scope removes it; write destroy: keep to keep the deploy history. Covers: honeycomb-marker in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Inngest

Recipe file: packages/adapters/recipes/inngest.yaml. Verified on 2026-10-11 against: No API block: its ops send their request to an absolute URL (a param: the app’s own, or an endpoint that authenticates by a key in the body, passed as a { secret } param), with no API credential. Assumptions:
  • IN1 (verified, source): A PUT to the app’s serve endpoint (by convention /api/inngest, curl -X PUT https://your-app.com/api/inngest --fail-with-body) makes the app’s SDK register its functions with Inngest; the SDK authenticates that registration with the app’s own INNGEST_SIGNING_KEY, so the caller sends no credential.
  • IN2 (verified, source): The app registers into the environment its SDK is configured for: INNGEST_ENV (or the client’s env option) names the branch environment and always wins; Vercel’s VERCEL_GIT_COMMIT_REF is detected automatically, while Netlify, Cloudflare Pages, Railway and Render must pass their branch variable. Sponson does not choose the environment: the app must be configured so.
  • IN3 (verified, source): A branch environment is created on demand the first time an app syncs with it (or events are sent to it), and every branch environment shares one signing key and one event key, so a preview needs no per-branch key.
  • IN4 (verified, source): Without x-inngest-sync-kind: in-band (which this recipe never sends) the serve handler syncs out of band: it calls Inngest’s register endpoint itself and answers with that call’s status and a JSON body { message, modified }; a failed registration answers 500 (Failed to register).
  • IN5 (verified, source): Sync only once the new version of the code is live: some hosts roll a deploy out over seconds or minutes and an early sync registers the old functions. The recipe’s trigger is the deployment’s URL (a deploy line’s external output, so the line waits for the deploy) and a revision.
  • IN6 (verified, source): Branch environments are archived automatically three days after their latest deployment (archiving stops functions from triggering and keeps the data), and archiving cannot be undone per app; there is no request to undo a sync, so destroy sends nothing and forgets the record.
  • IN7 (verified, source): The REST API’s POST /v2/apps/{appId}/syncs (marked beta, x-beta: true) takes { url } and answers a sync with status pending, success, error or duplicate, but the operation declares no header or parameter that selects a branch environment; this recipe does not use it.
  • IN8 (unverified): Syncing the same deployment again is harmless (the same functions registered again, modified: false), so the at-least-once delivery of an action (sent again after a crash before it was recorded) needs no idempotency key.
  • IN9 (unverified): A preview behind Vercel Deployment Protection answers the PUT with 401 unless it carries x-vercel-protection-bypass with the project’s automation bypass secret; pass it with headers, as a { secret }.

inngest.sync

App sync into its branch environment (http.action). Registers a preview deployment’s Inngest functions by sending PUT to its serve endpoint, once per deployment URL and revision; the app’s SDK picks the branch environment (INNGEST_ENV, IN2) and signs the registration with its own key (IN1), so no Inngest credential is needed. Covers: inngest-sync in the coverage matrix. Outputs: modified. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.
What the line expands into: the http.action params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

Kinde

Recipe file: packages/adapters/recipes/kinde.yaml. Verified on 2026-10-11 against: Assumptions:
  • KI1 (verified, source): A machine-to-machine application authorized for the Management API gets a token from POST https://<business>.kinde.com/oauth2/token (form-encoded grant_type=client_credentials, client_id, client_secret, audience=https://<business>.kinde.com/api); API calls go to https://<business>.kinde.com/api/v1/…. The recipe writes the token URL and audience as paths, so a plan’s base_url: https://<business>.kinde.com/api/v1 names the business once.
  • KI2 (verified, source): GET /api/v1/applications/{app_id}/auth_redirect_urls (scope read:applications_redirect_uris) answers { redirect_urls: [...] }; PUT on the same path (scope update:applications_redirect_uris) replaces all of an application’s callback URLs with the body’s urls array. (POST adds and DELETE ?urls= removes; the recipe replaces the list it read, under a lock, so that one apply is one idempotent write.)
  • KI3 (verified, source): GET /api/v1/applications/{app_id}/auth_logout_urls answers { logout_urls: [...] } and PUT replaces them with the body’s urls array, as for callback URLs.
  • KI4 (verified, source): The audience is always the business’s kinde.com subdomain (https://<business>.kinde.com/api), even when a custom domain serves sign-in, so base_url names the kinde.com subdomain.
  • KI5 (verified, source): KINDE_MANAGEMENT_CLIENT_ID and KINDE_MANAGEMENT_CLIENT_SECRET are the variables Kinde’s Management API SDK reads for the machine-to-machine client.
  • KI6 (unverified): A PUT with an empty urls list is accepted and leaves the application with no URLs of that kind (destroying the last preview URL of an application that has no others).
  • KI7 (unverified): URLs are read back exactly as written (no normalisation), and the replace has no precondition: the line locks the list (lockOn) against other Sponson runs; a console edit made between the read and the write can still be lost.

kinde.callback_url

Application callback URL (http.list_item). One URL in a Kinde application’s Allowed callback URLs, such as the preview’s /api/auth/kinde_callback. Read-modify-write: the list is read (redirect_urls) and replaced (urls) with the URL added, under a lock on the list; removed on destroy; every other URL is left as it is. Covers: kinde-callbacks in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

kinde.logout_url

Application allowed logout redirect URL (http.list_item). One URL in a Kinde application’s Allowed logout redirect URLs, such as the preview’s home page. Read (logout_urls) and replaced (urls) with the URL added, under a lock on the list; removed on destroy; every other URL is left as it is. Covers: kinde-callbacks in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Mailgun

Recipe file: packages/adapters/recipes/mailgun.yaml. Verified on 2026-10-12 against: Assumptions:
  • MG1 (verified, source): Requests authenticate with HTTP Basic, the user name api and an API key as the password; the US API is https://api.mailgun.net, the EU one https://api.eu.mailgun.net (an EU account sets base_url in the provider block).
  • MG2 (verified, source): POST /v1/webhooks creates an account-level webhook from url (required), event_types (required, one of accepted, clicked, opened, unsubscribed, delivered, permanent_fail, temporary_fail, complained; repeated for several) and description, and answers { webhook_id } only; it receives the events of every domain of the account. At most three URLs per event type (a fourth answers 409 ‘unable to add more than 3 urls for event type …’), so at most three previews (fewer when the account has its own webhooks) can each have one at a time.
  • MG3 (verified, source): GET /v1/webhooks answers { webhooks: [...] } and GET /v1/webhooks/{webhook_id} one webhook, each with webhook_id, description, url, event_types (a list) and created_at; PUT /v1/webhooks/{webhook_id} replaces url, description and event types whole (204), DELETE on the same path removes it (204), and an unknown id answers 404.
  • MG4 (verified, source): Lists in request bodies are the key repeated: the OpenAPI document declares action with style: form, explode: true and says to use event_types ‘multiple times to specify multiple event types’, hence form_arrays: repeat.
  • MG5 (unverified): The account-webhook operations, documented as multipart/form-data, accept the same fields application/x-www-form-urlencoded, as Mailgun’s v3 operations (documented so) do.
  • MG6 (verified, source): Webhook changes ‘can take up to 10 minutes to become effective due to caching’: a new preview may miss its first events.
  • MG7 (verified, source): Routes are account-wide (‘defined globally, per account, not per domain’). POST /v3/routes takes priority (default 0, lower first), description, expression (required) and action (repeated), and answers { message, route }; GET /v3/routes/{id} answers { route } (404 when unknown); GET /v3/routes answers { total_count, items }, limit up to 1000; PUT /v3/routes/{id} updates only the fields sent and answers { message, route }; DELETE /v3/routes/{id} removes it. A route object carries id, priority, description, expression, actions (a list) and created_at.
  • MG8 (verified, source): forward("https://…") posts a matching message to the URL; every route is evaluated unless a higher-priority one runs stop(); match_recipient('regex') filters on the recipient. The receiving domain needs its MX records pointing at Mailgun.
  • MG9 (unverified): The API answers description, url, event_types, expression and actions as they were written, so an unchanged webhook or route hashes the same on every plan, and a route’s description (not required to be unique by Mailgun) or a webhook’s identifies what this recipe made: two equal ones make find refuse with PARAM_INVALID.
  • MG10 (unverified): A per-domain webhook (v3 …/domains/{domain}/webhooks/{event}, a list of up to three URLs per event; v4 …/webhooks?url=, answered as a map of event to URLs) is not an object of its own that a later run can read back by an id, so this recipe uses account webhooks; the preview’s handler must ignore events of mail it did not send (every webhook gets every domain’s events).

mailgun.webhook

Account webhook (http.resource). An account-level webhook that posts the chosen email events to the preview’s own URL, url + path (usually { from: web.preview_url }). Identified by its description (name), so a new preview URL updates it in place. It receives every domain’s events (MG10), and Mailgun takes at most three URLs per event type (MG2): at most three previews at a time. A console edit of its URL or events is drift. Covers: mailgun-webhook-route in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

mailgun.route

Inbound route (http.resource). An inbound route per preview that forwards mail matching expression (such as match_recipient('pr-42@inbound.example.com')) to the preview’s own URL, url + path. Identified by its description (name); a console edit of its priority, expression or action is drift. The receiving domain is long-lived and shared (MX records at Mailgun, MG8); each preview gets its own address on it. Covers: mailgun-webhook-route in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

MongoDB Atlas

Recipe file: packages/adapters/recipes/mongodb_atlas.yaml. Verified on 2026-10-11 against: Assumptions:
  • MA1 (verified, source): Programmatic API keys authenticate with HTTP Digest (the public key as the user name, the private key as the password); service accounts take an OAuth2 client-credentials token instead (https://cloud.mongodb.com/api/oauth/token).
  • MA2 (verified, source): The Atlas CLI keeps the key pair in MONGODB_ATLAS_PUBLIC_API_KEY and MONGODB_ATLAS_PRIVATE_API_KEY.
  • MA3 (verified, source): Requests and answers use the versioned media type application/vnd.atlas.2023-01-01+json (in Accept and Content-Type); the database-user operations are published under that version.
  • MA4 (verified, source): POST /groups/{groupId}/databaseUsers creates a user from databaseName, groupId, roles and username (required), with password for SCRAM users (write-only, never answered), optional scopes (absent: every cluster of the project) and deleteAfterDate (Atlas deletes the user then; within one week of the request). It answers 201 with the user.
  • MA5 (verified, source): GET, PATCH and DELETE /groups/{groupId}/databaseUsers/{databaseName}/{username} read, update and delete one user; SCRAM users authenticate against databaseName admin.
  • MA6 (verified, source): A project supports 100 database users by default (900 at most).
  • MA7 (unverified): A GET of a user that does not exist answers 404; a second create of the same user answers 409.
  • MA8 (unverified): roles (each { databaseName, roleName, collectionName? }) and scopes (each { name, type }) are answered in the order they were sent, so they compare without drift.
  • MA9 (unverified): PATCH takes the user’s identifying fields (databaseName, groupId, username) with the changed ones and keeps the password when none is sent.

mongodb_atlas.database_user

Database user (http.resource). A SCRAM database user per scope on a shared Atlas cluster, such as app-pr-42 with readWrite on the database app_pr_42, instead of a cluster per preview. The password is an input: a { secret: … } reference (a value per environment in a secret manager) or a sensitive output of another line, sent on create only and never compared. Located by its user name; a console edit of its roles or scopes is drift; destroy deletes the user (not the database’s data). Covers: mongodb-atlas-user in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

New Relic

Recipe file: packages/adapters/recipes/newrelic.yaml. Verified on 2026-10-12 against: Assumptions:
  • NR1 (verified, source): NerdGraph is one GraphQL endpoint, https://api.newrelic.com/graphql (EU accounts: https://api.eu.newrelic.com/graphql, set base_url in the provider block), taking a user key in the API-Key header and a JSON body { query, variables }.
  • NR2 (verified, source): changeTrackingCreateDeployment(deployment: ChangeTrackingDeploymentInput!) records a deployment marker on an entity from version and entityGuid (both required) and optionally user, description, deploymentType (BASIC, BLUE_GREEN, CANARY, ROLLING, SHADOW, OTHER), deepLink, commit, changelog, groupId and timestamp (within 24 hours of now), and answers deploymentId and entityGuid. New Relic recommends changeTrackingCreateEvent for new work but says this mutation ‘isn’t being retired and continues to work’.
  • NR3 (verified, source): The input type’s name is ChangeTrackingDeploymentInput, as New Relic’s own Go client declares the mutation’s variable.
  • NR4 (unverified): NerdGraph has no mutation that deletes or edits a deployment marker (the REST v2 deployment endpoints, which could delete one, reach end of life on 2027-07-31), so destroy sends nothing and forgets the record: a marker is history.
  • NR5 (unverified): NerdGraph takes no idempotency key: a marker sent again after a crash before Sponson recorded it is a second marker for the same version (harmless, at least once).
  • NR6 (unverified): A preview that reports to its own entity (an app name per environment, such as web (preview)) gets its markers there; a preview sharing production’s entity would mark production’s charts, so give previews their own entity or leave this line to production.

newrelic.deployment

Deployment marker (http.action). A change-tracking deployment marker on the environment’s entity (entity_guid) for a release, sent once per entity and version (usually the commit). Nothing to undo: markers are history and NerdGraph deletes none (NR4). Covers: newrelic-deployment in the coverage matrix. Outputs: deployment_id. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.
What the line expands into: the http.action params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Okta

Recipe file: packages/adapters/recipes/okta.yaml. Verified on 2026-10-11 against: Assumptions:
  • OK1 (verified, source): An API token is sent as Authorization: SSWS <token> (the apiToken security scheme; Okta recommends scoped OAuth tokens, which need private_key_jwt and are not supported here). Every path is under https://<org>.okta.com/api/v1.
  • OK2 (verified, source): PUT /api/v1/apps/{appId} replaces an application: all required properties must be in the body (no partial update), and system-assigned properties (id, name, status, created, lastUpdated) in the body are ignored. So the recipe sends the whole application read with GET /api/v1/apps/{appId} back, with one URI added or removed (parent.send: parent).
  • OK3 (verified, source): An OIDC application keeps its redirect URIs in settings.oauthClient.redirect_uris and its sign-out redirect URIs in settings.oauthClient.post_logout_redirect_uris, both arrays of strings.
  • OK4 (unverified): The read-only members of the GET answer that the replace documentation does not list (_links, _embedded, credentials.oauthClient.client_id) are ignored when sent back, as the listed system-assigned ones are.
  • OK5 (unverified): The application replace has no precondition: the line locks the application (lockOn, shared by both URI ops) against other Sponson runs; a console edit made between the read and the write can still be lost (the next apply reports it as drift).
  • OK6 (verified, source): POST /api/v1/trustedOrigins creates a trusted origin from name, origin and scopes ([{ type: CORS }, { type: REDIRECT }], or IFRAME_EMBED); GET /api/v1/trustedOrigins lists them as a bare array, q prefix-matching name and origin, limit up to 200; PUT /api/v1/trustedOrigins/{id} replaces one; DELETE /api/v1/trustedOrigins/{id} answers 204. name and origin are each unique.
  • OK7 (unverified): A trusted origin is found among the first 200 that q=<origin> matches (pagination follows a Link header, which the http adapter does not read); an org with more than 200 origins starting with the same text may not find it and would see the create refused as a duplicate.
  • OK8 (unverified): Scopes are read back as written ([{ type: CORS }, { type: REDIRECT }], no added members), so they can be compared as drift.
  • OK9 (verified, source): OKTA_API_TOKEN is the variable the Okta Terraform provider reads for an API token.

okta.redirect_uri

OIDC application sign-in redirect URI (http.list_item). One URI in an Okta OIDC application’s sign-in redirect URIs (settings.oauthClient.redirect_uris), such as the preview’s /login/callback. The application is read, and written back whole with the URI added (Okta replaces applications only as a whole), under a lock on the application; removed on destroy; every other URI and setting is sent back as read. Covers: okta-redirect in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

okta.post_logout_redirect_uri

OIDC application sign-out redirect URI (http.list_item). One URI in an Okta OIDC application’s sign-out redirect URIs (settings.oauthClient.post_logout_redirect_uris), such as the preview’s home page. Written back with the whole application, under the application’s lock; removed on destroy; every other URI and setting is sent back as read. Covers: okta-redirect in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

okta.trusted_origin

Trusted origin (CORS and redirect) (http.resource). A trusted origin for the preview, so the Okta Sign-In Widget and Auth JS may call Okta from it (CORS) and Okta may redirect to it after sign-in or sign-out (REDIRECT). Found by its origin; its name and scopes are compared as drift and replaced with PUT; deleted on destroy. Covers: okta-redirect in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

PostHog

Recipe file: packages/adapters/recipes/posthog.yaml. Verified on 2026-10-11 against: Assumptions:
  • PH1 (verified, source): The private API is at https://us.posthog.com (US cloud) or https://eu.posthog.com (EU cloud; set base_url), authenticated with a personal API key as Authorization: Bearer, with the feature_flag:read and feature_flag:write scopes.
  • PH2 (verified, source): GET /api/projects/{project_id}/feature_flags/{id}/ (trailing slash) answers the flag bare, with its release conditions in filters.groups; PATCH on the same path updates it and answers it bare.
  • PH3 (verified, source): A condition group is { properties: [{ key, type, operator, value }], rollout_percentage, variant }; exact takes a list of values. Groups have no id, so the group is identified by the first value of its first property (the preview host).
  • PH4 (verified, source): A PATCH of filters merges top-level keys into the stored filters ({**stored_filters, **filters}): sending { filters: { groups } } replaces the groups and keeps multivariate, payloads and the rest. This is in the serializer’s source, not the docs.
  • PH5 (unverified): Self-hosted or older PostHog versions merge filters the same way (the merge arrived with 2026’s validation work). On a version that replaces filters whole, every write would drop the flag’s variants and payloads: check before pointing the recipe at one.
  • PH6 (unverified): A group is read back as written (the serializer drops unknown keys and adds none to the declared ones), so the compared properties, rollout_percentage and variant do not show as changed on every plan.
  • PH7 (verified, source): A flag under an approval policy answers a PATCH with 409 (approval_required or change_request_pending); the apply then fails with PROVIDER_CONFLICT rather than waiting for the approval.

posthog.release_condition

Flag release condition (http.list_item). A release condition (condition group) on an existing feature flag that matches the preview, such as $host exact [pr-42.preview.example.com] at 100%. Groups are OR’d; the flag’s other groups, variants and payloads are left as they are. A console edit of its properties, rollout or variant is drift. Destroy removes the group (with the last group gone, the flag matches no one). Covers: posthog-release-condition in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Postmark (Account API)

Recipe file: packages/adapters/recipes/postmark.yaml. Verified on 2026-10-11 against: Assumptions:
  • PM1 (verified, source): The Servers API authenticates with the account token in X-Postmark-Account-Token (account owner only) and takes and answers JSON.
  • PM2 (verified, source): POST /servers creates a server from Name (required), Color, DeliveryType (Live or Sandbox, fixed after creation), InboundHookUrl, TrackOpens, TrackLinks and more, answering the server with its integer ID, its settings and ApiTokens (the server’s tokens).
  • PM3 (verified, source): GET /servers?count=&offset=&name= answers { TotalCount, Servers: [...] }; name is a substring search (MyServer also matches MyServer Production), so the recipe matches the exact Name among the results.
  • PM4 (verified, source): PUT /servers/{serverid} changes the settings sent (everything but DeliveryType); GET /servers/{serverid} answers the server with its ApiTokens, so the server token stays readable.
  • PM5 (verified, source): DELETE /servers/{serverid} removes a server, but is not enabled for every account: where Postmark support has not enabled it, destroy fails and the line needs destroy: keep.
  • PM6 (unverified): Server names are unique in the account (a second create with the same name is refused), and an account has fewer than 500 servers, so the first page of count=500 holds the one find looks for.
  • PM7 (unverified): Color, TrackOpens, TrackLinks and InboundHookUrl are answered as sent (InboundHookUrl empty when unset), so they compare without drift.

postmark.server

Server (http.resource). A Postmark server per environment, such as sponson-preview-pr-42, with its inbound webhook URL and tracking settings. Its token is a sensitive output for the app’s environment ({ from: <line>.server_token }). Located by its name; a console edit of its color, tracking or inbound hook URL is drift. Its webhooks: postmark_server.webhook with credential: { from: <this line>.server_token }. Covers: postmark-server-webhook in the coverage matrix. Outputs: id, server_token (sensitive). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Postmark (Server API)

Recipe file: packages/adapters/recipes/postmark_server.yaml. Verified on 2026-10-11 against: Assumptions:
  • PS1 (verified, source): The Webhooks API authenticates with a server’s token in X-Postmark-Server-Token, so it manages the webhooks of that one server.
  • PS2 (verified, source): POST /webhooks creates a webhook from Url (required), MessageStream (default outbound), HttpAuth, HttpHeaders and Triggers (Open { Enabled, PostFirstOpenOnly }, Click, Delivery, Bounce { Enabled, IncludeContent }, SpamComplaint { Enabled, IncludeContent }, SubscriptionChange, each with Enabled), answering the webhook with its integer ID.
  • PS3 (verified, source): GET /webhooks (optionally ?MessageStream=) answers { Webhooks: [...] } with each webhook’s ID, Url, MessageStream and Triggers.
  • PS4 (verified, source): PUT /webhooks/{Id} changes Url, HttpAuth, HttpHeaders and Triggers (not MessageStream), re-verifying the endpoint when the URL or the triggers change; DELETE /webhooks/{Id} removes it.
  • PS5 (verified, source): Postmark tests the endpoint when a webhook is created; Verify: false saves it without the test (a preview that is not deployed yet would fail it).
  • PS6 (unverified): A URL identifies one webhook of the server (two webhooks with the same URL, on different streams, make find refuse with PARAM_INVALID), and every trigger’s Enabled is answered as a boolean even when it was not sent.
  • PS7 (unverified): PostFirstOpenOnly and IncludeContent are left at Postmark’s defaults; only which events are delivered is compared.
  • PS8 (unverified): A server’s webhooks belong to it: deleting the server (DELETE /servers/{id}, Account API) removes them, so a webhook line authenticated with the server’s own token can leave its destroy to the server’s line.

postmark_server.webhook

Webhook of a server (http.resource). A webhook of one Postmark server delivering to an environment’s URL, such as the preview’s /api/postmark handler, with the events it enables. Located by its URL; a console edit of an event’s Enabled is drift. The server is the one whose token is in POSTMARK_SERVER_TOKEN (a shared staging server), or, for a server the same plan creates per environment (postmark.server), the line says credential: { from: <server line>.server_token } and destroy: keep: deleting the server removes its webhooks (PS8). Covers: postmark-server-webhook in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Prisma Postgres

Recipe file: packages/adapters/recipes/prisma.yaml. Verified on 2026-10-11 against: Assumptions:
  • PP1 (verified, source): The Management API at https://api.prisma.io/v1 authenticates with a workspace service token as Authorization: Bearer; Prisma’s guide keeps it in PRISMA_SERVICE_TOKEN.
  • PP2 (verified, source): Answers are enveloped: one object as { data: {…} }, a list as { data: [...], pagination: { nextCursor, hasMore } }, paged with ?cursor=.
  • PP3 (verified, source): POST /v1/projects/{projectId}/databases with { name, region, source? } creates a database (201); source: { type: database, databaseId } copies an existing one. It answers the database with id, name, status (provisioning or ready) and connections[].
  • PP4 (verified, source): GET /v1/projects/{projectId}/databases lists the project’s databases with id and name; it has no name filter, so the list is searched page by page.
  • PP5 (unverified): Database names are not unique (the project-scoped create documents no 409), so two databases with one name make find.match ambiguous, which is refused with PARAM_INVALID rather than guessed. Sponson never creates a second one: it looks the name up before every create.
  • PP6 (verified, source): GET /v1/databases/{databaseId} answers the database (404 when it does not exist); DELETE on the same path deletes it (204; 404 when gone; 403 for a project’s default database).
  • PP7 (unverified): The connection strings (connections[0].endpoints.direct.connectionString, and .pooled when there is a pooled endpoint) are in the create answer only: list and get answer the endpoints’ host and port without them. So they are once-only outputs (ADR 0018), and a create whose answer was lost leaves dependents to be refused until the database is recreated. Inferred from the schemas; not stated in the docs.
  • PP8 (unverified): connections[0] is the database’s default connection, present in every create answer.
  • PP9 (unverified): A database answered with status: provisioning accepts connections within seconds; nothing waits for ready (the API has no readiness the http adapter can poll), so a dependent deployment that starts at once may see connection errors for that long.
  • PP10 (verified, source): Connection strings have the form postgres://USER:PASSWORD@db.prisma.io:5432/?sslmode=require (direct) and postgres://USER:PASSWORD@pooled.db.prisma.io:5432/?sslmode=require (pooled).

prisma.database

Database (http.resource). An empty Postgres database per scope in a Prisma Postgres project, such as app-pr-42. Its connection strings exist only in the create answer, so they are once-only outputs: reference them from the line that needs them (an env var) in the same plan. Located by name; only its existence is compared for drift; destroy deletes it and its data. Covers: prisma-postgres-database in the coverage matrix. Outputs: id, status, direct_url (sensitive, once: only from the create), pooled_url (sensitive, once: only from the create). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

prisma.database_copy

Database copied from another (http.resource). A Postgres database per scope copied from an existing one (source: { type: database }), such as app-pr-42 with the data of app. Otherwise like prisma.database: once-only connection strings, existence-only drift, destroy deletes the copy (never the source). Covers: prisma-postgres-database in the coverage matrix. Outputs: id, status, direct_url (sensitive, once: only from the create), pooled_url (sensitive, once: only from the create). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Upstash QStash

Recipe file: packages/adapters/recipes/qstash.yaml. Verified on 2026-10-11 against: Assumptions:
  • QS1 (verified, source): Requests authenticate with the QStash token as Authorization: Bearer; the SDKs read it from QSTASH_TOKEN.
  • QS2 (verified, source): Each region (qstash.upstash.io = EU, qstash-us-east-1.upstash.io) has its own token and its own schedules; the default here is the EU URL, and a US account overrides base_url in its block.
  • QS3 (verified, source): POST /v2/schedules/{destination} creates a schedule: the cron in Upstash-Cron, the method used to call the destination in Upstash-Method (default POST), the request body as the message delivered on every run. It answers { scheduleId }.
  • QS4 (verified, source): With Upstash-Schedule-Id, a schedule with that id is updated with the new settings if it exists (an upsert): the id is the client’s, the read path names it, and the same request is both create and update.
  • QS5 (verified, source): The official example writes the destination URL in the path as it is (/v2/schedules/https://example.com), hence the {+destination} placeholder.
  • QS6 (unverified): A destination URL with a query string arrives intact with its ? escaped as %3F (a raw ? would end the path); not documented.
  • QS7 (verified, source): GET /v2/schedules/{scheduleId} answers the schedule bare with scheduleId, cron, destination, method and body, and 404 when there is none; DELETE on the same path removes it.
  • QS8 (unverified): Deleting a schedule that is already gone answers 404 (only 200 is documented), which destroy treats as done.
  • QS9 (verified, source): Schedule ids are alphanumerics, hyphens, periods and underscores (400 otherwise); a cron may carry a CRON_TZ=<zone> prefix and runs at most once a minute.
  • QS10 (unverified): GET returns cron, destination and method exactly as they were sent (the cron string unnormalised), so comparing them is not drift on every plan.

qstash.schedule

Schedule (http.resource). A cron schedule that calls a preview’s URL, such as /api/cron of pr-42, under a schedule id per scope. Created and updated by the same upsert (QS4); a console edit of its cron, destination or method is drift; destroy deletes it. The message body is sent on create and update but not compared. Covers: qstash-schedule in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Railway

Recipe file: packages/adapters/recipes/railway.yaml. Verified on 2026-10-11 against: Assumptions:
  • RY1 (verified, source): The public API is one GraphQL endpoint, https://backboard.railway.com/graphql/v2; account, workspace and OAuth tokens are sent as Authorization: Bearer <token>, project tokens in Project-Access-Token instead (a project token therefore needs a hand-written block with auth: { header: Project-Access-Token, value_env: RAILWAY_TOKEN }).
  • RY2 (verified, source): The Railway CLI reads an account or workspace token from RAILWAY_API_TOKEN and a project token from RAILWAY_TOKEN, so the bearer credential of this recipe is RAILWAY_API_TOKEN.
  • RY3 (verified, source): Failures may be HTTP 200 with an errors array (a denied query among them); 400 is a parse or validation failure, 429 a rate limit with Retry-After. Queries are safe to retry; a mutation only when no answer arrived.
  • RY4 (verified, source): variables(projectId, environmentId, serviceId?, unrendered?) answers the variables of a service in an environment (without serviceId, the environment’s shared variables) as a map of name to value; unrendered: true answers references such as ${{Postgres.DATABASE_URL}} as written instead of resolved, so a value is compared as it was set.
  • RY5 (verified, source): variableUpsert(input: { projectId, environmentId, serviceId?, name, value, skipDeploys? }) creates or replaces one variable (without serviceId, a shared variable); by default it triggers a redeploy of the service (skipDeploys defaults to false).
  • RY6 (verified, source): variableDelete(input: { projectId, environmentId, serviceId?, name }) deletes one variable.
  • RY7 (verified, source): Railway creates a PR environment when a pull request is opened (with PR environments enabled) and deletes it, with its variables, when the pull request is merged or closed. The recipe takes the environment’s id as a param: the plan copies it from the dashboard, railway status, or a line that outputs it; the recipe does not look environments up by name (their naming is not documented).
  • RY8 (unverified): Deleting a variable whose environment Railway already removed (the pull request closed) is an error saying the environment was not found, which the adapter reads as already gone; deleting a variable that does not exist answers true.
  • RY9 (unverified): Sponson does not wait for the redeploy a write triggers (RY5): a step that needs the running service to see the value waits on Railway’s deployment status itself. A reference value written as ${{…}} is not supported in a plan, where ${…} is Sponson’s own interpolation.

railway.variable

Variable of a service in an environment (http.resource). One variable of a service (or, without service_id, a shared variable) in a Railway environment, typically the PR environment Railway created. Upserted with variableUpsert, read unrendered from variables, removed with variableDelete. A console edit of the value is drift. Railway redeploys the service after each write unless skip_deploys is set; Sponson does not wait for that deploy (RY9). Covers: railway-variables in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

Render

Recipe file: packages/adapters/recipes/render.yaml. Verified on 2026-10-11 against: Assumptions:
  • RE1 (verified, source): Requests authenticate with an API key as Authorization: Bearer; the Render CLI reads it from RENDER_API_KEY.
  • RE2 (verified, source): GET /services/{serviceId}/env-vars/{envVarKey} answers the variable bare, { key, value }, and 404 when it does not exist.
  • RE3 (verified, source): PUT /services/{serviceId}/env-vars/{envVarKey} with { value } adds the variable or replaces its value (200, { key, value }): a create whose answer was lost is the same request again, and the variable’s name is its identity.
  • RE4 (verified, source): DELETE /services/{serviceId}/env-vars/{envVarKey} removes the variable (204).
  • RE5 (unverified): Deleting a variable that is already gone answers 404 (documented only as “Unable to find the requested resource”), which destroy treats as done.
  • RE6 (verified, source): Changing environment variables through the API does not deploy the service, whatever its autoDeploy setting (stated for the replace-all endpoint); a running preview sees a new value only after its next deploy.
  • RE7 (unverified): The single-variable PUT does not deploy either (its page is silent; assumed like the replace-all endpoint).
  • RE8 (verified, source): Render creates service previews and preview environments itself (per pull request, from the service’s Previews setting or render.yaml); a preview copies the base service’s variables once, when it is created. No API looks up the preview of pull request N, so the line takes the service’s id.
  • RE9 (unverified): GET returns the value in plain text for every variable, including ones generated with generateValue, so the value can be compared for drift.

render.env_var

Service environment variable (http.resource). One environment variable on a Render service, such as a preview service’s API_URL, written by name (PUT adds or replaces it). A console edit of its value is drift; destroy deletes it. The service’s id is a param: Render creates preview services itself, and writing a variable does not deploy (RE6). Covers: render-env in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Resend

Recipe file: packages/adapters/recipes/resend.yaml. Verified on 2026-10-11 against: Assumptions:
  • RS1 (verified, source): POST /api-keys creates an API key from name (at most 50 characters), permission (full_access or sending_access) and domain_id (with sending_access only), answering { id, token }; requests carry Authorization: Bearer <key>.
  • RS2 (verified, source): GET /api-keys answers { object: list, has_more, data: [{ id, name, created_at, last_used_at }] }, every key when no limit is sent, and never the token: the token is shown only in the create answer, so it is a once-only output.
  • RS3 (verified, source): DELETE /api-keys/{api_key_id} removes a key; there is no endpoint to read or change one key, so its permission and domain are sent on create and never compared.
  • RS4 (verified, source): POST /webhooks creates a webhook from endpoint and events (both required), answering { object, id, signing_secret }.
  • RS5 (verified, source): GET /webhooks/{webhook_id} answers the webhook with status (enabled/disabled), endpoint, events and signing_secret: the secret stays readable, so it is an ordinary sensitive output.
  • RS6 (verified, source): GET /webhooks answers { object: list, has_more, data: [{ id, created_at, status, endpoint, events }] }, every webhook when no limit is sent.
  • RS7 (verified, source): PATCH /webhooks/{webhook_id} changes endpoint, events and status; DELETE on the same path removes the webhook.
  • RS8 (unverified): Key names and webhook endpoints are not unique in Resend: the recipe takes the name (keys) and the endpoint URL (webhooks) as the identity, and refuses (PARAM_INVALID) when two match, so give each environment its own name and URL.
  • RS9 (unverified): Creating a key or a webhook needs a full_access key; a deleted object answers 404.

resend.api_key

API key (http.resource). A restricted API key per environment, such as sponson-preview-pr-42 allowed only to send from one domain. Its token is a once-only output: pass it to the app’s environment line in the same run ({ from: <line>.token }); later runs keep what that line holds, and apply --recreate <line> issues a new one. Only its existence is compared for drift: Resend shows no permission or domain for a key. Covers: resend-key-webhook in the coverage matrix. Outputs: id, token (sensitive, once: only from the create). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

resend.webhook

Webhook (http.resource). A webhook delivering an environment’s email events to its URL, such as the preview’s /api/resend handler. Located by its endpoint URL; a console edit of its events is drift (its enabled/disabled status is left to the console: the create request takes none). Its signing secret is a sensitive output, readable on every run. Covers: resend-key-webhook in the coverage matrix. Outputs: id, signing_secret (sensitive). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Rollbar

Recipe file: packages/adapters/recipes/rollbar.yaml. Verified on 2026-10-12 against: Assumptions:
  • RB1 (verified, source): POST https://api.rollbar.com/api/1/deploy records a deploy from environment and revision (required), rollbar_username, local_username, comment and status (started, succeeded by default, failed, timed_out), authenticated by a project access token with the post_server_item scope in X-Rollbar-Access-Token, and answers 200 with data.deploy_id, an integer.
  • RB2 (unverified): The token in the header is enough: the reference also lists access_token as a body field, which this recipe does not send (a credential in a body would be part of what the request logs show).
  • RB3 (verified, source): The API reference has report, get, update and list operations for deploys and none that deletes one, so destroy sends nothing and forgets the record: a deploy is history.
  • RB4 (unverified): Rollbar takes no idempotency key for deploys: one sent again after a crash before Sponson recorded it is a second deploy of the same revision (harmless, at least once).
  • RB5 (unverified): A deploy is attributed to errors of the environment of the same name that the SDK reports (environment in its configuration), so a preview’s deploys stay apart from production’s when the preview’s SDK reports its own environment, such as preview.

rollbar.deploy

Deploy (http.action). A deploy of the release’s revision in the environment’s Rollbar environment (such as preview), sent once per environment and revision, so errors are attributed to the deploy. Nothing to undo: Rollbar deletes no deploys (RB3). Covers: rollbar-deploy in the coverage matrix. Outputs: deploy_id. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.
What the line expands into: the http.action params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Amazon S3 / Cloudflare R2 (S3 API)

Recipe file: packages/adapters/recipes/s3.yaml. Verified on 2026-10-11 against: Assumptions:
  • SS1 (verified, source): PUT /?cors on the bucket’s endpoint replaces its whole CORS configuration with a CORSConfiguration document of up to 100 CORSRules (each ID, AllowedOrigins, AllowedMethods, AllowedHeaders, ExposeHeaders, MaxAgeSeconds) and requires Content-MD5, which the SigV4 signer adds for s3.
  • SS2 (verified, source): GET /?cors answers the configuration as XML.
  • SS3 (verified, source): DELETE /?cors removes the configuration (the way to leave a bucket with no rule).
  • SS4 (verified, source): R2 implements PutBucketCors, GetBucketCors and DeleteBucketCors at https://<ACCOUNT_ID>.r2.cloudflarestorage.com; its SigV4 region is auto, and us-east-1 is accepted as an alias, so the recipe’s region works for R2 unchanged.
  • SS5 (unverified): A bucket without a CORS configuration answers 404 NoSuchCORSConfiguration (read as an empty list, parent.if_missing: empty), and an empty configuration is refused as MalformedXML (so the last rule is removed with DELETE, parent.when_empty: delete).
  • SS6 (unverified): R2 accepts path-style requests (https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<bucket>/?cors), so its base URL names the bucket in the path.
  • SS7 (unverified): S3 answers a rule’s elements as they were written; a rule’s ID identifies it among the bucket’s rules (IDs are not required to be unique by S3, so other writers must not reuse the recipe’s).

s3.bucket_cors

CORS rule of a bucket (http.list_item). A CORS rule of an S3 or R2 bucket allowing the preview’s origin, identified by its ID (such as sponson-pr-42); every other rule is left as it is. The base URL names the bucket: https://<bucket>.s3.<region>.amazonaws.com (set auth.aws_sigv4.region to the bucket’s region), or https://<account>.r2.cloudflarestorage.com/<bucket> for R2. A console edit of the rule is drift; destroy removes only this rule (the whole configuration when it was the last one). Covers: s3-r2-cors in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Sanity

Recipe file: packages/adapters/recipes/sanity.yaml. Verified on 2026-10-12 against: Assumptions:
  • SN1 (verified, source): The Projects API is served at https://api.sanity.io/<version> (default v2021-06-07) and authenticates with a robot token as Authorization: Bearer; the Sanity CLI reads a token for unattended use from SANITY_AUTH_TOKEN.
  • SN2 (verified, source): Managing a project’s datasets and settings needs the Administrator or Developer role (Developer only on Growth and Enterprise); editors can modify existing datasets but cannot create new ones.
  • SN3 (verified, source): PUT /projects/{projectId}/datasets/{name} with an optional { aclMode } (public, the default, or private, a paid feature) creates an empty dataset and answers 200 with { datasetName, aclMode }, not the dataset itself, so the recipe finds it by name afterwards (create.locate).
  • SN4 (verified, source): GET /projects/{projectId}/datasets answers a bare list of { name, aclMode, … }; dataset names are unique in a project.
  • SN5 (verified, source): DELETE /projects/{projectId}/datasets/{name} deletes the dataset and its documents and answers { deleted: true }.
  • SN6 (verified, source): A dataset name is 1 to 64 characters of lowercase letters, digits, - and _, beginning and ending with a letter or digit.
  • SN7 (verified, source): Free and Growth plans include 2 datasets (more on Growth cost $999 per dataset per month), and copying a dataset (Cloud Clone, an asynchronous job) is an Enterprise feature; so a dataset per preview is practical on Enterprise, or with one preview dataset at a time below it. This recipe makes an empty dataset; its content is the app’s import or seed (application data, not Sponson’s), and Cloud Clone copies are not covered.
  • SN8 (verified, source): A browser querying the Content Lake (or a Studio, or the Presentation tool) from an origin must find that origin in the project’s CORS origins; preview deployments get a new URL per branch, so the origin cannot be added ahead of time, and unused preview origins should be removed. https://*.vercel.app with credentials is unsafe.
  • SN9 (verified, source): GET /projects/{projectId}/cors answers a bare list of { id, origin, allowCredentials, createdAt } with integer ids; POST /projects/{projectId}/cors with { origin, allowCredentials? } creates one (201); DELETE /projects/{projectId}/cors/{id} deletes it.
  • SN10 (unverified): No update of a CORS origin or of a dataset’s aclMode is documented, so neither is compared for drift: allowCredentials and aclMode are sent when the line creates the object, and only its existence is checked after.
  • SN11 (unverified): A dataset being deleted (or a PUT of a name that exists) answers within the request; nothing waits, and a dataset is usable as soon as the PUT returns.

sanity.dataset

Dataset per preview (http.resource). An empty Sanity dataset per scope in a project, such as preview-pr-42, for the preview to import or seed its content into (SN7: copying production into it is Enterprise’s Cloud Clone and is not covered). Located by name; only its existence is compared for drift; destroy deletes the dataset and its documents. Covers: sanity-dataset in the coverage matrix. Outputs: id, name. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

sanity.cors_origin

CORS origin (http.resource). The preview deployment’s origin in the project’s CORS origins (API → CORS origins), so the preview’s browser code, an embedded Studio or the Presentation tool can query the Content Lake (SN8). Located by origin; removed on destroy; every other origin is left alone. Covers: sanity-cors in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it). Console link in the PR comment: https://www.sanity.io/manage/project/{project_id}/api (pattern documented).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

SendGrid

Recipe file: packages/adapters/recipes/sendgrid.yaml. Verified on 2026-10-11 against: Assumptions:
  • SG1 (verified, source): POST /v3/user/webhooks/event/settings creates an Event Webhook from url (required), enabled, one boolean per event type (processed, delivered, dropped, deferred, bounce, open, click, spam_report, unsubscribe, group_unsubscribe, group_resubscribe) and friendly_name, answering 201 with the webhook and its string id; requests carry the API key as Authorization: Bearer. EU regional subusers use https://api.eu.sendgrid.com.
  • SG2 (verified, source): Each URL can be used by only one Event Webhook (a second create answers 400 naming the existing id), so the URL identifies the webhook; the number of webhooks is limited by the plan (400 beyond it).
  • SG3 (verified, source): GET /v3/user/webhooks/event/settings/all answers { max_allowed, webhooks: [...] } with every webhook’s settings and id.
  • SG4 (verified, source): PATCH /v3/user/webhooks/event/settings/{id} requires url and updates the fields sent; DELETE on the same path answers 204, and 404 for an unknown id. Without an id these paths act on the oldest webhook, which the recipe never does (its paths always carry the id).
  • SG5 (verified, source): Signature verification and its public key are set through a separate endpoint and are not part of the webhook’s create or update; this recipe does not manage them.
  • SG6 (verified, source): POST /v3/user/webhooks/parse/settings creates an Inbound Parse setting from url, hostname, spam_check and send_raw, answering 201 with those four fields bare; GET, PATCH and DELETE (204) /v3/user/webhooks/parse/settings/{hostname} read, update and remove it. The hostname must be on a domain authenticated in SendGrid, with an MX record pointing at SendGrid.
  • SG7 (unverified): A PATCH that leaves out an event flag keeps its current value (the documentation’s examples send only url); the recipe sends every flag it compares anyway.
  • SG8 (unverified): A parse setting for a hostname that exists already is refused with an error status (not documented which); the next run then finds it by its hostname.

sendgrid.event_webhook

Event Webhook (http.resource). An Event Webhook delivering an environment’s email events to its URL, such as the preview’s /api/sendgrid/events handler. Located by its URL (unique in SendGrid); a console edit of its enabled flag, an event flag or its friendly name is drift. Covers: sendgrid-webhook in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

sendgrid.parse_setting

Inbound Parse setting (http.resource). An Inbound Parse host per environment, such as pr-42.parse.example.com, posting the mail it receives to the environment’s URL. Named by its hostname; a console edit of its URL, spam check or raw flag is drift. The hostname’s domain must be authenticated in SendGrid and its MX record point at SendGrid (a wildcard record serves every environment). Covers: sendgrid-webhook in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Sentry

Recipe file: packages/adapters/recipes/sentry.yaml. Verified on 2026-10-11 against: Assumptions:
  • SE1 (verified, source): POST /api/0/organizations/{organization_id_or_slug}/releases/ creates a release from version and projects (both required), ref, url, dateReleased and commit data, with a bearer token holding project:releases, project:write, project:admin or org:ci; it answers 201 (or 208).
  • SE2 (verified, source): A version is one release across the organization’s projects, and GET /api/0/organizations/{org}/releases/{version}/ answers it (404 when there is none), so the version names the release.
  • SE3 (verified, source): PUT /api/0/organizations/{org}/releases/{version}/ updates ref, url, dateReleased and commit data (the documentation says an omitted dateReleased is the current time), and answers the release.
  • SE4 (verified, source): DELETE /api/0/organizations/{org}/releases/{version}/ permanently removes a release and its files (project:releases or project:admin). The recipe keeps releases on destroy: one version is shared by every environment it is deployed to.
  • SE5 (verified, source): POST /api/0/organizations/{org}/releases/{version}/deploys/ records a deploy of the release from environment (required), name, url, dateStarted, dateFinished (default now) and projects (default: all of the release’s).
  • SE6 (verified, source): GET /api/0/organizations/{org}/releases/{version}/deploys/ lists the release’s deploys; deploys cannot be deleted, so the deploy op never deletes (destroy: never).
  • SE7 (verified, source): The release’s dateReleased defaults to the current time on create, so a release this recipe creates is finalized at once.
  • SE8 (unverified): A create for a version that exists answers 208 with the existing release (not an error), so a release another tool made (sentry-cli in the build) is taken over without a second write; the recipe keeps it on destroy either way.
  • SE9 (unverified): The deploy list answers a bare array of { id, environment, name, url, dateStarted, dateFinished } with string ids, all of a release’s deploys on the first page; environment and name together identify the deploy this recipe recorded.
  • SE10 (unverified): Release url is answered as sent (null when unset); organizations hosted in another region (de.sentry.io) or self-hosted Sentry set base_url in the provider block.

sentry.release

Release (http.resource). A release for the commit, such as app@${ctx.git.sha}, in the given projects, finalized when created (SE7). Named by its version; a console edit of its URL is drift. Kept on destroy (shared history: preview and production deploy the same version); destroy: delete on the line removes it. Covers: sentry-release in the coverage matrix. Outputs: id. On destroy: left in place by default (destroy: delete on the line removes it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

sentry.deploy

Deploy of a release to an environment (http.resource). The record that a release was deployed to an environment (preview, named for the scope), which Sentry shows on the release and uses for deploy notifications. Located by environment and name among the release’s deploys. Sentry has no way to change or delete a deploy, so nothing is compared for drift and destroy leaves it (destroy: never). Covers: sentry-release in the coverage matrix. Outputs: id. On destroy: left in place: the provider offers no way to delete it (destroy: delete is refused).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Split (Harness FME)

Recipe file: packages/adapters/recipes/split.yaml. Verified on 2026-10-11 against: Assumptions:
  • SP1 (verified, source): The Admin API is at https://api.split.io/internal/api/v2, authenticated as Authorization: Bearer with a Split Admin API key (or a Harness PAT/SAT, accepted as Bearer on the non-deprecated endpoints). Host and paths are unchanged by the move to Harness; the workspace id is still Split’s wsId.
  • SP2 (verified, source): GET /splits/ws/{wsId}/{featureFlagName}/environments/{environmentIdOrName} answers the flag’s definition in the environment bare: treatments, defaultTreatment, baselineTreatment, trafficAllocation, rules, defaultRule and read-only fields (name, environment, trafficType, times).
  • SP3 (verified, source): PUT on the same path replaces the definition with treatments, defaultTreatment, baselineTreatment, trafficAllocation, rules and defaultRule (the first three of the required fields). Those are sent as read, with the rule list changed (parent.send); whether PUT takes the read-only fields back is not documented, so they are not sent.
  • SP4 (verified, source): A rule is { condition: { combiner: AND, matchers: [...] }, buckets: [{ treatment, size }] } (bucket sizes sum to 100); rules have no id and are evaluated in order. IN_LIST_STRING with attribute and strings matches an attribute in a list.
  • SP5 (unverified): A rule is read back with its matcher exactly as written (no added negate: false or null members); otherwise every plan shows the rule as changed.
  • SP6 (unverified): A full update leaves the flag’s killed state alone (killed is not a documented PUT field).
  • SP7 (verified, source): In a project that requires a title and comment on changes, they are query parameters of the update; this recipe does not send them, so such a project refuses the write and the apply fails.

split.targeting_rule

Feature flag targeting rule (http.list_item). A targeting rule on a feature flag’s definition in one environment that serves a treatment to the preview, such as if preview is in list [pr-42] serve on. Added after the flag’s other rules (evaluated in order: an earlier matching rule wins); the treatments, default rule and traffic allocation are written back as read. Identified by the matched value; a console edit of its matcher or treatment is drift. Destroy removes the rule. Covers: split-rule in the coverage matrix. Outputs: none. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.list_item params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Statsig

Recipe file: packages/adapters/recipes/statsig.yaml. Verified on 2026-10-11 against: Assumptions:
  • SG1 (verified, source): The Console API is at https://statsigapi.net/console/v1, authenticated with a Console API key in the STATSIG-API-KEY header; STATSIG-API-VERSION: 20240601 (the only version) is optional today and announced as required later, so it is always sent.
  • SG2 (verified, source): POST /gates/{id}/rule (singular) adds one rule (name, passPercentage, conditions, environments) and answers { message, data: <the whole gate> }, not the rule: the new rule is located by name afterwards (create.locate).
  • SG3 (unverified): GET /gates/{id}/rules answers the rules under data.rules (the documented example); the spec’s schema says data is a list of { rules } objects instead. If the API follows the schema, every read fails with PROVIDER_RESPONSE naming find.list_path (it does not create duplicates). The rules of one gate fit on the first page.
  • SG4 (verified, source): PATCH /gates/{id}/rules/{ruleID} updates the given fields of one rule; DELETE on the same path removes it. Rules carry a server-assigned id.
  • SG5 (unverified): Rule names are unique within a gate (not documented): the rule is identified by its name, and two rules with that name are PARAM_INVALID rather than a guess.
  • SG6 (unverified): A rule is read back with conditions exactly as written (type, operator, targetValue, and field when sent). If Statsig adds keys such as field: null to a condition, every plan shows the rule as changed.
  • SG7 (unverified): DELETE of a rule that is already gone answers 404 (the spec lists only 200); any other answer fails the destroy rather than passing silently.
  • SG8 (unverified): On a gate that requires reviews, what a Console API rule write does (apply, fail, or open a pending review) is not documented; a write that does not take effect is seen on the next read as missing or changed, never as done.

statsig.gate_rule

Feature gate rule (http.resource). A rule on an existing feature gate that passes the preview, such as custom_field preview any [pr-42]. Located by its name in the gate’s rules; a console edit of its pass percentage, conditions or environments is drift. Destroy deletes the rule. Covers: statsig-gate-rule in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Stripe

Recipe file: packages/adapters/recipes/stripe.yaml. Verified on 2026-10-11 against: Assumptions:
  • ST1 (verified, source): The API takes a secret or restricted key as Authorization: Bearer (besides Basic), and request bodies form-encoded with bracketed nesting (metadata[k]=v, enabled_events[0]=x).
  • ST2 (verified, source): Test-mode keys start with sk_test_ / rk_test_, live-mode keys with sk_live_ / rk_live_; objects of one mode are invisible to the other. The recipe refuses the live prefixes (refuse_credential) unless SPONSON_STRIPE_ALLOW_LIVE=1.
  • ST3 (unverified): Organization keys (sk_org_…) and platform-managed keys do not carry the mode in their prefix, so they are not refused: use a test-mode secret or restricted key for previews.
  • ST4 (verified, source): Every v1 POST accepts an Idempotency-Key (up to 255 characters, kept at least 24 hours); a retry with the same key returns the saved answer, and different parameters under one key are an error.
  • ST5 (verified, source): POST /v1/webhook_endpoints takes url, enabled_events (['*'] for all), description, metadata and api_version, and answers the endpoint with its signing secret populated.
  • ST6 (verified, source): GET /v1/webhook_endpoints (limit 1–100) answers { data: [...], has_more } whose endpoints carry url, enabled_events, description and metadata but no secret: the secret exists only in the create’s answer, hence the once output.
  • ST7 (verified, source): An account has at most 16 webhook endpoints, so one list page of 100 holds them all, and at most 16 previews (minus the account’s own endpoints) can have one at a time.
  • ST8 (verified, source): POST /v1/webhook_endpoints/{id} updates url, enabled_events, description, metadata and disabled in place (the secret is not rotated); DELETE on the same path removes the endpoint.
  • ST9 (unverified): The API returns url, enabled_events and description as they were written, enabled_events in the order sent, so an unchanged endpoint hashes the same on every plan.
  • ST10 (verified, source): POST /v1/products takes a client-chosen id (unique among the account’s products), name, description and active; GET /v1/products/{id} retrieves it; POST /v1/products/{id} updates it.
  • ST11 (verified, source): A product can be deleted only while it has no prices, and prices are never deleted, so the recipe archives a product (active: false) instead of deleting it.
  • ST12 (unverified): An archived product is still returned by GET /v1/products/{id}, and its id stays taken: re-applying a destroyed scope finds it and reactivates it as an adopted object, which a later destroy leaves active.
  • ST13 (verified, source): POST /v1/prices takes product, currency, unit_amount, recurring[interval], nickname, lookup_key and transfer_lookup_key (which moves the lookup key from the price holding it to the new one).
  • ST14 (verified, source): GET /v1/prices lists active prices by default, filters by up to 10 lookup_keys and by active, and answers { data: [...] }.
  • ST15 (verified, source): A price’s amount, currency and interval cannot change after creation; POST /v1/prices/{id} updates active, nickname, lookup_key and metadata. There is no delete: the recipe archives (active: false).
  • ST16 (verified, source): POST /v1/coupons takes a client-chosen id, name, percent_off or amount_off with currency, duration (once, repeating, forever) and duration_in_months.
  • ST17 (unverified): GET /v1/coupons/{id} retrieves a coupon, POST /v1/coupons/{id} updates its name and metadata only (the discount is fixed), and DELETE /v1/coupons/{id} deletes it, after which its id may be used again.
  • ST18 (unverified): Stripe must reach the endpoint’s URL over public HTTPS: a preview behind deployment protection (Vercel Authentication) receives no events unless it lets Stripe’s requests through.

stripe.webhook_endpoint

Webhook endpoint (http.resource). A test-mode webhook endpoint that sends the preview’s events to its own URL, url + path (usually { from: web.preview_url }). Identified by name in its metadata, so a new preview URL updates the endpoint in place. Its signing secret exists only in the answer that creates the endpoint: it is a once-only output, passed in the same run to the line that writes it (STRIPE_WEBHOOK_SECRET in a vercel.env or netlify.env line); later runs keep what that line holds, and apply --recreate <line> rotates it. Writing the secret rebuilds the preview, and a preview URL output follows the newest deploy, so the next run moves the endpoint to the rebuilt deploy’s URL (in place: the secret is kept); a URL that does not change per deploy (https://deploy-preview-${ctx.pr.number}--<site>.netlify.app, a custom preview domain) avoids that update. A console edit of its URL, events or description is drift. Covers: stripe-webhook in the coverage matrix. Outputs: id, secret (sensitive, once: only from the create). On destroy: deleted (destroy: keep on the line leaves it). Console link in the PR comment: https://dashboard.stripe.com/webhooks (pattern documented).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

stripe.product

Product (http.resource). A test-mode product with an id of the plan’s choosing, such as pro-pr-42, for the prices a preview sells. Destroy archives it (active: false): Stripe never deletes a product that has prices. A console edit of its name, description or active flag is drift. Covers: stripe-test-objects in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

stripe.price

Price (http.resource). A test-mode price found by its lookup key (lookup, such as pro-monthly-pr-42, which the preview’s code looks the price up by), product, currency, amount and interval. Amount, currency and interval cannot change on a Stripe price, so changing them in the plan makes a new price that takes over the lookup key, and the old one stays until the scope is destroyed. Destroy archives it (active: false): Stripe never deletes a price. A console edit of its nickname is drift. Covers: stripe-test-objects in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

stripe.coupon

Coupon (http.resource). A test-mode coupon with an id of the plan’s choosing, such as preview-pr-42, for the discount codes a preview accepts. Its discount (percent or amount, duration) is set on create and fixed after; give the line a new coupon_id to change it. Destroy deletes it. A console edit of its name is drift. Covers: stripe-test-objects in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Stytch

Recipe file: packages/adapters/recipes/stytch.yaml. Verified on 2026-10-11 against: Assumptions:
  • SY1 (verified, source): The management API authenticates with HTTP Basic: a workspace management key’s id as the user and its secret as the password.
  • SY2 (verified, source): POST /pwa/v3/projects/{project_slug}/environments/{environment_slug}/redirect_urls takes url, valid_types ({ type, is_default }, type one of LOGIN, INVITE, SIGNUP, RESET_PASSWORD, DISCOVERY) and do_not_promote_defaults, and answers { request_id, redirect_url: { url, valid_types }, status_code }.
  • SY3 (verified, source): GET …/redirect_urls/all answers { redirect_urls: [{ url, valid_types }], … }: every redirect URL of the environment, unpaged.
  • SY4 (verified, source): A redirect URL has no id of its own: GET, PUT (to change valid_types) and DELETE on …/redirect_urls?url=<url> address it by its URL.
  • SY5 (verified, source): A project has at most 100 redirect URLs.
  • SY6 (unverified): do_not_promote_defaults: true on create and delete keeps a preview URL from becoming, or a deletion from changing, the environment’s default for a type.
  • SY7 (unverified): A PUT body that also carries url (the adapter sends the identifying field back on PUT) is accepted, and valid_types read back in the order and form they were written, so an unchanged URL hashes the same on every plan.
  • SY8 (unverified): Each redirect URL is its own object (no shared list to rewrite), so concurrent previews need no lock.

stytch.redirect_url

Redirect URL (http.resource). One redirect URL of a Stytch project environment, such as the preview’s /authenticate, with the redirect types it is valid for (by default login and sign-up, never the default). Found by its URL; a console edit of its types is drift and is put back with a PUT. Destroy deletes it without promoting another URL to default. Covers: stytch-redirect in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Svix

Recipe file: packages/adapters/recipes/svix.yaml. Verified on 2026-10-11 against: Assumptions:
  • SV1 (verified, source): POST /api/v1/app/{app_id}/endpoint creates an endpoint from url (required), uid, description, eventTypes, channels, disabled, metadata and an optional secret, answering 201 with the endpoint (id ep_…, uid, url, …); requests carry Authorization: Bearer <token>.
  • SV2 (verified, source): GET, PATCH (partial update) and DELETE (204) /api/v1/app/{app_id}/endpoint/{endpoint_id} take the endpoint’s id or its uid, and {app_id} takes the application’s id or uid.
  • SV3 (verified, source): A uid is 1–256 characters of letters, digits, -, _ and ., unique among the application’s endpoints, so it names the endpoint for every later request and a create whose answer was lost is found by it.
  • SV4 (verified, source): The endpoint’s signing secret is generated when secret is null (recommended) and is readable at any time with GET …/endpoint/{endpoint_id}/secret ({ key }): it is not a once-only value, and this recipe does not output it (an endpoint answer does not carry it).
  • SV5 (verified, source): The API is served per region (api.us.svix.com, api.eu.svix.com, api.ca.svix.com, api.au.svix.com), and POST requests accept an idempotency-key header.
  • SV6 (unverified): A create with a uid that exists already answers 409 (the specification lists 409 for every operation without saying when), after which the endpoint is found by its uid and claimed only if an earlier intent of the same scope named it.
  • SV7 (unverified): description is answered as sent (default empty), disabled as a boolean and eventTypes as the list sent, so they compare without drift on every plan; accounts outside the US region set base_url in the provider block.

svix.endpoint

Application endpoint (http.resource). An endpoint of a Svix application delivering to an environment’s URL, named by a uid such as preview-pr-42. Its URL, description, event type filter and disabled flag are compared for drift; its signing secret stays readable in Svix (SV4). Covers: svix-endpoint in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Turso

Recipe file: packages/adapters/recipes/turso.yaml. Verified on 2026-10-11 against: Assumptions:
  • TU1 (verified, source): POST /v1/organizations/{organizationSlug}/databases with name, group and seed: { type: database, name: <parent> } creates a database copied from the parent, authenticated with a Platform API token as Authorization: Bearer.
  • TU2 (verified, source): The create answers { database: { DbId, Hostname, Name } } (capitalised), and 409 when a database with that name exists already.
  • TU3 (verified, source): GET /v1/organizations/{organizationSlug}/databases/{databaseName} answers { database: { Name, DbId, Hostname, group, … } }, and 404 when there is none.
  • TU4 (verified, source): DELETE on the same path removes the database; 404 when it is gone.
  • TU5 (verified, source): Database names are lower-case letters, numbers and dashes, at most 64 characters, unique in the organization; the group must exist already.
  • TU6 (unverified): The database is usable as soon as the create answers (no readiness to poll for a branch of a database in the same group).

turso.database_branch

Database branch (http.resource). A database copied from a parent database (a branch), named for the scope, such as app-pr-42 from app. Its hostname is an output for the app’s connection URL. Only its existence is compared for drift: changing group or parent later does not move or re-copy a database that exists. Covers: turso-database in the coverage matrix. Outputs: id, hostname, db_id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Twilio

Recipe file: packages/adapters/recipes/twilio.yaml. Verified on 2026-10-12 against: Assumptions:
  • TW1 (verified, source): Requests authenticate with HTTP Basic: an API key’s SID as the user name and its secret as the password, or the Account SID and the Auth Token. Request parameters are form-encoded (application/x-www-form-urlencoded).
  • TW2 (verified, source): A TwiML Application is a set of URLs Twilio fetches TwiML from, ‘most commonly used for the Voice SDKs to handle outbound calls’: a Voice SDK access token’s Voice grant names it as outgoingApplicationSid (‘The SID of the TwiML App that Twilio will look to when making outgoing calls’), so each preview’s tokens can name the preview’s own app. A phone number can also be assigned an application instead of its own URLs.
  • TW3 (verified, source): POST /2010-04-01/Accounts/{AccountSid}/Applications.json creates an application from FriendlyName (up to 64 characters), VoiceUrl, VoiceMethod, StatusCallback, SmsUrl, SmsMethod and more, answering 201; GET …/Applications.json lists them (applications, filterable by FriendlyName); GET and POST …/Applications/{Sid}.json read and update one; DELETE answers 204 and unassigns it from any phone number using it. An account holds at most 1000 applications.
  • TW4 (verified, source): Parameters are written in PascalCase (FriendlyName, VoiceUrl) and answers name the same fields in snake_case (friendly_name, voice_url, voice_method, sms_url, sms_method, status_callback), with the object’s id in sid (AP…); paths end in .json.
  • TW5 (unverified): Twilio does not require a FriendlyName to be unique (the documentation does not say); the recipe gives each preview its own, and two applications of the same name make find refuse with PARAM_INVALID. The list filtered by FriendlyName fits in its first page.
  • TW6 (unverified): Twilio answers the URLs and methods as they were written, so an unchanged application hashes the same on every plan.
  • TW7 (verified, source): A phone number has one sms_url and one voice_url (or one sms_application_sid / voice_application_sid, single strings): pointing a shared number at a preview takes it from every other environment, so a number is not per-preview state and the recipe does not manage numbers. Inbound SMS or calls per preview need a number per preview (bought and, for US SMS, A2P-registered) assigned to the preview’s application.

twilio.twiml_app

TwiML Application (http.resource). A TwiML Application per preview whose voice (and optionally messaging and status callback) URLs are the preview’s own, url + a path: the Voice SDK tokens the preview issues name it (outgoingApplicationSid, TW2), so outgoing calls from that preview reach that preview’s handlers. Identified by its FriendlyName (name), so a new preview URL updates it in place. A console edit of a URL or method is drift. Phone numbers are not managed (TW7). Covers: twilio-twiml-app in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value, { join: [...] } for its parts joined into one string, and {<name>} in a path for its URI-encoded value.

Unleash

Recipe file: packages/adapters/recipes/unleash.yaml. Verified on 2026-10-11 against: Assumptions:
  • UN1 (verified, source): Every request carries the token as it is in Authorization (Authorization: <token>, no Bearer); a personal access token or service account token (admin tokens are deprecated) reaches the Admin API. The spec also lists a Bearer scheme, but the server does not strip a Bearer prefix, so the bare form is used.
  • UN2 (verified, source): POST /api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategies with name, title, disabled, constraints and parameters adds a strategy and answers it bare, with its server-assigned id (a UUID).
  • UN3 (verified, source): GET on the same path answers a bare array of the environment’s strategies (the source returns FeatureStrategySchema[]; the docs page’s schema shows a single strategy, which is wrong).
  • UN4 (verified, source): PUT .../strategies/{strategyId} replaces a strategy’s configuration (so it sends name, title and every compared field); DELETE on the same path removes it.
  • UN5 (verified, source): A constraint is { contextName, operator, values, caseInsensitive, inverted } (caseInsensitive and inverted default to false, and are written explicitly so the comparison does not depend on defaults); parameters is a map of strings (rollout: "100").
  • UN6 (unverified): title is optional and not unique on the server: the strategy is identified by its title in the environment, and two strategies with that title are PARAM_INVALID rather than a guess.
  • UN7 (unverified): A strategy is read back with constraints and parameters exactly as written (no extra keys such as value: ""); otherwise every plan shows it as changed.
  • UN8 (verified, source): Deleting a strategy that is gone already answers 200 (nothing to do). Deleting the last strategy of an environment turns the flag off in that environment (the service calls disableEnvironmentIfNoStrategies). Adding a strategy does not turn the environment on.
  • UN9 (verified, source): With change requests enabled for the environment, these writes are refused unless the caller may bypass them; the refusal fails the apply.

unleash.flag_strategy

Flag strategy with constraints (http.resource). A flexibleRollout strategy on a flag in one environment, constrained to the preview (such as preview IN [pr-42]). Located by its title in the environment’s strategies; a console edit of its constraints, rollout parameters or disabled is drift. Destroy deletes it; if it was the environment’s last strategy, Unleash turns the flag off there (UN8). The environment itself must be enabled for the flag to be served. Covers: unleash-strategy in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Upstash Redis

Recipe file: packages/adapters/recipes/upstash.yaml. Verified on 2026-10-11 against: Assumptions:
  • UR1 (verified, source): The Developer API authenticates with HTTP Basic, the account’s email as user and an API key as password; the Upstash CLI reads them from UPSTASH_EMAIL and UPSTASH_API_KEY. Accounts created through Vercel or Fly cannot use it.
  • UR2 (verified, source): POST /v2/redis/database with { database_name, platform, primary_region, read_regions?, plan?, tls?, eviction? } creates a global database and answers it bare, with database_id, database_name, endpoint, port and state.
  • UR3 (verified, source): GET /v2/redis/databases answers every database of the account as a bare array (no paging).
  • UR4 (unverified): Database names are unique in the account, so database_name identifies one; a create whose answer was lost is found by it before another is created. (Not documented: a second database with the same name makes find.match ambiguous, which is refused with PARAM_INVALID rather than guessed.)
  • UR5 (verified, source): GET /v2/redis/database/{id} answers the database, with its credentials unless ?credentials=hide is sent.
  • UR6 (unverified): The credentials are the fields password and rest_token (the documented schema omits them; the Terraform resource and the CLI expose them), so they are readable on every run, not only from the create: ordinary sensitive outputs, not once-only ones.
  • UR7 (verified, source): DELETE /v2/redis/database/{id} deletes the database and answers 200 "OK".
  • UR8 (unverified): A database that is gone answers 404 on GET and DELETE (only 200 is documented).
  • UR9 (unverified): The database is usable when the create answers (state: active in the documented example); nothing waits for it.
  • UR10 (unverified): endpoint is the database’s host name (<name>.upstash.io); the documented example shows only its first label, so check the output before building a URL from it.

upstash.redis_database

Redis database (http.resource). A global Redis database per scope, such as app-pr-42, with its endpoint, port, password and REST token as outputs (the last two sensitive). Located by name; only its existence is compared for drift (region, plan and TLS are set on create and not compared); destroy deletes it and its data. Covers: upstash-redis-database in the coverage matrix. Outputs: id, endpoint, port, password (sensitive), rest_token (sensitive). On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

Vercel

Recipe file: packages/adapters/recipes/vercel.yaml. Verified on 2026-10-11 against: Assumptions:
  • VD1 (verified, source): Requests authenticate as Authorization: Bearer <token>; the Vercel CLI reads the token from VERCEL_TOKEN (the built-in vercel adapter uses the same variable).
  • VD2 (verified, source): POST /v10/projects/{idOrName}/domains?teamId= with { name, gitBranch, redirect, redirectStatusCode } adds the domain to the project and answers it bare: { name, apexName, projectId, verified, gitBranch, redirect, redirectStatusCode, verification[] }.
  • VD3 (verified, source): GET /v9/projects/{idOrName}/domains/{domain}?teamId= answers the same shape; the domain’s name identifies it within the project, so the read path names it and a create whose answer was lost is found by it.
  • VD4 (unverified): A domain that is not on the project answers 404 on GET and DELETE (documented with no description).
  • VD5 (verified, source): PATCH /v9/projects/{idOrName}/domains/{domain} changes gitBranch, redirect and redirectStatusCode; DELETE on the same path removes the domain from the project (200, {}).
  • VD6 (verified, source): Adding a domain that is already on a project of the account answers 409 (and, for the same project, the description says 400): someone else’s domain is never taken over by name, and the conflict surfaces as an error.
  • VD7 (verified, source): gitBranch and redirect cannot be set together, and the production branch cannot be a gitBranch (400).
  • VD8 (verified, source): A domain the account does not own answers verified: false with a verification challenge (a TXT record) that must be completed before it serves traffic; a domain bought through Vercel needs none.
  • VD9 (unverified): A free <name>.vercel.app domain that no other project uses is verified on the spot (verified: true).
  • VD10 (unverified): Every account has a team id (personal accounts are Hobby teams), so teamId can always be sent; the recipe requires it.

vercel.project_domain

Branch-bound project domain (http.resource). A domain on a Vercel project that serves one git branch’s latest preview, such as pr-42.preview.example.com for feat/x: a stable preview hostname. Located by name; a console edit of its branch or redirect is drift; destroy removes it from the project. Its verified output says whether DNS verification is still needed (VD8). Covers: vercel-domain in the coverage matrix. Outputs: id, verified, apex_name. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.

WorkOS

Recipe file: packages/adapters/recipes/workos.yaml. Verified on 2026-10-11 against: Assumptions:
  • WO1 (verified, source): POST /user_management/redirect_uris with { uri } creates a redirect URI for the environment the API key (Authorization: Bearer sk_…) belongs to, and answers the redirect_uri object (id, uri, default).
  • WO2 (verified, source): GET /user_management/redirect_uris (limit 1–100, cursors before/after) answers { data: [...], list_metadata: { before, after } }.
  • WO3 (verified, source): DELETE /user_management/redirect_uris/{id} removes one and answers an empty body. There is no GET or update by id: a URI is found in the list, and nothing about it is changed in place.
  • WO4 (verified, source): Redirect URIs may contain wildcards, except the default one.
  • WO5 (unverified): Each URI is its own object (no shared list to rewrite), so concurrent previews need no lock; a URI is returned exactly as it was created, so uri identifies it.
  • WO6 (unverified): Creating a URI that already exists is refused as a conflict (409) rather than duplicated; the line then finds the existing one.
  • WO7 (unverified): The first redirect URI of an environment becomes its default; a preview’s URI is never the first in an environment that has a production URI.

workos.redirect_uri

AuthKit redirect URI (http.resource). One redirect URI on the WorkOS environment the API key belongs to, such as the preview’s /callback: usually { from: <vercel.env line>.preview_url } joined in the app. Found by its URI in the list (following list_metadata.after); removed by id on destroy. A URI has nothing else to edit, so the only drift is a URI deleted in the Dashboard, which the next apply creates again. Covers: workos-redirect in the coverage matrix. Outputs: id. On destroy: deleted (destroy: keep on the line leaves it).
What the line expands into: the http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.