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;apidefaults to<provider>, and so does its block underproviders.http: write the block only to overridebase_url,base_url_env,auth,headersorencoding.- 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 isPARAM_INVALIDnaming it, before any request; an unknown recipe or op lists the known ones. destroy: keepleaves 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.
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:
- Control API (base URL, access tokens and their capabilities, rules)
- Control API OpenAPI document (rule_post, rule_patch, rule_response, http_rule_*)
- Webhooks (integration rules, batching, signing)
Assumptions:
- AB1 (verified, source): The Control API is at
https://control.ably.net/v1and takes an account access token asAuthorization: Bearer; managing rules needs theread:ruleandwrite:rulecapabilities, andGET /meshows a token’s. - AB2 (verified, source):
POST /apps/{app_id}/rulescreates an integration rule; anhttp(webhook) rule takesruleType: http,requestMode(singleorbatch),source(channelFilter, a regular expression over the whole channel name, andtype:channel.message,channel.presence,channel.lifecycleorchannel.occupancy),target(url,formatjsonormsgpack,enveloped,headers,signingKeyId) andstatus, and answers 201 with the rule and itsid. - AB3 (verified, source):
GET /apps/{app_id}/rulesanswers a bare array of rules,GET /apps/{app_id}/rules/{rule_id}one rule;PATCHon the same path updates the fields sent (ruleTyperequired) and answers 200;DELETEanswers 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
findrefuse 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,requestModeandstatusas written, and aPATCHthat sendstargetwithoutheaderskeeps 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).
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:
- CreateBranch
- GetBranch
- UpdateBranch
- DeleteBranch
- Amplify endpoints and quotas
- Create a signed AWS API request (SigV4)
Assumptions:
- AM1 (verified, source): The Amplify API is regional (
https://amplify.<region>.amazonaws.com) and signed with SigV4 for the serviceamplify. The recipe defaults tous-east-1; an app in another region setsbase_urlandauth(with itsregion) in the provider block, and temporary credentials (a CI role) addsession_token_env: AWS_SESSION_TOKENthere. - AM2 (verified, source):
POST /apps/{appId}/branches(CreateBranch) creates a branch frombranchName(required) and optionaldescription,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 }withbranchName,stage,description,enableAutoBuildandenvironmentVariables(a map of string to string). - AM4 (verified, source):
POST /apps/{appId}/branches/{branchName}(UpdateBranch) takes the same optional fields and answers{ branch };environmentVariablesis 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 404NotFoundException. - 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
environmentVariablesit 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: falseand 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).
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).
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:
- Get Management API access tokens for production
- Management API v2, get a client
- Management API v2, update a client
- Auth0 Terraform provider, configuration (conventional variable names)
Assumptions:
- AU1 (verified, source): A machine-to-machine application gets a Management API token from
POST https://<tenant>/oauth/token(form-encodedgrant_type=client_credentials,client_id,client_secret,audience); the audience is the Management API’s identifierhttps://<tenant>/api/v2/. The answer hasaccess_token,expires_in(86400 in the example) andtoken_type: Bearer. The recipe writes both as paths, so a plan’sbase_url: https://<tenant>/api/v2names the tenant once. - AU2 (verified, source):
GET /api/v2/clients/{id}answers the application with itscallbacks,allowed_logout_urls,web_originsandallowed_originsarrays, which only a token withread:clients(orread:client_keys) receives.fieldswithinclude_fields=truelimits the answer to the listed fields, so the recipe never reads the client secret. - AU3 (verified, source):
PATCH /api/v2/clients/{id}(scopeupdate:clients) updates an application;callbacks,allowed_logout_urls,web_originsandallowed_originsare 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_IDandAUTH0_CLIENT_SECRETare 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_urlnames 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).
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).
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).
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).
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:
- Static Sites - Create Or Update Static Site Build App Settings
- Static Sites - List Static Site Build App Settings
- Static Sites - Get Static Site Builds
- Preview environments in Azure Static Web Apps
- Configure application settings for Azure Static Web Apps
- az account get-access-token
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-01with{ properties: { <name>: <value>, … } }sets the app settings of one environment (build) and answers 200 with the sameproperties. - 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 withparent.read_method: POST. - AZ3 (verified, source): Each environment is a
builds/{environmentName}resource: production isdefault, 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 tsvprints an Azure Resource Manager access token for the signed-in account (the default resource ishttps://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).
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:
- BugSnag CLI create-build, the payload and endpoints BugSnag’s own tooling sends (pkg/build, pkg/server, pkg/endpoints)
- grunt-bugsnag, BugSnag’s documented build-reporting options
- Build and deploy integrations (the Build API’s place among them)
{ secret } param), with no API credential.
Assumptions:
- BS1 (verified, source): A build is reported with a JSON
POSTtohttps://build.bugsnag.com(https://build.bugsnag.smartbear.comfor keys of BugSnag’s SmartBear-hosted instance; an on-premise endpoint otherwise), as BugSnag’s CLI sends it withContent-Type: application/json. - BS2 (verified, source): The body carries
apiKey(the project’s notifier API key, required) andappVersion(required), and optionallyreleaseStage,builderName,sourceControl(provider,repository,revision),autoAssignReleaseandmetadata; the API key in the body is the only authentication. - BS3 (verified, source):
autoAssignRelease: trueassociates 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 toproduction, 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.
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 asAuthorization: Bearerthat has DNS Write on the zone. - CF2 (verified, source):
GET /zones/{zone_id}/dns_recordsanswers{ result: [...] }and filters bytypeandname.exact(names compare case-insensitively). - CF3 (verified, source): Single-record answers (create, details, update) carry the record under
result, with itsid. - CF4 (verified, source):
PATCH /zones/{zone_id}/dns_records/{id}updates a record;DELETEon the same path removes it permanently. - CF5 (unverified): A PATCH needs
nameandtypealongside the changed fields (the edit schema does not mark them optional), so every update sends both. - CF6 (unverified): The API returns
namelower-cased as the full name andcontentas written, sonamemust 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, sottlother than 1 withproxied: trueshows 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).
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-Versionpins the API version (YYYY-MM-DD); without it the latest version answers, which may change under a client. The guide documents2024-09-16. - CR3 (verified, source):
POST /clusters/{cluster_id}/databaseswith{ name }creates a database and answers{ name, table_count };GETon the same path lists{ databases: [{ name, table_count }], pagination: { next_page } }, paged withpagination.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-userswith{ name, password }(both required) creates a SQL user;GETon 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}/passwordchanges it. - CR5 (verified, source): A SQL user created through the API gets the SQL
adminrole 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_KEYis the variable the CockroachDB Terraform provider andccloudread 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).
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).
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:
- Public Management API
- OpenAPI spec (get value v2, replace value v2, targeting rule models)
- Terraform provider (credential variable names)
Assumptions:
- CC1 (verified, source): The API is at
https://api.configcat.com, authenticated with Basic auth from Public API credentials (the Terraform provider’sCONFIGCAT_BASIC_AUTH_USERNAME/CONFIGCAT_BASIC_AUTH_PASSWORD). - CC2 (verified, source):
GET /v2/environments/{environmentId}/settings/{settingId}/valueanswers the setting’s value in the environment bare:defaultValue,targetingRules,percentageEvaluationAttributeand read-only fields. - CC3 (verified, source):
PUTon the same path replaces the value: onlydefaultValue,targetingRulesandpercentageEvaluationAttributecan 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.isOneOfandsensitiveIsOneOfcompare 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
segmentConditionandprerequisiteFlagConditionnull and each list value’shintnull, 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/bypassApprovalis 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).
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:
- Management API overview (authorization, OpenAPI)
- Management API OpenAPI document
- Platform APIs, team access tokens
- Multiple deployments, preview deployments
- Hosting on Vercel, preview deployments
- Deploy key types
- Pricing (preview deployments on every plan)
Assumptions:
- CX1 (verified, source): The Management API at
https://api.convex.dev/v1authenticates withAuthorization: Bearerand 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 fromCONVEX_TEAM_ACCESS_TOKEN. - CX2 (verified, source):
POST /projects/{project_id}/create_deploymentwith{ type: "preview", reference }creates a deployment and answers it withname(such asplayful-otter-123),deploymentUrl,referenceanddeploymentType. 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=previewanswers a bare list of deployments with theirnameandreference;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
expiresAtsays otherwise. A preview deleted so ismissingdrift, created again by the next apply (empty: its data went with it). - CX5 (verified, source):
POST /deployments/{name}/deletedeletes the deployment with all its data and files; it takes no body (the recipe sends{}). - CX6 (verified, source):
POST /deployments/{name}/create_deploy_keywith{ name }answers only{ deployKey }, the key’s value, shown once;GET /deployments/{name}/list_deploy_keyslists the keys with theirnamebut never their value;POST /deployments/{name}/delete_deploy_keywith{ id }deletes one,idbeing the key’s value or its unique name. A preview deploy key cannot call these: the team token can. - CX7 (verified, source):
npx convex deployreads its deploy key fromCONVEX_DEPLOY_KEYand deploys to the deployment the key names; a preview deployment’s URL is what the frontend’sNEXT_PUBLIC_CONVEX_URL(orVITE_CONVEX_URL,CONVEX_URL) must hold. - CX8 (unverified): A deploy key made for a preview deployment with
create_deploy_keyletsnpx convex deploypush 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
findbyreferencesees 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).
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).
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:
- Synthetics API reference
- API authentication (keys, sites)
- Datadog API v1 OpenAPI document (datadog-api-client)
Assumptions:
- DD1 (verified, source): Requests carry the API key in
DD-API-KEYand the application key inDD-APPLICATION-KEY; Datadog’s clients read them fromDD_API_KEYandDD_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, setbase_urlin the provider block. - DD3 (verified, source):
POST /synthetics/tests/apicreates an API test fromname,type(api),config(requestwithmethodandurl,assertions),locations,optionsandmessage(all required), withsubtype(http),status(liveorpaused) andtags, answering it with itspublic_id. - DD4 (verified, source):
GET /synthetics/tests/api/{public_id}answers one API test;PUTon the same path replaces its configuration with a whole test definition. - DD5 (verified, source):
GET /synthetics/testslists every test as{ tests: [...] }(page_sizeandpage_numberare optional);POST /synthetics/tests/deletewith{ public_ids: [...] }deletes tests. - DD6 (unverified): Without
page_sizethe list answers every test of the organisation, and a test name identifies one test (two with one name makefindrefuse 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).
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:
- Identity Toolkit API v2, projects.getConfig
- Identity Toolkit API v2, projects.updateConfig
- Identity Toolkit API v2, Config
- Authenticate for using REST
Assumptions:
- FB1 (verified, source):
GET https://identitytoolkit.googleapis.com/admin/v2/projects/{project}/configanswers the project’s AuthConfig. - FB2 (verified, source):
PATCH …/admin/v2/projects/{project}/config?updateMask=authorizedDomainsupdates the config with aConfigbody; fields set in the body but not named inupdateMaskare ignored, so the write changes the authorized domains only. It needs one of theidentitytoolkit,firebaseorcloud-platformOAuth scopes. - FB3 (verified, source):
authorizedDomainsis 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-tokenprints an access token for the signed-in credentials, used asAuthorization: Bearer. With user credentials some APIs also need the quota project inx-goog-user-project: addheaders: { x-goog-user-project: <project> }to theproviders.http.firebaseblock (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).
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:
- Firebase Hosting REST API discovery document (v1beta1, sites.channels)
- Test, preview, then deploy (preview channels)
- Manage preview channels, releases and versions
- GitHub pull request integration (a channel per PR)
- Firebase predefined roles (Hosting Admin)
- firebase-tools hosting API client (channel create, get, delete)
Assumptions:
- FH1 (verified, source): The API is served at
https://firebasehosting.googleapis.com/v1beta1with a Google OAuth access token (scopefirebaseorcloud-platform) asAuthorization: Bearer; Firebase Hosting Admin is the role for writes. With user credentials some Google APIs also need the quota project inx-goog-user-project: addheaders: { x-goog-user-project: <project> }to theproviders.http.firebase_hostingblock (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 aChannelbody with optionalttl(input only, a duration such as604800s),labelsandretainedReleaseCount;GETandDELETE /sites/{site}/channels/{id}read and delete it; thelivechannel cannot be deleted. - FH3 (verified, source): A
Channelanswersname(sites/SITE_ID/channels/CHANNEL_ID),url(the channel’sweb.appURL, also served onfirebaseapp.com),expireTime,retainedReleaseCount(1 to 100, 10 by default) andlabels;PATCH …/channels/{id}?updateMask=updates the masked fields. - FH4 (verified, source): Without
ttlorexpireTimea 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 asmissingdrift 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, andliveis 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).
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 fromnameandproject(both required) and optionaldescription, answering it (201) with its integeridand itsapi_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,PATCHandDELETE /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
findsees them all (nextis 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
findrefuse with PARAM_INVALID). - FS7 (unverified):
descriptionis answered as sent;FLAGSMITH_MASTER_API_KEYis 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).
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:
- Secrets and Fly Apps
- superfly/fly-go resource_secrets.go (the GraphQL operations flyctl sends)
- superfly/fly-go schema.graphql
- Working with the Machines API (tokens)
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 listshows 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, runsponson apply --destroyfor 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 --stagedoes) reaches only Machines started or updated afterwards. Order the line before the PR’sfly deploy; after a later change runfly secrets deploy. - FS5 (unverified): A token from
fly tokens create(FlyV1 fm2_…) orfly auth tokenis accepted asAuthorization: Bearer <token>by the GraphQL API, as the Machines API documents for the sameFLY_API_TOKEN. (fly-go itself sends a macaroon asFlyV1 <token>; a block that needs that sends it withauth: { 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).
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 carryAuthorization: Bearer <fly_api_token>. - FM2 (verified, source):
POST /v1/appswithapp_name(unique) andorg_slug(both required) and optionalnetworkcreates an app and answers 201 withidandcreated_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=truestops 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).
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:
- Buckets: patch
- Buckets resource (cors)
- Set up and view CORS configurations
- Performance tips (patch semantics)
Assumptions:
- GC1 (verified, source): The JSON API takes an OAuth access token as
Authorization: Bearer; Google’s own example gets it fromgcloud auth print-access-token. - GC2 (verified, source):
GET /b/{bucket}?fields=corsanswers the bucket’s CORS configuration;PATCH /b/{bucket}?fields=corswith{ 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
corslist is written back. - GC4 (verified, source): Each
corsentry hasorigin(list),method(list),responseHeader(list) andmaxAgeSeconds(integer). - GC5 (verified, source):
PATCHtakesifMetagenerationMatch, 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.updatepermission. - GC7 (unverified): A bucket without a CORS configuration answers
{}(nocorsfield), 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).
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:
- REST API endpoints for repository webhooks
- Authenticating to the REST API
- Getting started with the REST API (headers, API versions)
Assumptions:
- GH1 (verified, source):
POST /repos/{owner}/{repo}/hookscreates a repository webhook fromname: web,config(url,content_type,secret,insecure_ssl),eventsandactive, answering 201 with the hook (its integeridandconfig), authenticated withAuthorization: Bearer <token>. - GH2 (verified, source):
GET /repos/{owner}/{repo}/hooksanswers a bare array of hooks, each withconfig.url; it pages withper_page(at most 100) andpage. - GH3 (verified, source):
PATCH /repos/{owner}/{repo}/hooks/{hook_id}updatesconfig,events(replacing the list) andactive; a secret set before is removed unless the same or a new one is sent, so every update sends the config’surlandsecretagain. - 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
findrefuse (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=100holds the onefindlooks for. - GH8 (verified, source):
config.content_typeandconfig.insecure_sslare answered as sent (json/form, the strings"0"/"1"), andAccept: application/jsonis served likeapplication/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_hookscope; theGITHUB_TOKENof 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).
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:
- REST API reference (authentication, versioning)
- Get a feature (v2)
- Update a feature (v2)
- OpenAPI spec
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 asAuthorization: Bearer. - GB2 (verified, source): The v1 feature endpoints (rules per environment) are deprecated; in v2 a feature’s rules are one top-level
rulesarray, each rule naming itsenvironments(orallEnvironments). - 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, andrules, 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 };conditionis a JSON query serialised as a string andvalueis 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
conditionexactly 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).
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:
- Platform API reference (clients, Config Vars, Review App)
- Platform API JSON schema (config-var)
- Configuration and config vars
- CLI authentication (HEROKU_API_KEY)
Assumptions:
- HK1 (verified, source): Requests authenticate as
Authorization: Bearer <token>; the Heroku CLI reads the token fromHEROKU_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 406not_acceptable; a request without it was answered 400missing_versionwhen checked). - HK3 (verified, source):
GET /apps/{app_id_or_name}/config-varsanswers the app’s config vars as one flat map of name to string value. - HK4 (verified, source):
PATCH /apps/{app_id_or_name}/config-varswith a map sets the named vars and removes those set tonull, 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 (nullonly 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-appsanswerspendingwith a nullableapp), 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-Typeis the same vendor media type (media_typesends it as bothAcceptandContent-Type) is accepted likeapplication/json; the reference’s examples sendapplication/json. - HK8 (unverified): A missing app answers 404
not_foundon 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).
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 fromstart_time,end_time(Unix seconds),message,typeandurl(all optional;start_timedefaults to the request’s arrival), answering 201 with the marker and itsid;__all__as the dataset slug makes an environment-wide marker. Requests carry a Configuration Key with the Manage Markers permission inX-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) andapi.eu1.honeycomb.io(EU); EU teams setbase_urlin 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
findrefuse 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 notstart_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).
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:
- Syncing an Inngest app (curl -X PUT on the serve endpoint), Inngest’s integration guide
- Apps in Inngest Cloud (sync methods, when to sync)
- Environments and branch environments
- The TypeScript SDK’s serve handler (InngestCommHandler, PUT = register)
- REST API v2 OpenAPI document (V2_SyncApp)
{ secret } param), with no API credential.
Assumptions:
- IN1 (verified, source): A
PUTto 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 ownINNGEST_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’senvoption) names the branch environment and always wins; Vercel’sVERCEL_GIT_COMMIT_REFis 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 withstatuspending, 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
PUTwith 401 unless it carriesx-vercel-protection-bypasswith the project’s automation bypass secret; pass it withheaders, 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.
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:
- Kinde Management API (OpenAPI specification)
- Access the Kinde Management API
- Kinde Management API JS SDK (conventional variable names)
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-encodedgrant_type=client_credentials,client_id,client_secret,audience=https://<business>.kinde.com/api); API calls go tohttps://<business>.kinde.com/api/v1/…. The recipe writes the token URL and audience as paths, so a plan’sbase_url: https://<business>.kinde.com/api/v1names the business once. - KI2 (verified, source):
GET /api/v1/applications/{app_id}/auth_redirect_urls(scoperead:applications_redirect_uris) answers{ redirect_urls: [...] };PUTon the same path (scopeupdate:applications_redirect_uris) replaces all of an application’s callback URLs with the body’surlsarray. (POSTadds andDELETE ?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_urlsanswers{ logout_urls: [...] }andPUTreplaces them with the body’surlsarray, 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, sobase_urlnames the kinde.com subdomain. - KI5 (verified, source):
KINDE_MANAGEMENT_CLIENT_IDandKINDE_MANAGEMENT_CLIENT_SECRETare the variables Kinde’s Management API SDK reads for the machine-to-machine client. - KI6 (unverified): A
PUTwith an emptyurlslist 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).
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).
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:
- Mailgun API OpenAPI document (every operation and schema below)
- Authentication (HTTP Basic, user
api) - Account Webhooks
- Create an account-level webhook
- Get account-level webhook by ID
- Update an account-level webhook
- Domain Webhooks (the v3 and v4 per-domain alternatives)
- Routes
- Route actions
Assumptions:
- MG1 (verified, source): Requests authenticate with HTTP Basic, the user name
apiand an API key as the password; the US API ishttps://api.mailgun.net, the EU onehttps://api.eu.mailgun.net(an EU account setsbase_urlin the provider block). - MG2 (verified, source):
POST /v1/webhookscreates an account-level webhook fromurl(required),event_types(required, one of accepted, clicked, opened, unsubscribed, delivered, permanent_fail, temporary_fail, complained; repeated for several) anddescription, 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/webhooksanswers{ webhooks: [...] }andGET /v1/webhooks/{webhook_id}one webhook, each withwebhook_id,description,url,event_types(a list) andcreated_at;PUT /v1/webhooks/{webhook_id}replaces url, description and event types whole (204),DELETEon 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
actionwithstyle: form, explode: trueand says to useevent_types‘multiple times to specify multiple event types’, henceform_arrays: repeat. - MG5 (unverified): The account-webhook operations, documented as
multipart/form-data, accept the same fieldsapplication/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/routestakespriority(default 0, lower first),description,expression(required) andaction(repeated), and answers{ message, route };GET /v3/routes/{id}answers{ route }(404 when unknown);GET /v3/routesanswers{ total_count, items },limitup to 1000;PUT /v3/routes/{id}updates only the fields sent and answers{ message, route };DELETE /v3/routes/{id}removes it. A route object carriesid,priority,description,expression,actions(a list) andcreated_at. - MG8 (verified, source):
forward("https://…")posts a matching message to the URL; every route is evaluated unless a higher-priority one runsstop();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,expressionandactionsas 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 makefindrefuse 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).
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).
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:
- Atlas Administration API v2 (OpenAPI document)
- Create One Database User in One Project
- Configure Atlas API access (API keys use HTTP Digest)
- Atlas CLI environment variables
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_KEYandMONGODB_ATLAS_PRIVATE_API_KEY. - MA3 (verified, source): Requests and answers use the versioned media type
application/vnd.atlas.2023-01-01+json(inAcceptandContent-Type); the database-user operations are published under that version. - MA4 (verified, source):
POST /groups/{groupId}/databaseUserscreates a user fromdatabaseName,groupId,rolesandusername(required), withpasswordfor SCRAM users (write-only, never answered), optionalscopes(absent: every cluster of the project) anddeleteAfterDate(Atlas deletes the user then; within one week of the request). It answers 201 with the user. - MA5 (verified, source):
GET,PATCHandDELETE /groups/{groupId}/databaseUsers/{databaseName}/{username}read, update and delete one user; SCRAM users authenticate againstdatabaseNameadmin. - 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? }) andscopes(each{ name, type }) are answered in the order they were sent, so they compare without drift. - MA9 (unverified):
PATCHtakes 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).
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:
- Track changes with NerdGraph (changeTrackingCreateDeployment, changeTrackingCreateEvent)
- Introduction to NerdGraph (endpoints, the API-Key header)
- New Relic’s Go client, the mutation as its CLI and Terraform provider send it (ChangeTrackingDeploymentInput)
- User keys
Assumptions:
- NR1 (verified, source): NerdGraph is one GraphQL endpoint,
https://api.newrelic.com/graphql(EU accounts:https://api.eu.newrelic.com/graphql, setbase_urlin the provider block), taking a user key in theAPI-Keyheader and a JSON body{ query, variables }. - NR2 (verified, source):
changeTrackingCreateDeployment(deployment: ChangeTrackingDeploymentInput!)records a deployment marker on an entity fromversionandentityGuid(both required) and optionallyuser,description,deploymentType(BASIC,BLUE_GREEN,CANARY,ROLLING,SHADOW,OTHER),deepLink,commit,changelog,groupIdandtimestamp(within 24 hours of now), and answersdeploymentIdandentityGuid. New Relic recommendschangeTrackingCreateEventfor 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.
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:
- Okta Management API (OpenAPI specification, management-minimal.yaml)
- Applications API, replace an application
- Trusted Origins API
- Okta Terraform provider, configuration (conventional variable names)
Assumptions:
- OK1 (verified, source): An API token is sent as
Authorization: SSWS <token>(theapiTokensecurity scheme; Okta recommends scoped OAuth tokens, which needprivate_key_jwtand are not supported here). Every path is underhttps://<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 withGET /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_urisand its sign-out redirect URIs insettings.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/trustedOriginscreates a trusted origin fromname,originandscopes([{ type: CORS }, { type: REDIRECT }], orIFRAME_EMBED);GET /api/v1/trustedOriginslists them as a bare array,qprefix-matchingnameandorigin,limitup to 200;PUT /api/v1/trustedOrigins/{id}replaces one;DELETE /api/v1/trustedOrigins/{id}answers 204.nameandoriginare each unique. - OK7 (unverified): A trusted origin is found among the first 200 that
q=<origin>matches (pagination follows aLinkheader, 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_TOKENis 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).
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).
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).
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:
- Private API (hosts, personal API keys)
- Feature flags API
- OpenAPI schema (FeatureFlag, PersonalAPIKeyAuth)
- Feature flag serializer (filters validation and merge)
- Feature flag filters schema (condition groups)
Assumptions:
- PH1 (verified, source): The private API is at
https://us.posthog.com(US cloud) orhttps://eu.posthog.com(EU cloud; setbase_url), authenticated with a personal API key asAuthorization: Bearer, with thefeature_flag:readandfeature_flag:writescopes. - PH2 (verified, source):
GET /api/projects/{project_id}/feature_flags/{id}/(trailing slash) answers the flag bare, with its release conditions infilters.groups;PATCHon the same path updates it and answers it bare. - PH3 (verified, source): A condition group is
{ properties: [{ key, type, operator, value }], rollout_percentage, variant };exacttakes 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
filtersmerges top-level keys into the stored filters ({**stored_filters, **filters}): sending{ filters: { groups } }replaces the groups and keepsmultivariate,payloadsand the rest. This is in the serializer’s source, not the docs. - PH5 (unverified): Self-hosted or older PostHog versions merge
filtersthe same way (the merge arrived with 2026’s validation work). On a version that replacesfilterswhole, 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_percentageandvariantdo not show as changed on every plan. - PH7 (verified, source): A flag under an approval policy answers a PATCH with 409 (
approval_requiredorchange_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).
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 /serverscreates a server fromName(required),Color,DeliveryType(LiveorSandbox, fixed after creation),InboundHookUrl,TrackOpens,TrackLinksand more, answering the server with its integerID, its settings andApiTokens(the server’s tokens). - PM3 (verified, source):
GET /servers?count=&offset=&name=answers{ TotalCount, Servers: [...] };nameis a substring search (MyServeralso matchesMyServer Production), so the recipe matches the exactNameamong the results. - PM4 (verified, source):
PUT /servers/{serverid}changes the settings sent (everything butDeliveryType);GET /servers/{serverid}answers the server with itsApiTokens, 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 needsdestroy: 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=500holds the onefindlooks for. - PM7 (unverified):
Color,TrackOpens,TrackLinksandInboundHookUrlare answered as sent (InboundHookUrlempty 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).
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 /webhookscreates a webhook fromUrl(required),MessageStream(defaultoutbound),HttpAuth,HttpHeadersandTriggers(Open { Enabled, PostFirstOpenOnly },Click,Delivery,Bounce { Enabled, IncludeContent },SpamComplaint { Enabled, IncludeContent },SubscriptionChange, each withEnabled), answering the webhook with its integerID. - PS3 (verified, source):
GET /webhooks(optionally?MessageStream=) answers{ Webhooks: [...] }with each webhook’sID,Url,MessageStreamandTriggers. - PS4 (verified, source):
PUT /webhooks/{Id}changesUrl,HttpAuth,HttpHeadersandTriggers(notMessageStream), 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: falsesaves 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
findrefuse with PARAM_INVALID), and every trigger’sEnabledis answered as a boolean even when it was not sent. - PS7 (unverified):
PostFirstOpenOnlyandIncludeContentare 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).
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:
- Management API OpenAPI document
- Management API guide (base URL, service tokens)
- Connecting to your database (connection strings)
Assumptions:
- PP1 (verified, source): The Management API at
https://api.prisma.io/v1authenticates with a workspace service token asAuthorization: Bearer; Prisma’s guide keeps it inPRISMA_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}/databaseswith{ name, region, source? }creates a database (201);source: { type: database, databaseId }copies an existing one. It answers the database withid,name,status(provisioningorready) andconnections[]. - PP4 (verified, source):
GET /v1/projects/{projectId}/databaseslists the project’s databases withidandname; 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.matchambiguous, 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);DELETEon 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.pooledwhen 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: provisioningaccepts connections within seconds; nothing waits forready(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) andpostgres://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).
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).
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:
- Authentication
- Create a schedule
- Get a schedule
- Delete a schedule
- API examples (raw destination URL in the path)
- Multi-region (base URLs, tokens)
Assumptions:
- QS1 (verified, source): Requests authenticate with the QStash token as
Authorization: Bearer; the SDKs read it fromQSTASH_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 overridesbase_urlin its block. - QS3 (verified, source):
POST /v2/schedules/{destination}creates a schedule: the cron inUpstash-Cron, the method used to call the destination inUpstash-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 withscheduleId,cron,destination,methodandbody, and 404 when there is none;DELETEon 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):
GETreturnscron,destinationandmethodexactly 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).
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:
- Public API (endpoint, tokens, errors)
- Manage Variables with the Public API
- Environments (PR environments)
- CLI (RAILWAY_TOKEN and RAILWAY_API_TOKEN)
- railwayapp/cli GraphQL schema
Assumptions:
- RY1 (verified, source): The public API is one GraphQL endpoint,
https://backboard.railway.com/graphql/v2; account, workspace and OAuth tokens are sent asAuthorization: Bearer <token>, project tokens inProject-Access-Tokeninstead (a project token therefore needs a hand-written block withauth: { header: Project-Access-Token, value_env: RAILWAY_TOKEN }). - RY2 (verified, source): The Railway CLI reads an account or workspace token from
RAILWAY_API_TOKENand a project token fromRAILWAY_TOKEN, so the bearer credential of this recipe isRAILWAY_API_TOKEN. - RY3 (verified, source): Failures may be HTTP 200 with an
errorsarray (a denied query among them); 400 is a parse or validation failure, 429 a rate limit withRetry-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 (withoutserviceId, the environment’s shared variables) as a map of name to value;unrendered: trueanswers 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 (withoutserviceId, a shared variable); by default it triggers a redeploy of the service (skipDeploysdefaults 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).
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:
- Authentication
- Retrieve environment variable
- Add or update environment variable
- Delete environment variable
- Update environment variables (replace all; deploy behaviour)
- Service previews
- Render CLI (RENDER_API_KEY)
Assumptions:
- RE1 (verified, source): Requests authenticate with an API key as
Authorization: Bearer; the Render CLI reads it fromRENDER_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):
GETreturns the value in plain text for every variable, including ones generated withgenerateValue, 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).
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:
- Create API key
- List API keys
- Delete API key
- Create webhook
- Retrieve webhook
- List webhooks
- Update webhook
- Delete webhook
Assumptions:
- RS1 (verified, source):
POST /api-keyscreates an API key fromname(at most 50 characters),permission(full_accessorsending_access) anddomain_id(withsending_accessonly), answering{ id, token }; requests carryAuthorization: Bearer <key>. - RS2 (verified, source):
GET /api-keysanswers{ object: list, has_more, data: [{ id, name, created_at, last_used_at }] }, every key when nolimitis 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 /webhookscreates a webhook fromendpointandevents(both required), answering{ object, id, signing_secret }. - RS5 (verified, source):
GET /webhooks/{webhook_id}answers the webhook withstatus(enabled/disabled),endpoint,eventsandsigning_secret: the secret stays readable, so it is an ordinary sensitive output. - RS6 (verified, source):
GET /webhooksanswers{ object: list, has_more, data: [{ id, created_at, status, endpoint, events }] }, every webhook when nolimitis sent. - RS7 (verified, source):
PATCH /webhooks/{webhook_id}changesendpoint,eventsandstatus;DELETEon 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_accesskey; 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).
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).
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:
- Report a deploy
- Deploy tracking
- Project access tokens and their scopes
- API reference index (deploy endpoints)
Assumptions:
- RB1 (verified, source):
POST https://api.rollbar.com/api/1/deployrecords a deploy fromenvironmentandrevision(required),rollbar_username,local_username,commentandstatus(started,succeededby default,failed,timed_out), authenticated by a project access token with thepost_server_itemscope inX-Rollbar-Access-Token, and answers 200 withdata.deploy_id, an integer. - RB2 (unverified): The token in the header is enough: the reference also lists
access_tokenas 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 (
environmentin its configuration), so a preview’s deploys stay apart from production’s when the preview’s SDK reports its own environment, such aspreview.
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.
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 /?corson the bucket’s endpoint replaces its whole CORS configuration with aCORSConfigurationdocument of up to 100CORSRules (eachID,AllowedOrigins,AllowedMethods,AllowedHeaders,ExposeHeaders,MaxAgeSeconds) and requiresContent-MD5, which the SigV4 signer adds fors3. - SS2 (verified, source):
GET /?corsanswers the configuration as XML. - SS3 (verified, source):
DELETE /?corsremoves 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 isauto, andus-east-1is 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 asMalformedXML(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
IDidentifies 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).
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:
- Projects API reference (datasets, CORS origins)
- Datasets (naming rules, plan limits, environments)
- CORS origins (preview deployments)
- HTTP API authentication (robot tokens)
- Cloud Clone (dataset copy)
- CLI configuration (SANITY_AUTH_TOKEN)
- Pricing (datasets per plan)
Assumptions:
- SN1 (verified, source): The Projects API is served at
https://api.sanity.io/<version>(defaultv2021-06-07) and authenticates with a robot token asAuthorization: Bearer; the Sanity CLI reads a token for unattended use fromSANITY_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, orprivate, 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}/datasetsanswers 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.appwith credentials is unsafe. - SN9 (verified, source):
GET /projects/{projectId}/corsanswers a bare list of{ id, origin, allowCredentials, createdAt }with integer ids;POST /projects/{projectId}/corswith{ 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
aclModeis documented, so neither is compared for drift:allowCredentialsandaclModeare sent when the line creates the object, and only its existence is checked after. - SN11 (unverified): A dataset being deleted (or a
PUTof a name that exists) answers within the request; nothing waits, and a dataset is usable as soon as thePUTreturns.
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).
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).
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:
- Create an Event Webhook
- Get all Event Webhooks
- Get an Event Webhook
- Update an Event Webhook
- Delete an Event Webhook
- Create a parse setting
- Retrieve a specific parse setting
- Update a parse setting
- Delete a parse setting
Assumptions:
- SG1 (verified, source):
POST /v3/user/webhooks/event/settingscreates an Event Webhook fromurl(required),enabled, one boolean per event type (processed,delivered,dropped,deferred,bounce,open,click,spam_report,unsubscribe,group_unsubscribe,group_resubscribe) andfriendly_name, answering 201 with the webhook and its stringid; requests carry the API key asAuthorization: Bearer. EU regional subusers usehttps://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/allanswers{ max_allowed, webhooks: [...] }with every webhook’s settings andid. - SG4 (verified, source):
PATCH /v3/user/webhooks/event/settings/{id}requiresurland updates the fields sent;DELETEon 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/settingscreates an Inbound Parse setting fromurl,hostname,spam_checkandsend_raw, answering 201 with those four fields bare;GET,PATCHandDELETE(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).
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).
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:
- Create a New Release for an Organization
- Retrieve an Organization’s Release
- Update an Organization’s Release
- Delete an Organization’s Release
- Create a Deploy
- List a Release’s Deploys
- Release Management (sentry-cli)
Assumptions:
- SE1 (verified, source):
POST /api/0/organizations/{organization_id_or_slug}/releases/creates a release fromversionandprojects(both required),ref,url,dateReleasedand commit data, with a bearer token holdingproject:releases,project:write,project:adminororg: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}/updatesref,url,dateReleasedand commit data (the documentation says an omitteddateReleasedis 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:releasesorproject: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 fromenvironment(required),name,url,dateStarted,dateFinished(default now) andprojects(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
dateReleaseddefaults 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;environmentandnametogether identify the deploy this recipe recorded. - SE10 (unverified): Release
urlis answered as sent (null when unset); organizations hosted in another region (de.sentry.io) or self-hosted Sentry setbase_urlin 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).
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).
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:
- Authentication
- Get feature flag definition in environment
- Full update feature flag definition in environment
- Feature flag definition, rule, condition, matcher
- Matcher types
- API for Split admins after the move to Harness
Assumptions:
- SP1 (verified, source): The Admin API is at
https://api.split.io/internal/api/v2, authenticated asAuthorization: Bearerwith 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’swsId. - SP2 (verified, source):
GET /splits/ws/{wsId}/{featureFlagName}/environments/{environmentIdOrName}answers the flag’s definition in the environment bare:treatments,defaultTreatment,baselineTreatment,trafficAllocation,rules,defaultRuleand read-only fields (name,environment,trafficType, times). - SP3 (verified, source):
PUTon the same path replaces the definition withtreatments,defaultTreatment,baselineTreatment,trafficAllocation,rulesanddefaultRule(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_STRINGwithattributeandstringsmatches an attribute in a list. - SP5 (unverified): A rule is read back with its matcher exactly as written (no added
negate: falseor null members); otherwise every plan shows the rule as changed. - SP6 (unverified): A full update leaves the flag’s killed state alone (
killedis 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).
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:
- Console API introduction (base URL, STATSIG-API-KEY, STATSIG-API-VERSION)
- Add gate rule
- Read gate rules
- Update gate rules (one rule)
- Delete gate rule
- Console API OpenAPI spec (20240601)
Assumptions:
- SG1 (verified, source): The Console API is at
https://statsigapi.net/console/v1, authenticated with a Console API key in theSTATSIG-API-KEYheader;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}/rulesanswers the rules underdata.rules(the documented example); the spec’s schema saysdatais a list of{ rules }objects instead. If the API follows the schema, every read fails with PROVIDER_RESPONSE namingfind.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;DELETEon the same path removes it. Rules carry a server-assignedid. - 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
conditionsexactly as written (type,operator,targetValue, andfieldwhen sent). If Statsig adds keys such asfield: nullto 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).
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:
- Authentication
- API keys
- Idempotent requests
- Create a webhook endpoint
- List all webhook endpoints
- Update a webhook endpoint
- Delete a webhook endpoint
- Webhooks in the Dashboard (endpoint limit)
- Create a product
- Retrieve a product
- Delete a product
- Create a price
- List all prices
- Update a price
- Create a coupon
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 withsk_live_/rk_live_; objects of one mode are invisible to the other. The recipe refuses the live prefixes (refuse_credential) unlessSPONSON_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
POSTaccepts anIdempotency-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_endpointstakesurl,enabled_events(['*']for all),description,metadataandapi_version, and answers the endpoint with its signingsecretpopulated. - ST6 (verified, source):
GET /v1/webhook_endpoints(limit1–100) answers{ data: [...], has_more }whose endpoints carryurl,enabled_events,descriptionandmetadatabut nosecret: the secret exists only in the create’s answer, hence theonceoutput. - 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}updatesurl,enabled_events,description,metadataanddisabledin place (the secret is not rotated);DELETEon the same path removes the endpoint. - ST9 (unverified): The API returns
url,enabled_eventsanddescriptionas they were written,enabled_eventsin the order sent, so an unchanged endpoint hashes the same on every plan. - ST10 (verified, source):
POST /v1/productstakes a client-chosenid(unique among the account’s products),name,descriptionandactive;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/pricestakesproduct,currency,unit_amount,recurring[interval],nickname,lookup_keyandtransfer_lookup_key(which moves the lookup key from the price holding it to the new one). - ST14 (verified, source):
GET /v1/priceslists active prices by default, filters by up to 10lookup_keysand byactive, and answers{ data: [...] }. - ST15 (verified, source): A price’s amount, currency and interval cannot change after creation;
POST /v1/prices/{id}updatesactive,nickname,lookup_keyandmetadata. There is no delete: the recipe archives (active: false). - ST16 (verified, source):
POST /v1/couponstakes a client-chosenid,name,percent_offoramount_offwithcurrency,duration(once,repeating,forever) andduration_in_months. - ST17 (unverified):
GET /v1/coupons/{id}retrieves a coupon,POST /v1/coupons/{id}updates itsnameandmetadataonly (the discount is fixed), andDELETE /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).
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).
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).
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).
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:
- Create redirect URL (PWA v3)
- Get all redirect URLs (PWA v3)
- Get redirect URL (PWA v3)
- Update redirect URL (PWA v3)
- Delete redirect URL (PWA v3)
- Limits
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_urlstakesurl,valid_types({ type, is_default }, type one of LOGIN, INVITE, SIGNUP, RESET_PASSWORD, DISCOVERY) anddo_not_promote_defaults, and answers{ request_id, redirect_url: { url, valid_types }, status_code }. - SY3 (verified, source):
GET …/redirect_urls/allanswers{ 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 changevalid_types) andDELETEon…/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: trueon create and delete keeps a preview URL from becoming, or a deletion from changing, the environment’s default for a type. - SY7 (unverified): A
PUTbody that also carriesurl(the adapter sends the identifying field back on PUT) is accepted, andvalid_typesread 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).
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}/endpointcreates an endpoint fromurl(required),uid,description,eventTypes,channels,disabled,metadataand an optionalsecret, answering 201 with the endpoint (idep_…,uid,url, …); requests carryAuthorization: Bearer <token>. - SV2 (verified, source):
GET,PATCH(partial update) andDELETE(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
secretis null (recommended) and is readable at any time withGET …/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 anidempotency-keyheader. - 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):
descriptionis answered as sent (default empty),disabledas a boolean andeventTypesas the list sent, so they compare without drift on every plan; accounts outside the US region setbase_urlin 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).
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}/databaseswithname,groupandseed: { type: database, name: <parent> }creates a database copied from the parent, authenticated with a Platform API token asAuthorization: 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):
DELETEon 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).
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:
- Requests to Twilio’s APIs (authentication, form-encoded parameters)
- Applications resource (TwiML Apps)
- Access tokens (Voice grant, outgoingApplicationSid)
- API keys
- Twilio’s OpenAPI document for the 2010-04-01 API (Applications paths and schema)
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.jsoncreates an application fromFriendlyName(up to 64 characters),VoiceUrl,VoiceMethod,StatusCallback,SmsUrl,SmsMethodand more, answering 201;GET …/Applications.jsonlists them (applications, filterable byFriendlyName);GETandPOST …/Applications/{Sid}.jsonread and update one;DELETEanswers 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 insid(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
findrefuse with PARAM_INVALID. The list filtered byFriendlyNamefits 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_urland onevoice_url(or onesms_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).
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:
- Admin API overview (authorization, base URL)
- Add a strategy to a flag in an environment
- Get feature flag strategies
- Update a strategy (replace)
- Delete a strategy
- API tokens and client keys
Assumptions:
- UN1 (verified, source): Every request carries the token as it is in
Authorization(Authorization: <token>, noBearer); 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 aBearerprefix, so the bare form is used. - UN2 (verified, source):
POST /api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategieswithname,title,disabled,constraintsandparametersadds a strategy and answers it bare, with its server-assignedid(a UUID). - UN3 (verified, source):
GETon the same path answers a bare array of the environment’s strategies (the source returnsFeatureStrategySchema[]; 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 sendsname,titleand every compared field);DELETEon the same path removes it. - UN5 (verified, source): A constraint is
{ contextName, operator, values, caseInsensitive, inverted }(caseInsensitiveandinverteddefault to false, and are written explicitly so the comparison does not depend on defaults);parametersis a map of strings (rollout: "100"). - UN6 (unverified):
titleis 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
constraintsandparametersexactly as written (no extra keys such asvalue: ""); 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).
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:
- Developer API authentication
- Developer API introduction (supported accounts)
- Create a Redis database (global)
- List Redis databases
- Get a Redis database
- Delete a Redis database
- Upstash CLI (UPSTASH_EMAIL, UPSTASH_API_KEY)
- Terraform upstash_redis_database (exported credentials)
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_EMAILandUPSTASH_API_KEY. Accounts created through Vercel or Fly cannot use it. - UR2 (verified, source):
POST /v2/redis/databasewith{ database_name, platform, primary_region, read_regions?, plan?, tls?, eviction? }creates a global database and answers it bare, withdatabase_id,database_name,endpoint,portandstate. - UR3 (verified, source):
GET /v2/redis/databasesanswers every database of the account as a bare array (no paging). - UR4 (unverified): Database names are unique in the account, so
database_nameidentifies one; a create whose answer was lost is found by it before another is created. (Not documented: a second database with the same name makesfind.matchambiguous, 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=hideis sent. - UR6 (unverified): The credentials are the fields
passwordandrest_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: activein the documented example); nothing waits for it. - UR10 (unverified):
endpointis 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).
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:
- Add a domain to a project
- Get a project domain
- Update a project domain
- Remove a domain from a project
- Errors
- CLI global options (VERCEL_TOKEN)
- Working with domains (verification)
Assumptions:
- VD1 (verified, source): Requests authenticate as
Authorization: Bearer <token>; the Vercel CLI reads the token fromVERCEL_TOKEN(the built-inverceladapter 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}changesgitBranch,redirectandredirectStatusCode;DELETEon 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):
gitBranchandredirectcannot be set together, and the production branch cannot be agitBranch(400). - VD8 (verified, source): A domain the account does not own answers
verified: falsewith averificationchallenge (a TXT record) that must be completed before it serves traffic; a domain bought through Vercel needs none. - VD9 (unverified): A free
<name>.vercel.appdomain 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
teamIdcan 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).
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_uriswith{ uri }creates a redirect URI for the environment the API key (Authorization: Bearer sk_…) belongs to, and answers theredirect_uriobject (id,uri,default). - WO2 (verified, source):
GET /user_management/redirect_uris(limit1–100, cursorsbefore/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
uriidentifies 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).
http.resource params, { param: <name> } standing for a param’s value and {<name>} in a path for its URI-encoded value.