> ## Documentation Index
> Fetch the complete documentation index at: https://sponson.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Recipes

> Verified, tested building blocks for the generic http adapter: one line per provider operation.

A recipe is a verified [`http`](/reference/plan-format#the-generic-http-adapter) 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](/contributing/adr/0020-recipes).

```yaml theme={null}
providers:
  http:
    cloudflare: { recipe: cloudflare }   # optional: the recipe's API defaults; override base_url, auth, headers here
changes:
  - id: preview_dns
    adapter: http
    op: resource                         # the recipe's kind: resource or list_item
    recipe: cloudflare.dns_cname
    zone_id: 023e105f4ecef8ad9ca31a8372d0c353
    name: "${ctx.scope}.preview.example.com"
    target: preview-host.example.net
```

* `recipe: <provider>.<op>` picks the operation; `api` defaults to `<provider>`, and so does its block under
  `providers.http`: write the block only to override `base_url`, `base_url_env`, `auth`, `headers` or `encoding`.
* Every other key of the line is one of the recipe's params. Values are literals, `${ctx.*}`, `{ from }`,
  `{ secret }` or `{ keep: true }`, as anywhere in a plan. An unknown param, a missing required one or a value of
  the wrong type is `PARAM_INVALID` naming it, before any request; an unknown recipe or op lists the known ones.
* `destroy: keep` leaves what the line made in place when the scope is destroyed.
* Identity: the ledger records the API block as resolved (base URL, credential variable, headers, encoding) and the
  keys and record ids of the expanded requests, never the recipe's text. A line written by hand with the same
  values is the same resource, and a new Sponson version whose recipe changes only its docs, params or assumptions
  re-identifies nothing.

Every op below is tested by `scenarios/recipes.test.ts` against the local fake cloud (create, idempotent re-apply,
drift, destroy), and live with `pnpm test:live` when its credential and the `SPONSON_LIVE_*` variables its recipe
names are set.

| Recipe | Op | What it manages | Credential |
| - | - | - | - |
| [`ably.webhook_rule`](/reference/recipes#ably-webhook_rule) | `resource` | Webhook rule | `ABLY_CONTROL_TOKEN` (bearer) |
| [`amplify.branch`](/reference/recipes#amplify-branch) | `resource` | Branch | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (AWS SigV4, `amplify` in `us-east-1`) |
| [`amplify.branch_variable`](/reference/recipes#amplify-branch_variable) | `list_item` | Branch environment variable | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (AWS SigV4, `amplify` in `us-east-1`) |
| [`auth0.callback_url`](/reference/recipes#auth0-callback_url) | `list_item` | Application callback URL | `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` (OAuth2 client credentials) |
| [`auth0.logout_url`](/reference/recipes#auth0-logout_url) | `list_item` | Application allowed logout URL | `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` (OAuth2 client credentials) |
| [`auth0.web_origin`](/reference/recipes#auth0-web_origin) | `list_item` | Application allowed web origin | `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` (OAuth2 client credentials) |
| [`auth0.allowed_origin`](/reference/recipes#auth0-allowed_origin) | `list_item` | Application allowed origin (CORS) | `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` (OAuth2 client credentials) |
| [`azure_swa.build_app_setting`](/reference/recipes#azure_swa-build_app_setting) | `list_item` | App setting of a preview environment | the token `az account get-access-token --query accessToken -o tsv` prints (bearer) |
| [`bugsnag.build`](/reference/recipes#bugsnag-build) | `action` | Build (release) | none of Sponson's (calls a URL the line gives; a key it needs is a `{ secret }` param) |
| [`cloudflare.dns_cname`](/reference/recipes#cloudflare-dns_cname) | `resource` | DNS CNAME record | `CLOUDFLARE_API_TOKEN` (bearer) |
| [`cockroachdb.database`](/reference/recipes#cockroachdb-database) | `resource` | Database in a cluster | `COCKROACH_API_KEY` (bearer) |
| [`cockroachdb.sql_user`](/reference/recipes#cockroachdb-sql_user) | `resource` | SQL user in a cluster | `COCKROACH_API_KEY` (bearer) |
| [`configcat.targeting_rule`](/reference/recipes#configcat-targeting_rule) | `list_item` | Feature flag targeting rule | `CONFIGCAT_BASIC_AUTH_USERNAME` / `CONFIGCAT_BASIC_AUTH_PASSWORD` (basic) |
| [`convex.preview_deployment`](/reference/recipes#convex-preview_deployment) | `resource` | Preview deployment | `CONVEX_TEAM_ACCESS_TOKEN` (bearer) |
| [`convex.deploy_key`](/reference/recipes#convex-deploy_key) | `resource` | Deploy key of a deployment | `CONVEX_TEAM_ACCESS_TOKEN` (bearer) |
| [`datadog.synthetic_api_test`](/reference/recipes#datadog-synthetic_api_test) | `resource` | Synthetic API test against a preview | `DD_API_KEY` (header `DD-API-KEY`) and `DD_APP_KEY` (header `DD-APPLICATION-KEY`) |
| [`firebase.authorized_domain`](/reference/recipes#firebase-authorized_domain) | `list_item` | Authorized domain | the token `gcloud auth print-access-token` prints (bearer) |
| [`firebase_hosting.channel`](/reference/recipes#firebase_hosting-channel) | `resource` | Preview channel | the token `gcloud auth print-access-token` prints (bearer) |
| [`flagsmith.environment`](/reference/recipes#flagsmith-environment) | `resource` | Environment per preview | `FLAGSMITH_MASTER_API_KEY` (header `Authorization`, after `Api-Key`) |
| [`fly.secret`](/reference/recipes#fly-secret) | `resource` | App secret | `FLY_API_TOKEN` (bearer) |
| [`fly_machines.app`](/reference/recipes#fly_machines-app) | `resource` | App | `FLY_API_TOKEN` (bearer) |
| [`gcs.bucket_cors`](/reference/recipes#gcs-bucket_cors) | `list_item` | CORS entry of a bucket | the token `gcloud auth print-access-token` prints (bearer) |
| [`github.repo_webhook`](/reference/recipes#github-repo_webhook) | `resource` | Repository webhook | `GITHUB_TOKEN` (bearer) |
| [`growthbook.force_rule`](/reference/recipes#growthbook-force_rule) | `list_item` | Feature force rule | `GROWTHBOOK_API_KEY` (bearer) |
| [`heroku.config_var`](/reference/recipes#heroku-config_var) | `list_item` | App config var | `HEROKU_API_KEY` (bearer) |
| [`honeycomb.marker`](/reference/recipes#honeycomb-marker) | `resource` | Deploy marker | `HONEYCOMB_API_KEY` (header `X-Honeycomb-Team`) |
| [`inngest.sync`](/reference/recipes#inngest-sync) | `action` | App sync into its branch environment | none of Sponson's (calls a URL the line gives; a key it needs is a `{ secret }` param) |
| [`kinde.callback_url`](/reference/recipes#kinde-callback_url) | `list_item` | Application callback URL | `KINDE_MANAGEMENT_CLIENT_ID` / `KINDE_MANAGEMENT_CLIENT_SECRET` (OAuth2 client credentials) |
| [`kinde.logout_url`](/reference/recipes#kinde-logout_url) | `list_item` | Application allowed logout redirect URL | `KINDE_MANAGEMENT_CLIENT_ID` / `KINDE_MANAGEMENT_CLIENT_SECRET` (OAuth2 client credentials) |
| [`mailgun.webhook`](/reference/recipes#mailgun-webhook) | `resource` | Account webhook | `MAILGUN_API_KEY` (basic, user `api`) |
| [`mailgun.route`](/reference/recipes#mailgun-route) | `resource` | Inbound route | `MAILGUN_API_KEY` (basic, user `api`) |
| [`mongodb_atlas.database_user`](/reference/recipes#mongodb_atlas-database_user) | `resource` | Database user | `MONGODB_ATLAS_PUBLIC_API_KEY` / `MONGODB_ATLAS_PRIVATE_API_KEY` (HTTP Digest) |
| [`newrelic.deployment`](/reference/recipes#newrelic-deployment) | `action` | Deployment marker | `NEW_RELIC_API_KEY` (header `API-Key`) |
| [`okta.redirect_uri`](/reference/recipes#okta-redirect_uri) | `list_item` | OIDC application sign-in redirect URI | `OKTA_API_TOKEN` (header `Authorization`, after `SSWS`) |
| [`okta.post_logout_redirect_uri`](/reference/recipes#okta-post_logout_redirect_uri) | `list_item` | OIDC application sign-out redirect URI | `OKTA_API_TOKEN` (header `Authorization`, after `SSWS`) |
| [`okta.trusted_origin`](/reference/recipes#okta-trusted_origin) | `resource` | Trusted origin (CORS and redirect) | `OKTA_API_TOKEN` (header `Authorization`, after `SSWS`) |
| [`posthog.release_condition`](/reference/recipes#posthog-release_condition) | `list_item` | Flag release condition | `POSTHOG_PERSONAL_API_KEY` (bearer) |
| [`postmark.server`](/reference/recipes#postmark-server) | `resource` | Server | `POSTMARK_ACCOUNT_TOKEN` (header `X-Postmark-Account-Token`) |
| [`postmark_server.webhook`](/reference/recipes#postmark_server-webhook) | `resource` | Webhook of a server | `POSTMARK_SERVER_TOKEN` (header `X-Postmark-Server-Token`) |
| [`prisma.database`](/reference/recipes#prisma-database) | `resource` | Database | `PRISMA_SERVICE_TOKEN` (bearer) |
| [`prisma.database_copy`](/reference/recipes#prisma-database_copy) | `resource` | Database copied from another | `PRISMA_SERVICE_TOKEN` (bearer) |
| [`qstash.schedule`](/reference/recipes#qstash-schedule) | `resource` | Schedule | `QSTASH_TOKEN` (bearer) |
| [`railway.variable`](/reference/recipes#railway-variable) | `resource` | Variable of a service in an environment | `RAILWAY_API_TOKEN` (bearer) |
| [`render.env_var`](/reference/recipes#render-env_var) | `resource` | Service environment variable | `RENDER_API_KEY` (bearer) |
| [`resend.api_key`](/reference/recipes#resend-api_key) | `resource` | API key | `RESEND_API_KEY` (bearer) |
| [`resend.webhook`](/reference/recipes#resend-webhook) | `resource` | Webhook | `RESEND_API_KEY` (bearer) |
| [`rollbar.deploy`](/reference/recipes#rollbar-deploy) | `action` | Deploy | `ROLLBAR_ACCESS_TOKEN` (header `X-Rollbar-Access-Token`) |
| [`s3.bucket_cors`](/reference/recipes#s3-bucket_cors) | `list_item` | CORS rule of a bucket | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (AWS SigV4, `s3` in `us-east-1`) |
| [`sanity.dataset`](/reference/recipes#sanity-dataset) | `resource` | Dataset per preview | `SANITY_AUTH_TOKEN` (bearer) |
| [`sanity.cors_origin`](/reference/recipes#sanity-cors_origin) | `resource` | CORS origin | `SANITY_AUTH_TOKEN` (bearer) |
| [`sendgrid.event_webhook`](/reference/recipes#sendgrid-event_webhook) | `resource` | Event Webhook | `SENDGRID_API_KEY` (bearer) |
| [`sendgrid.parse_setting`](/reference/recipes#sendgrid-parse_setting) | `resource` | Inbound Parse setting | `SENDGRID_API_KEY` (bearer) |
| [`sentry.release`](/reference/recipes#sentry-release) | `resource` | Release | `SENTRY_AUTH_TOKEN` (bearer) |
| [`sentry.deploy`](/reference/recipes#sentry-deploy) | `resource` | Deploy of a release to an environment | `SENTRY_AUTH_TOKEN` (bearer) |
| [`split.targeting_rule`](/reference/recipes#split-targeting_rule) | `list_item` | Feature flag targeting rule | `SPLIT_ADMIN_API_KEY` (bearer) |
| [`statsig.gate_rule`](/reference/recipes#statsig-gate_rule) | `resource` | Feature gate rule | `STATSIG_CONSOLE_API_KEY` (header `STATSIG-API-KEY`) |
| [`stripe.webhook_endpoint`](/reference/recipes#stripe-webhook_endpoint) | `resource` | Webhook endpoint | `STRIPE_SECRET_KEY` (bearer) |
| [`stripe.product`](/reference/recipes#stripe-product) | `resource` | Product | `STRIPE_SECRET_KEY` (bearer) |
| [`stripe.price`](/reference/recipes#stripe-price) | `resource` | Price | `STRIPE_SECRET_KEY` (bearer) |
| [`stripe.coupon`](/reference/recipes#stripe-coupon) | `resource` | Coupon | `STRIPE_SECRET_KEY` (bearer) |
| [`stytch.redirect_url`](/reference/recipes#stytch-redirect_url) | `resource` | Redirect URL | `STYTCH_WORKSPACE_KEY_ID` / `STYTCH_WORKSPACE_KEY_SECRET` (basic) |
| [`svix.endpoint`](/reference/recipes#svix-endpoint) | `resource` | Application endpoint | `SVIX_AUTH_TOKEN` (bearer) |
| [`turso.database_branch`](/reference/recipes#turso-database_branch) | `resource` | Database branch | `TURSO_API_TOKEN` (bearer) |
| [`twilio.twiml_app`](/reference/recipes#twilio-twiml_app) | `resource` | TwiML Application | `TWILIO_API_KEY` / `TWILIO_API_SECRET` (basic) |
| [`unleash.flag_strategy`](/reference/recipes#unleash-flag_strategy) | `resource` | Flag strategy with constraints | `UNLEASH_AUTH_TOKEN` (header `Authorization`) |
| [`upstash.redis_database`](/reference/recipes#upstash-redis_database) | `resource` | Redis database | `UPSTASH_EMAIL` / `UPSTASH_API_KEY` (basic) |
| [`vercel.project_domain`](/reference/recipes#vercel-project_domain) | `resource` | Branch-bound project domain | `VERCEL_TOKEN` (bearer) |
| [`workos.redirect_uri`](/reference/recipes#workos-redirect_uri) | `resource` | AuthKit redirect URI | `WORKOS_API_KEY` (bearer) |

## Ably

Recipe file: [`packages/adapters/recipes/ably.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/ably.yaml). Verified on 2026-10-12 against:

* [Control API (base URL, access tokens and their capabilities, rules)](https://ably.com/docs/platform/account/control-api)
* [Control API OpenAPI document (rule\_post, rule\_patch, rule\_response, http\_rule\_\*)](https://ably.com/docs/open-specs/control-v1.yaml)
* [Webhooks (integration rules, batching, signing)](https://ably.com/docs/platform/integrations/webhooks)

| API default | Value |
| - | - |
| `base_url` | `https://control.ably.net/v1` |
| `base_url_env` | `ABLY_CONTROL_API_URL` |
| Credential | `ABLY_CONTROL_TOKEN` (bearer) |

Assumptions:

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

### `ably.webhook_rule`

**Webhook rule** (`http.resource`). An integration rule that posts the messages (or presence, lifecycle, occupancy events) of a preview's channels, those matching `channel_filter` such as `^pr-42:`, to the preview's own URL, `url` + `path`. Identified by its channel filter and source type, so a new preview URL updates it in place; a console edit of its URL, format, envelope, request mode or status is drift. The app and its keys are shared and not managed.

Covers: `ably-webhook-rule` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The Ably app the rule belongs to. |
| `channel_filter` | string | yes | — | A regular expression over the whole channel name selecting the preview's channels, such as `^${ctx.scope}:`; with `source_type`, the rule's identity. |
| `source_type` | string | no | `channel.message` | The events the rule posts (AB2). One of `channel.message`, `channel.presence`, `channel.lifecycle`, `channel.occupancy`. |
| `url` | string | yes | — | The preview's base URL, without a trailing slash: usually `{ from: <deploy line>.preview_url }`. Matches `^https://\\S*[^/]$`. |
| `path` | string | no | `/api/webhooks/ably` | Where the app handles Ably's requests, appended to `url`. Matches `^/\\S*$`. |
| `request_mode` | string | no | `single` | One request per event, or events batched. One of `single`, `batch`. |
| `format` | string | no | `json` | The encoding of what is posted. One of `json`, `msgpack`. |
| `enveloped` | boolean | no | `true` | Whether events are wrapped in Ably's metadata envelope. |
| `status` | string | no | `enabled` | Whether the rule posts. One of `enabled`, `disabled`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: webhook_rule
  adapter: http
  op: resource
  recipe: ably.webhook_rule
  app_id: 28GY6a
  channel_filter: "^${ctx.scope}:"
  url: https://pr-42.preview.example.com
```

What the line expands into: the [`http.resource`](/reference/plan-format#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.

```yaml theme={null}
find:
  path: /apps/{app_id}/rules
  match:
    /source/channelFilter: { param: channel_filter }
    /source/type: { param: source_type }
read:
  path: /apps/{app_id}/rules/{id}
create:
  path: /apps/{app_id}/rules
  body:
    ruleType: http
update:
  method: PATCH
  path: /apps/{app_id}/rules/{id}
  body:
    ruleType: http
fields:
  /target/url:
    join:
      - { param: url }
      - { param: path }
  /target/format: { param: format }
  /target/enveloped: { param: enveloped }
  /requestMode: { param: request_mode }
  /status: { param: status }
```

## AWS Amplify Hosting

Recipe file: [`packages/adapters/recipes/amplify.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/amplify.yaml). Verified on 2026-10-11 against:

* [CreateBranch](https://docs.aws.amazon.com/amplify/latest/APIReference/API_CreateBranch.html)
* [GetBranch](https://docs.aws.amazon.com/amplify/latest/APIReference/API_GetBranch.html)
* [UpdateBranch](https://docs.aws.amazon.com/amplify/latest/APIReference/API_UpdateBranch.html)
* [DeleteBranch](https://docs.aws.amazon.com/amplify/latest/APIReference/API_DeleteBranch.html)
* [Amplify endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/amplify.html)
* [Create a signed AWS API request (SigV4)](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html)

| API default | Value |
| - | - |
| `base_url` | `https://amplify.us-east-1.amazonaws.com` |
| `base_url_env` | `AMPLIFY_API_URL` |
| Credential | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (AWS SigV4, `amplify` in `us-east-1`) |

Assumptions:

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

### `amplify.branch`

**Branch** (`http.resource`). An Amplify branch named by the plan (the git branch of the pull request, `${ctx.git.branch}`), created with CreateBranch and deleted on destroy. Its `stage`, `description` and auto-build setting are compared: a console edit is drift. Variables on it are `amplify.branch_variable` lines.

Covers: `amplify-branch` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The Amplify app's id. Matches `^d[a-z0-9]+$`. |
| `branch_name` | string | yes | — | The branch's name, as the repository names it. |
| `stage` | string | no | — | The branch's stage. One of `PRODUCTION`, `BETA`, `DEVELOPMENT`, `EXPERIMENTAL`, `PULL_REQUEST`. |
| `description` | string | no | — | The branch's description. |
| `enable_auto_build` | boolean | no | — | Build automatically on every push (false lets CI start the build after the variables are written; AM7). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: branch
  adapter: http
  op: resource
  recipe: amplify.branch
  app_id: d1demo
  branch_name: ${ctx.scope}
  stage: PULL_REQUEST
  description: Preview of ${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /apps/{app_id}/branches/{branch_name}
create:
  path: /apps/{app_id}/branches
  body:
    branchName: { param: branch_name }
update:
  method: POST
item_path: /branch
id_path: /branchName
fields:
  stage: { param: stage }
  description: { param: description }
  enableAutoBuild: { param: enable_auto_build }
```

### `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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The Amplify app's id. Matches `^d[a-z0-9]+$`. |
| `branch_name` | string | yes | — | The branch's name (a `{ from }` of the `amplify.branch` line orders the two). |
| `name` | string | yes | — | The variable's name. |
| `value` | string | yes | — | The variable's value. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: branch_variable
  adapter: http
  op: list_item
  recipe: amplify.branch_variable
  app_id: d1demo
  branch_name: ${ctx.scope}
  name: API_URL
  value: https://api-${ctx.scope}.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /apps/{app_id}/branches/{branch_name}
  method: POST
  send: collection
  item_path: /branch
  if_missing: empty
list_path: /environmentVariables
shape: map
item: { param: name }
value: { param: value }
```

## Auth0

Recipe file: [`packages/adapters/recipes/auth0.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/auth0.yaml). Verified on 2026-10-11 against:

* [Get Management API access tokens for production](https://auth0.com/docs/secure/tokens/access-tokens/management-api-access-tokens/get-management-api-access-tokens-for-production)
* [Management API v2, get a client](https://auth0.com/docs/api/management/v2/clients/get-clients-by-id)
* [Management API v2, update a client](https://auth0.com/docs/api/management/v2/clients/patch-clients-by-id)
* [Auth0 Terraform provider, configuration (conventional variable names)](https://raw.githubusercontent.com/auth0/terraform-provider-auth0/main/docs/index.md)

| API default | Value |
| - | - |
| `base_url` | none: set it in the provider block |
| `base_url_env` | `AUTH0_API_URL` |
| Credential | `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` (OAuth2 client credentials) |
| Token endpoint | `/oauth/token` on the origin of `base_url`; override: `AUTH0_TOKEN_URL` |
| Audience | `/api/v2/` on the origin of `base_url` |

Assumptions:

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

### `auth0.callback_url`

**Application callback URL** (`http.list_item`). One URL in an Auth0 application's Allowed Callback URLs (`callbacks`), such as the preview's `/callback`; usually `{ from: <deploy line>.preview_url }` joined with the path in the app. Added by read-modify-write of the list under a lock on the application, removed on destroy; every other URL is left as it is. A URL removed in the Dashboard is drift (`missing`) that the next apply puts back.

Covers: `auth0-callbacks` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `client_id` | string | yes | — | The application's Client ID. |
| `url` | string | yes | — | The callback URL, exactly as the application sends it as `redirect_uri`. Matches `^https?://\\S+$`. |

```yaml theme={null}
providers:
  http:
    auth0:
      recipe: auth0
      base_url: https://acme.eu.auth0.com/api/v2
changes:
  - id: callback_url
    adapter: http
    op: list_item
    recipe: auth0.callback_url
    client_id: AbC123dEf456GhI789jKl012MnO345pQ
    url: https://${ctx.scope}.preview.example.app/callback
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /clients/{client_id}
  read_path: /clients/{client_id}?fields=callbacks,allowed_logout_urls,web_origins,allowed_origins&include_fields=true
list_path: /callbacks
item: { param: url }
```

### `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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `client_id` | string | yes | — | The application's Client ID. |
| `url` | string | yes | — | The URL the application passes as `returnTo` on logout. Matches `^https?://\\S+$`. |

```yaml theme={null}
providers:
  http:
    auth0:
      recipe: auth0
      base_url: https://acme.eu.auth0.com/api/v2
changes:
  - id: logout_url
    adapter: http
    op: list_item
    recipe: auth0.logout_url
    client_id: AbC123dEf456GhI789jKl012MnO345pQ
    url: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /clients/{client_id}
  read_path: /clients/{client_id}?fields=callbacks,allowed_logout_urls,web_origins,allowed_origins&include_fields=true
list_path: /allowed_logout_urls
item: { param: url }
```

### `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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `client_id` | string | yes | — | The application's Client ID. |
| `origin` | string | yes | — | The origin, scheme and host without a path (`https://pr-42.preview.example.app`). Matches `^https?://[^/\\s]+$`. |

```yaml theme={null}
providers:
  http:
    auth0:
      recipe: auth0
      base_url: https://acme.eu.auth0.com/api/v2
changes:
  - id: web_origin
    adapter: http
    op: list_item
    recipe: auth0.web_origin
    client_id: AbC123dEf456GhI789jKl012MnO345pQ
    origin: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /clients/{client_id}
  read_path: /clients/{client_id}?fields=callbacks,allowed_logout_urls,web_origins,allowed_origins&include_fields=true
list_path: /web_origins
item: { param: origin }
```

### `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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `client_id` | string | yes | — | The application's Client ID. |
| `origin` | string | yes | — | The origin, scheme and host without a path (`https://pr-42.preview.example.app`). Matches `^https?://[^/\\s]+$`. |

```yaml theme={null}
providers:
  http:
    auth0:
      recipe: auth0
      base_url: https://acme.eu.auth0.com/api/v2
changes:
  - id: allowed_origin
    adapter: http
    op: list_item
    recipe: auth0.allowed_origin
    client_id: AbC123dEf456GhI789jKl012MnO345pQ
    origin: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /clients/{client_id}
  read_path: /clients/{client_id}?fields=callbacks,allowed_logout_urls,web_origins,allowed_origins&include_fields=true
list_path: /allowed_origins
item: { param: origin }
```

## Azure Static Web Apps

Recipe file: [`packages/adapters/recipes/azure_swa.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/azure_swa.yaml). Verified on 2026-10-11 against:

* [Static Sites - Create Or Update Static Site Build App Settings](https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/create-or-update-static-site-build-app-settings?view=rest-appservice-2024-04-01)
* [Static Sites - List Static Site Build App Settings](https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/list-static-site-build-app-settings?view=rest-appservice-2024-04-01)
* [Static Sites - Get Static Site Builds](https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/get-static-site-builds?view=rest-appservice-2024-04-01)
* [Preview environments in Azure Static Web Apps](https://learn.microsoft.com/en-us/azure/static-web-apps/preview-environments)
* [Configure application settings for Azure Static Web Apps](https://learn.microsoft.com/en-us/azure/static-web-apps/application-settings)
* [az account get-access-token](https://learn.microsoft.com/en-us/cli/azure/account#az-account-get-access-token)

| API default | Value |
| - | - |
| `base_url` | `https://management.azure.com` |
| `base_url_env` | `AZURE_SWA_API_URL` |
| Credential | the token `az account get-access-token --query accessToken -o tsv` prints (bearer) |

Assumptions:

* **AZ1** (verified, [source](https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/create-or-update-static-site-build-app-settings?view=rest-appservice-2024-04-01)): `PUT https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/staticSites/{name}/builds/{environmentName}/config/appsettings?api-version=2024-04-01` with `{ properties: { <name>: <value>, … } }` sets the app settings of one environment (build) and answers 200 with the same `properties`.
* **AZ2** (verified, [source](https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/list-static-site-build-app-settings?view=rest-appservice-2024-04-01)): The settings are read with `POST …/builds/{environmentName}/listAppSettings?api-version=2024-04-01` (no body), which answers a StringDictionary `{ id, properties }`; there is no GET, so the recipe reads with `parent.read_method: POST`.
* **AZ3** (verified, [source](https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/get-static-site-builds?view=rest-appservice-2024-04-01)): Each environment is a `builds/{environmentName}` resource: production is `default`, a pull request's preview environment is named by the pull request's number (`builds/12`).
* **AZ4** (verified, [source](https://learn.microsoft.com/en-us/azure/static-web-apps/preview-environments)): 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](https://learn.microsoft.com/en-us/cli/azure/account#az-account-get-access-token)): `az account get-access-token --query accessToken -o tsv` prints an Azure Resource Manager access token for the signed-in account (the default resource is `https://management.azure.com/`), sent as a bearer token.
* **AZ6** **(unverified)**: The PUT replaces the whole settings map (a setting it is not sent is removed), so the recipe sends the map as read with the one entry changed (`send: collection`); there is no ETag on this resource, so only Sponson's own writers are serialised (the parent lock), and a setting added in the portal between the read and the write is lost.
* **AZ7** **(unverified)**: Listing the settings of an environment that does not exist (not deployed yet, or deleted with its pull request) answers 404, which the recipe reads as no settings (`if_missing: empty`): plan shows a create before the first deploy, and destroy after the pull request closed is a no-op. A write before the first deploy fails with that 404; order the line after the deploy.

### `azure_swa.build_app_setting`

**App setting of a preview environment** (`http.list_item`). One app setting of a Static Web App environment (a pull request's preview environment is named by its number), by read-modify-write of the environment's settings: read with `listAppSettings`, written whole with a PUT. Other settings are left alone and the environment is locked while Sponson writes it. Settings reach the app's managed API, not the front-end build (AZ4); the environment exists only after the PR's first deploy (AZ7).

Covers: `azure-swa-settings` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `subscription_id` | string | yes | — | The Azure subscription id. |
| `resource_group` | string | yes | — | The resource group of the static web app. |
| `site` | string | yes | — | The static web app's name. |
| `environment` | string | yes | — | The environment's name (the pull request's number for its preview environment; `default` is production). Matches `^[a-zA-Z0-9]+$`. |
| `name` | string | yes | — | The setting's name. |
| `value` | string | yes | — | The setting's value. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: build_app_setting
  adapter: http
  op: list_item
  recipe: azure_swa.build_app_setting
  subscription_id: 00000000-0000-0000-0000-000000000000
  resource_group: rg-web
  site: web
  environment: "42"
  name: API_URL
  value: https://api-${ctx.scope}.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Web/staticSites/{site}/builds/{environment}/config/appsettings?api-version=2024-04-01
  read_path: /subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Web/staticSites/{site}/builds/{environment}/listAppSettings?api-version=2024-04-01
  read_method: POST
  method: PUT
  send: collection
  if_missing: empty
list_path: /properties
shape: map
item: { param: name }
value: { param: value }
```

## BugSnag

Recipe file: [`packages/adapters/recipes/bugsnag.yaml`](https://github.com/MishaBear94/Sponson/blob/main/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)](https://github.com/bugsnag/bugsnag-cli/tree/main/pkg)
* [grunt-bugsnag, BugSnag's documented build-reporting options](https://github.com/bugsnag/grunt-bugsnag)
* [Build and deploy integrations (the Build API's place among them)](https://docs.bugsnag.com/build-integrations/)

No API block: its ops send their request to an absolute URL (a param: the app's own, or an endpoint that authenticates by a key in the body, passed as a `{ secret }` param), with no API credential.

Assumptions:

* **BS1** (verified, [source](https://github.com/bugsnag/bugsnag-cli/tree/main/pkg)): A build is reported with a JSON `POST` to `https://build.bugsnag.com` (`https://build.bugsnag.smartbear.com` for keys of BugSnag's SmartBear-hosted instance; an on-premise endpoint otherwise), as BugSnag's CLI sends it with `Content-Type: application/json`.
* **BS2** (verified, [source](https://github.com/bugsnag/grunt-bugsnag)): The body carries `apiKey` (the project's notifier API key, required) and `appVersion` (required), and optionally `releaseStage`, `builderName`, `sourceControl` (`provider`, `repository`, `revision`), `autoAssignRelease` and `metadata`; the API key in the body is the only authentication.
* **BS3** (verified, [source](https://github.com/bugsnag/grunt-bugsnag)): `autoAssignRelease: true` associates the build with new error events and sessions received for its release stage until the next build is reported; without a release stage it applies to `production`, so the recipe always sends the stage.
* **BS4** **(unverified)**: BugSnag's public APIs delete no builds or releases, so destroy sends nothing and forgets the record: a release is history.
* **BS5** **(unverified)**: Reporting the same app version and release stage twice (an action is sent at least once) updates or repeats the same release rather than failing.
* **BS6** **(unverified)**: The notifier API key is also embedded in the app's client bundle for browser error reporting, but it is still passed here as a `{ secret }` so it never reaches the plan file, receipts or logs.

### `bugsnag.build`

**Build (release)** (`http.action`). Reports a build of `app_version` for the environment's `release_stage` (such as `preview`) to BugSnag's Build API, once per version and stage, so errors from the preview are grouped under its release. The project's notifier API key travels in the body (`api_key`, a `{ secret }`); Sponson sends no credential of its own. Nothing to undo (BS4).

Covers: `bugsnag-build` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `url` | string | no | `https://build.bugsnag.com` | The Build API: `https://build.bugsnag.com`, or the SmartBear-hosted or on-premise endpoint (BS1). Matches `^https?://\\S*[^/]$`. |
| `path` | string | no | `/` | The path of the build endpoint under `url`. Matches `^/\\S*$`. |
| `api_key` | string | yes | — | The project's notifier API key, always as a reference: `{ secret: "env://BUGSNAG_API_KEY" }`. |
| `app_version` | string | yes | — | The version the app's notifier reports (its `appVersion`), such as the commit. A new one reports a new build. |
| `release_stage` | string | yes | — | The release stage the preview's notifier reports, such as `${ctx.env}`. |
| `builder_name` | string | no | `sponson` | Who built it. |
| `revision` | string | no | — | The commit, for BugSnag's source links: `"${ctx.git.sha}"`. |
| `repository` | string | no | — | The repository's URL, for source links. |
| `provider` | string | no | — | The source control provider, for source links. One of `github`, `github-enterprise`, `gitlab`, `gitlab-onpremise`, `bitbucket`, `bitbucket-server`. |
| `auto_assign_release` | boolean | no | `true` | Attribute new events of the release stage to this build (BS3). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: build
  adapter: http
  op: action
  recipe: bugsnag.build
  api_key:
    secret: env://BUGSNAG_API_KEY
  app_version: ${ctx.git.sha}
  release_stage: ${ctx.env}
```

What the line expands into: the [`http.action`](/reference/plan-format#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.

```yaml theme={null}
trigger:
  app_version: { param: app_version }
  release_stage: { param: release_stage }
request:
  method: POST
  url:
    join:
      - { param: url }
      - { param: path }
  body:
    apiKey: { param: api_key }
    appVersion: { param: app_version }
    releaseStage: { param: release_stage }
    builderName: { param: builder_name }
    autoAssignRelease: { param: auto_assign_release }
    sourceControl:
      provider: { param: provider }
      repository: { param: repository }
      revision: { param: revision }
```

## Cloudflare

Recipe file: [`packages/adapters/recipes/cloudflare.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/cloudflare.yaml). Verified on 2026-10-11 against:

* [Create DNS record](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/)
* [List DNS records](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/)
* [DNS record details](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/get/)
* [Update DNS record (PATCH)](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/)
* [Delete DNS record](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/)

| API default | Value |
| - | - |
| `base_url` | `https://api.cloudflare.com/client/v4` |
| `base_url_env` | `CLOUDFLARE_API_URL` |
| Credential | `CLOUDFLARE_API_TOKEN` (bearer) |

Assumptions:

* **CF1** (verified, [source](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/)): Records are created with `POST /zones/{zone_id}/dns_records` (`type`, `name`, `content`, `ttl`, `proxied`, `comment`), authenticated with an API token as `Authorization: Bearer` that has DNS Write on the zone.
* **CF2** (verified, [source](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/)): `GET /zones/{zone_id}/dns_records` answers `{ result: [...] }` and filters by `type` and `name.exact` (names compare case-insensitively).
* **CF3** (verified, [source](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/get/)): Single-record answers (create, details, update) carry the record under `result`, with its `id`.
* **CF4** (verified, [source](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/)): `PATCH /zones/{zone_id}/dns_records/{id}` updates a record; `DELETE` on the same path removes it permanently.
* **CF5** **(unverified)**: A PATCH needs `name` and `type` alongside the changed fields (the edit schema does not mark them optional), so every update sends both.
* **CF6** **(unverified)**: The API returns `name` lower-cased as the full name and `content` as written, so `name` must be given lower-case and fully qualified, or every plan finds no record and tries to create one.
* **CF7** **(unverified)**: A proxied record reports `ttl: 1` (automatic) whatever was sent, so `ttl` other than 1 with `proxied: true` shows as drift on every plan.
* **CF8** (verified, [source](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/)): 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](/reference/coverage#coverage-matrix). Outputs: `id`, `hostname`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `zone_id` | string | yes | — | The zone's id (32 hex characters, on the zone's Overview page). Matches `^[0-9a-f]{32}$`. |
| `name` | string | yes | — | The record's full name, lower-case, zone included. Matches `^[a-z0-9*_.-]+$`. |
| `target` | string | yes | — | The host the name points at (the record's `content`). |
| `proxied` | boolean | no | `false` | Whether Cloudflare proxies the traffic. |
| `ttl` | integer | no | `1` | TTL in seconds (60–86400), or 1 for automatic. |
| `comment` | string | no | — | A note on the record; no effect on DNS. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: dns_cname
  adapter: http
  op: resource
  recipe: cloudflare.dns_cname
  zone_id: 023e105f4ecef8ad9ca31a8372d0c353
  name: ${ctx.scope}.preview.example.com
  target: preview-host.example.net
  comment: Managed by Sponson
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /zones/{zone_id}/dns_records?type=CNAME&name.exact={name}
  list_path: /result
  match:
    type: CNAME
    name: { param: name }
create:
  path: /zones/{zone_id}/dns_records
update:
  method: PATCH
  path: /zones/{zone_id}/dns_records/{id}
  body:
    type: CNAME
    name: { param: name }
delete:
  path: /zones/{zone_id}/dns_records/{id}
item_path: /result
fields:
  content: { param: target }
  proxied: { param: proxied }
  ttl: { param: ttl }
  comment: { param: comment }
outputs:
  hostname: /name
```

## CockroachDB Cloud

Recipe file: [`packages/adapters/recipes/cockroachdb.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/cockroachdb.yaml). Verified on 2026-10-11 against:

* [Cloud API OpenAPI document (version 2026-09-15)](https://cockroachlabs.cloud/assets/docs/api/latest/openapi.json)
* [Use the CockroachDB Cloud API](https://docs.cockroachlabs.com/docs/cockroachcloud/cloud-api)

| API default | Value |
| - | - |
| `base_url` | `https://cockroachlabs.cloud/api/v1` |
| `base_url_env` | `COCKROACH_API_URL` |
| Credential | `COCKROACH_API_KEY` (bearer) |
| `headers` | \{"Cc-Version":"2024-09-16"} |

Assumptions:

* **CR1** (verified, [source](https://docs.cockroachlabs.com/docs/cockroachcloud/cloud-api)): 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](https://docs.cockroachlabs.com/docs/cockroachcloud/cloud-api)): `Cc-Version` pins the API version (`YYYY-MM-DD`); without it the latest version answers, which may change under a client. The guide documents `2024-09-16`.
* **CR3** (verified, [source](https://cockroachlabs.cloud/assets/docs/api/latest/openapi.json)): `POST /clusters/{cluster_id}/databases` with `{ name }` creates a database and answers `{ name, table_count }`; `GET` on the same path lists `{ databases: [{ name, table_count }], pagination: { next_page } }`, paged with `pagination.page`; `DELETE /clusters/{cluster_id}/databases/{name}` deletes one. There is no GET of a single database.
* **CR4** (verified, [source](https://cockroachlabs.cloud/assets/docs/api/latest/openapi.json)): `POST /clusters/{cluster_id}/sql-users` with `{ name, password }` (both required) creates a SQL user; `GET` on the same path lists `{ users: [{ name }], pagination: { next_page } }`; `DELETE /clusters/{cluster_id}/sql-users/{name}` deletes one. The password cannot be read back; `PUT …/sql-users/{name}/password` changes it.
* **CR5** (verified, [source](https://docs.cockroachlabs.com/docs/cockroachcloud/cloud-api)): A SQL user created through the API gets the SQL `admin` role by default, which the guide advises against for anything but administration.
* **CR6** (verified, [source](https://cockroachlabs.cloud/assets/docs/api/latest/openapi.json)): Creating or deleting databases and SQL users needs a cluster-level role of the service account (Cluster Admin, Cluster Operator or Cluster Data Accessor for databases; Cluster Admin for SQL users).
* **CR7** **(unverified)**: `COCKROACH_API_KEY` is the variable the CockroachDB Terraform provider and `ccloud` read the key from.
* **CR8** **(unverified)**: A second create of an existing database or SQL user is refused with 409 (ALREADY\_EXISTS), and deleting one that is gone answers 404.
* **CR9** **(unverified)**: Deleting a database drops it with its tables (CASCADE); the API offers no way to refuse when it holds data.
* **CR10** **(unverified)**: The API takes database and user names that need quoting in SQL (`app_pr-42`, from a scope with `-`) and quotes them itself.

### `cockroachdb.database`

**Database in a cluster** (`http.resource`). A database per scope in a shared CockroachDB Cloud cluster, such as `app_pr_42`, instead of a cluster per preview. Located by its name in the cluster's list; only its existence is compared (the API exposes nothing else to set); destroy drops it with its data (CR9).

Covers: `cockroachdb-database` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `cluster_id` | string | yes | — | The cluster's id (a UUID, shown in the Cloud Console's URL). |
| `name` | string | yes | — | The database's name; its identity. A name with `-` (a scope such as `pr-42`) needs quoting in SQL (CR10). Matches `^[A-Za-z_][A-Za-z0-9_.-]{0,62}$`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: database
  adapter: http
  op: resource
  recipe: cockroachdb.database
  cluster_id: 9e3f1a52-63d1-4a4c-9b8f-2c8b7a0e5d11
  name: app_${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /clusters/{cluster_id}/databases
  list_path: /databases
  match:
    name: { param: name }
  next: /pagination/next_page
  cursor_param: pagination.page
create:
  path: /clusters/{cluster_id}/databases
delete:
  path: /clusters/{cluster_id}/databases/{id}
id_path: /name
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `cluster_id` | string | yes | — | The cluster's id (a UUID, shown in the Cloud Console's URL). |
| `name` | string | yes | — | The user's name; its identity. Matches `^[A-Za-z_][A-Za-z0-9_.-]{0,62}$`. |
| `password` | string | yes | — | The user's password, as a reference (`{ secret: env://… }`, or `{ from: <line>.<output> }` of a sensitive output); sent on create only (CR4). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: sql_user
  adapter: http
  op: resource
  recipe: cockroachdb.sql_user
  cluster_id: 9e3f1a52-63d1-4a4c-9b8f-2c8b7a0e5d11
  name: app_${ctx.scope}
  password:
    secret: env://PREVIEW_DB_PASSWORD
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /clusters/{cluster_id}/sql-users
  list_path: /users
  match:
    name: { param: name }
  next: /pagination/next_page
  cursor_param: pagination.page
create:
  path: /clusters/{cluster_id}/sql-users
  body:
    password: { param: password }
delete:
  path: /clusters/{cluster_id}/sql-users/{id}
id_path: /name
```

## ConfigCat

Recipe file: [`packages/adapters/recipes/configcat.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/configcat.yaml). Verified on 2026-10-11 against:

* [Public Management API](https://api.configcat.com/docs/)
* [OpenAPI spec (get value v2, replace value v2, targeting rule models)](https://api.configcat.com/docs/v1/swagger.json)
* [Terraform provider (credential variable names)](https://github.com/configcat/terraform-provider-configcat/blob/main/docs/index.md)

| API default | Value |
| - | - |
| `base_url` | `https://api.configcat.com` |
| `base_url_env` | `CONFIGCAT_API_URL` |
| Credential | `CONFIGCAT_BASIC_AUTH_USERNAME` / `CONFIGCAT_BASIC_AUTH_PASSWORD` (basic) |

Assumptions:

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

### `configcat.targeting_rule`

**Feature flag targeting rule** (`http.list_item`). A targeting rule on a boolean feature flag in one environment that serves a value to the preview, such as `IF Preview IS ONE OF [pr-42] THEN true`. Added after the flag's other rules (evaluated in order: an earlier matching rule wins); the default value and the other rules are written back as read. Identified by the matched value; a console edit of its condition or served value is drift. Destroy removes the rule.

Covers: `configcat-rule` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `environment_id` | string | yes | — | The environment's id (a UUID). |
| `setting_id` | string | yes | — | The feature flag's numeric setting id. Matches `^[0-9]+$`. |
| `attribute` | string | yes | — | The user attribute the rule compares (a custom attribute such as `Preview`, or `Identifier`, `Email`). |
| `comparator` | string | no | `isOneOf` | `isOneOf`, or `sensitiveIsOneOf` (hashed in the config JSON). One of `isOneOf`, `sensitiveIsOneOf`. |
| `match` | string | yes | — | The attribute value that is the preview; it identifies the rule. |
| `serve` | boolean | no | `true` | The value the flag serves to the preview. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: targeting_rule
  adapter: http
  op: list_item
  recipe: configcat.targeting_rule
  environment_id: 08d8becf-d4d9-4c66-8b48-6ac74cd95fba
  setting_id: "12345"
  attribute: Preview
  match: ${ctx.scope}
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /v2/environments/{environment_id}/settings/{setting_id}/value
  method: PUT
  send:
    - defaultValue
    - percentageEvaluationAttribute
list_path: /targetingRules
key_field: /conditions/0/userCondition/comparisonValue/listValue/0/value
item:
  conditions:
    - userCondition:
        comparisonAttribute: { param: attribute }
        comparator: { param: comparator }
        comparisonValue:
          stringValue: null
          doubleValue: null
          listValue:
            - value: { param: match }
              hint: null
      segmentCondition: null
      prerequisiteFlagCondition: null
  value:
    boolValue: { param: serve }
    stringValue: null
    intValue: null
    doubleValue: null
    predefinedVariationId: null
```

## Convex

Recipe file: [`packages/adapters/recipes/convex.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/convex.yaml). Verified on 2026-10-12 against:

* [Management API overview (authorization, OpenAPI)](https://docs.convex.dev/management-api/overview)
* [Management API OpenAPI document](https://api.convex.dev/v1/openapi.json)
* [Platform APIs, team access tokens](https://docs.convex.dev/platform-apis/overview)
* [Multiple deployments, preview deployments](https://docs.convex.dev/production/multiple-deployments)
* [Hosting on Vercel, preview deployments](https://docs.convex.dev/production/hosting/vercel)
* [Deploy key types](https://docs.convex.dev/cli/deploy-key-types)
* [Pricing (preview deployments on every plan)](https://www.convex.dev/pricing)

| API default | Value |
| - | - |
| `base_url` | `https://api.convex.dev/v1` |
| `base_url_env` | `CONVEX_API_URL` |
| Credential | `CONVEX_TEAM_ACCESS_TOKEN` (bearer) |

Assumptions:

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

### `convex.preview_deployment`

**Preview deployment** (`http.resource`). A Convex preview deployment per scope, a backend with its own data, functions and URL, created through the Management API instead of by `npx convex deploy` with a preview deploy key, so that it exists, with its URL, before the preview builds and is deleted when the scope is destroyed (CX4: not left to expire). Located by `reference`; only its existence is compared for drift; destroy deletes it with its data.

Covers: `convex-preview-deployment` in [the coverage matrix](/reference/coverage#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](https://github.com/get-convex/convex-backend/blob/main/npm-packages/convex/src/cli/lib/dashboard.ts)).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project's numeric id. Matches `^[0-9]+$`. |
| `reference` | string | yes | — | The deployment's reference in the project, its identity here (CX2): usually `${ctx.scope}`. Matches `^[a-z0-9/-]{3,100}$`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: preview_deployment
  adapter: http
  op: resource
  recipe: convex.preview_deployment
  project_id: "12345"
  reference: ${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /projects/{project_id}/list_deployments?deploymentType=preview
  match:
    reference: { param: reference }
read:
  path: /deployments/{id}
create:
  path: /projects/{project_id}/create_deployment
  body:
    type: preview
delete:
  method: POST
  path: /deployments/{id}/delete
id_path: /name
outputs:
  name: /name
  url: /deploymentUrl
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`, `deploy_key` (sensitive, [once](/reference/plan-format#once-only-outputs): only from the create). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `deployment` | string | yes | — | The deployment's name: usually `{ from: <preview_deployment line>.name }`. |
| `name` | string | yes | — | The key's name, unique in the deployment. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: deploy_key
  adapter: http
  op: resource
  recipe: convex.deploy_key
  deployment: playful-otter-123
  name: sponson-${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /deployments/{deployment}/list_deploy_keys
  match:
    name: { param: name }
create:
  path: /deployments/{deployment}/create_deploy_key
delete:
  method: POST
  path: /deployments/{deployment}/delete_deploy_key
  body:
    id: "{id}"
id_path: /name
outputs:
  deploy_key:
    path: /deployKey
    sensitive: true
    once: true
```

## Datadog

Recipe file: [`packages/adapters/recipes/datadog.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/datadog.yaml). Verified on 2026-10-11 against:

* [Synthetics API reference](https://docs.datadoghq.com/api/latest/synthetics/)
* [API authentication (keys, sites)](https://docs.datadoghq.com/api/latest/authentication/)
* [Datadog API v1 OpenAPI document (datadog-api-client)](https://github.com/DataDog/datadog-api-client-typescript/blob/master/.generator/schemas/v1/openapi.yaml)

| API default | Value |
| - | - |
| `base_url` | `https://api.datadoghq.com/api/v1` |
| `base_url_env` | `DATADOG_API_URL` |
| Credential | `DD_API_KEY` (header `DD-API-KEY`) and `DD_APP_KEY` (header `DD-APPLICATION-KEY`) |

Assumptions:

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

### `datadog.synthetic_api_test`

**Synthetic API test against a preview** (`http.resource`). A Synthetic HTTP test per scope that checks the preview's URL from Datadog's locations, such as `preview pr-42` asserting status 200, with its own monitor. Located by its name; a console edit of its request, assertions, locations, frequency, message, status or tags is drift; destroy deletes the test (and its monitor).

Covers: `datadog-marker` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The test's name; its identity (DD6). |
| `url` | string | yes | — | The URL to request (the preview's URL, or a health path on it). Matches `^https?://\\S+$`. |
| `method` | string | no | `GET` | The HTTP method. One of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`. |
| `assertions` | list | no | `[{"type":"statusCode","operator":"is","target":200}]` | The assertions, each `{ type, operator, target }` (`statusCode`, `responseTime`, `body`, `header`). |
| `locations` | list | yes | — | The managed locations to run from, such as `aws:us-east-2` or `aws:eu-central-1`. |
| `tick_every` | integer | no | `300` | How often the test runs, in seconds. |
| `message` | string | no | `Preview check managed by Sponson` | The notification message of the test's monitor. |
| `status` | string | no | `live` | `paused` keeps the test without running it. One of `live`, `paused`. |
| `tags` | list | no | `["managed-by:sponson"]` | The test's tags. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: synthetic_api_test
  adapter: http
  op: resource
  recipe: datadog.synthetic_api_test
  name: preview ${ctx.scope}
  url: https://${ctx.scope}.preview.example.app/api/health
  locations:
    - aws:us-east-2
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /synthetics/tests
  list_path: /tests
  match:
    name: { param: name }
read:
  path: /synthetics/tests/api/{id}
create:
  path: /synthetics/tests/api
  body:
    type: api
    subtype: http
update:
  method: PUT
  body:
    type: api
    subtype: http
delete:
  method: POST
  path: /synthetics/tests/delete
  body:
    public_ids:
      - "{id}"
id_path: /public_id
fields:
  /config/request/method: { param: method }
  /config/request/url: { param: url }
  /config/assertions: { param: assertions }
  locations: { param: locations }
  /options/tick_every: { param: tick_every }
  message: { param: message }
  status: { param: status }
  tags: { param: tags }
```

## Firebase Authentication

Recipe file: [`packages/adapters/recipes/firebase.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/firebase.yaml). Verified on 2026-10-11 against:

* [Identity Toolkit API v2, projects.getConfig](https://docs.cloud.google.com/identity-platform/docs/reference/rest/v2/projects/getConfig)
* [Identity Toolkit API v2, projects.updateConfig](https://docs.cloud.google.com/identity-platform/docs/reference/rest/v2/projects/updateConfig)
* [Identity Toolkit API v2, Config](https://docs.cloud.google.com/identity-platform/docs/reference/rest/v2/Config)
* [Authenticate for using REST](https://docs.cloud.google.com/docs/authentication/rest)

| API default | Value |
| - | - |
| `base_url` | `https://identitytoolkit.googleapis.com/admin/v2` |
| `base_url_env` | `FIREBASE_API_URL` |
| Credential | the token `gcloud auth print-access-token` prints (bearer) |

Assumptions:

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

### `firebase.authorized_domain`

**Authorized domain** (`http.list_item`). One domain in Firebase Authentication's authorized domains (Authentication, Settings, Authorized domains), which OAuth sign-in redirects and email links require, such as the preview deployment's host. Read-modify-write of the list with `updateMask=authorizedDomains` under a lock on the project's config; removed on destroy; every other domain (localhost, the Firebase hosting domains) is left as it is.

Covers: `firebase-auth-domains` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project` | string | yes | — | The Google Cloud / Firebase project id. |
| `domain` | string | yes | — | The domain, a host name without scheme or path (`pr-42.preview.example.app`, `my-app-git-feat-x.vercel.app`). Matches `^[A-Za-z0-9.-]+$`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: authorized_domain
  adapter: http
  op: list_item
  recipe: firebase.authorized_domain
  project: demo-project
  domain: ${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /projects/{project}/config?updateMask=authorizedDomains
  read_path: /projects/{project}/config
list_path: /authorizedDomains
item: { param: domain }
```

## Firebase Hosting

Recipe file: [`packages/adapters/recipes/firebase_hosting.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/firebase_hosting.yaml). Verified on 2026-10-12 against:

* [Firebase Hosting REST API discovery document (v1beta1, sites.channels)](https://firebasehosting.googleapis.com/\$discovery/rest?version=v1beta1)
* [Test, preview, then deploy (preview channels)](https://firebase.google.com/docs/hosting/test-preview-deploy)
* [Manage preview channels, releases and versions](https://firebase.google.com/docs/hosting/manage-hosting-resources)
* [GitHub pull request integration (a channel per PR)](https://firebase.google.com/docs/hosting/github-integration)
* [Firebase predefined roles (Hosting Admin)](https://firebase.google.com/docs/projects/iam/roles-predefined-product)
* [firebase-tools hosting API client (channel create, get, delete)](https://github.com/firebase/firebase-tools/blob/master/src/hosting/api.ts)

| API default | Value |
| - | - |
| `base_url` | `https://firebasehosting.googleapis.com/v1beta1` |
| `base_url_env` | `FIREBASE_HOSTING_API_URL` |
| Credential | the token `gcloud auth print-access-token` prints (bearer) |

Assumptions:

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

### `firebase_hosting.channel`

**Preview channel** (`http.resource`). A Firebase Hosting preview channel per scope on a site, such as `pr-42`, that `firebase hosting:channel:deploy` then deploys the preview into (FH5): made before the deploy so its `url` is known to other lines, and deleted when the scope is destroyed instead of left to expire. A console edit of its retained release count is drift; destroy deletes the channel and its releases.

Covers: `firebase-hosting-channel` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `url`, `expire_time`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `site` | string | yes | — | The Hosting site id (the project id for the default site). Matches `^[a-z0-9-]+$`. |
| `channel_id` | string | yes | — | The channel id, unique in the site (FH7): usually `${ctx.scope}`. Matches `^(?!live$)[A-Za-z0-9_.-]+$`. |
| `ttl` | string | no | — | How long until Firebase deletes it by itself, in seconds (`2592000s`, the most: 30 days). Sent on create only; left out, the channel never expires (FH4). Matches `^[0-9]+s$`. |
| `retained_release_count` | integer | no | `10` | How many past releases the channel keeps (1 to 100). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: channel
  adapter: http
  op: resource
  recipe: firebase_hosting.channel
  site: demo-project
  channel_id: ${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /sites/{site}/channels/{channel_id}
create:
  path: /sites/{site}/channels?channelId={channel_id}
  body:
    ttl: { param: ttl }
update:
  method: PATCH
  path: /sites/{site}/channels/{channel_id}?updateMask=retainedReleaseCount
id_path: /name
fields:
  retainedReleaseCount: { param: retained_release_count }
outputs:
  url: /url
  expire_time: /expireTime
```

## Flagsmith

Recipe file: [`packages/adapters/recipes/flagsmith.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/flagsmith.yaml). Verified on 2026-10-11 against:

* [Admin API authentication](https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api/authentication)
* [Admin API overview](https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api)
* [Flagsmith API OpenAPI document](https://api.flagsmith.com/api/v1/swagger.json)

| API default | Value |
| - | - |
| `base_url` | `https://api.flagsmith.com/api/v1` |
| `base_url_env` | `FLAGSMITH_API_URL` |
| Credential | `FLAGSMITH_MASTER_API_KEY` (header `Authorization`, after `Api-Key`) |

Assumptions:

* **FS1** (verified, [source](https://docs.flagsmith.com/integrating-with-flagsmith/flagsmith-api-overview/admin-api/authentication)): 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](https://api.flagsmith.com/api/v1/swagger.json)): `POST /environments/` creates an environment from `name` and `project` (both required) and optional `description`, answering it (201) with its integer `id` and its `api_key`, the client-side environment key SDKs use.
* **FS3** (verified, [source](https://api.flagsmith.com/api/v1/swagger.json)): `GET /environments/?project=<id>` lists a project's environments as `{ count, next, previous, results: [...] }`.
* **FS4** (verified, [source](https://api.flagsmith.com/api/v1/swagger.json)): `GET`, `PATCH` and `DELETE /environments/{api_key}/` read, update and delete one environment (DELETE answers 204).
* **FS5** **(unverified)**: A project has fewer environments than one page of the list holds, so `find` sees them all (`next` is a URL, which the http adapter does not follow).
* **FS6** **(unverified)**: A new environment starts with every feature of the project at its default state, and environment names are unique in a project in practice (two with one name make `find` refuse with PARAM\_INVALID).
* **FS7** **(unverified)**: `description` is answered as sent; `FLAGSMITH_MASTER_API_KEY` is the variable Flagsmith's Terraform provider reads the key from.
* **FS8** **(unverified)**: Identity and segment overrides are not recipes: Edge projects (Flagsmith SaaS's default) address identities by UUID through `edge-identities`, and a segment override is two objects (a feature segment, then a feature state) created in order.
* **FS9** **(unverified)**: The create takes the project id as a string of digits (`"1234"`) as well as a number, as Django REST Framework's primary-key fields do.

### `flagsmith.environment`

**Environment per preview** (`http.resource`). A Flagsmith environment per scope in a project, such as `preview-pr-42`, starting from the project's default flag states; its client-side environment key is the output `api_key` for the preview's SDK configuration. Located by name in the project; a console edit of its description is drift; destroy deletes the environment with its flag states and identities.

Covers: `flagsmith-override` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `api_key`, `environment_id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project's id, digits as a string (FS9). Matches `^[0-9]+$`. |
| `name` | string | yes | — | The environment's name; its identity in the project (FS6). |
| `description` | string | no | `Preview environment managed by Sponson` | The environment's description. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: environment
  adapter: http
  op: resource
  recipe: flagsmith.environment
  project_id: "1234"
  name: preview-${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /environments/?project={project_id}
  list_path: /results
  match:
    name: { param: name }
read:
  path: /environments/{id}/
create:
  path: /environments/
  body:
    project: { param: project_id }
update:
  method: PATCH
id_path: /api_key
fields:
  description: { param: description }
outputs:
  api_key: /api_key
  environment_id: /id
```

## Fly.io

Recipe file: [`packages/adapters/recipes/fly.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/fly.yaml). Verified on 2026-10-11 against:

* [Secrets and Fly Apps](https://docs.fly.io/apps/secrets)
* [superfly/fly-go resource\_secrets.go (the GraphQL operations flyctl sends)](https://github.com/superfly/fly-go/blob/main/resource_secrets.go)
* [superfly/fly-go schema.graphql](https://github.com/superfly/fly-go/blob/main/schema.graphql)
* [Working with the Machines API (tokens)](https://docs.fly.io/machines/api/working-with-machines-api/)

| API default | Value |
| - | - |
| `base_url` | `https://api.fly.io/graphql` |
| `base_url_env` | `FLY_API_URL` |
| Credential | `FLY_API_TOKEN` (bearer) |

Assumptions:

* **FS1** (verified, [source](https://github.com/superfly/fly-go/blob/main/resource_secrets.go)): `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](https://github.com/superfly/fly-go/blob/main/resource_secrets.go)): `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](https://docs.fly.io/apps/secrets)): Plain-text values cannot be read back (`fly secrets list` shows a name, a digest and a date), so a value changed in the console, or in the plan, is not detected: drift is the secret's presence only. To change a value, run `sponson apply --destroy` for the scope (or remove the line, apply, and add it back).
* **FS4** (verified, [source](https://docs.fly.io/apps/secrets)): Machines read secrets as environment variables at boot: a secret set without updating the Machines (as `fly secrets set --stage` does) reaches only Machines started or updated afterwards. Order the line before the PR's `fly deploy`; after a later change run `fly secrets deploy`.
* **FS5** **(unverified)**: A token from `fly tokens create` (`FlyV1 fm2_…`) or `fly auth token` is accepted as `Authorization: Bearer <token>` by the GraphQL API, as the Machines API documents for the same `FLY_API_TOKEN`. (fly-go itself sends a macaroon as `FlyV1 <token>`; a block that needs that sends it with `auth: { header: Authorization, value_env: FLY_API_TOKEN }`.)
* **FS6** **(unverified)**: Unsetting a secret of an app that no longer exists answers an error saying the app could not be found, which the adapter reads as already gone; unsetting an absent key of an existing app is not an error. setSecrets through GraphQL updates no Machines (FS4). Current flyctl sets secrets through the Machines API instead; the GraphQL mutations are not marked deprecated.

### `fly.secret`

**App secret** (`http.resource`). One secret of a Fly app (typically the app made for the pull request, `fly_machines.app`), set with `setSecrets` and removed with `unsetSecrets`. Fly never returns secret values, so only its presence is compared: a secret unset in the console is recreated, a changed value is not noticed (FS3). Machines see it once started or updated after the write: order it before the PR's deploy (FS4).

Covers: `fly-secrets` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app` | string | yes | — | The app's name. |
| `name` | string | yes | — | The secret's name (the environment variable the Machines see). Matches `^[A-Za-z_][A-Za-z0-9_]*$`. |
| `value` | string | yes | — | The secret's value, normally a `{ secret }` reference. It is sent, never stored or compared. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: secret
  adapter: http
  op: resource
  recipe: fly.secret
  app: demo-${ctx.scope}
  name: DATABASE_URL
  value: postgres://app@db.internal:5432/${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  graphql:
    query: "query ($appName: String!) { app(name: $appName) { secrets { name digest createdAt } } }"
    variables:
      appName: { param: app }
    data_path: /app/secrets
  match:
    name: { param: name }
create:
  graphql:
    query: "mutation($input: SetSecretsInput!) { setSecrets(input: $input) { release { id version } } }"
    variables:
      input:
        appId: { param: app }
        secrets:
          - key: { param: name }
            value: { param: value }
    idempotent: true
delete:
  graphql:
    query: "mutation($input: UnsetSecretsInput!) { unsetSecrets(input: $input) { release { id version } } }"
    variables:
      input:
        appId: { param: app }
        keys:
          - { param: name }
```

## Fly.io Machines API

Recipe file: [`packages/adapters/recipes/fly_machines.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/fly_machines.yaml). Verified on 2026-10-11 against:

* [Working with the Machines API](https://docs.fly.io/machines/api/working-with-machines-api/)
* [Apps resource](https://docs.fly.io/machines/api/apps-resource/)

| API default | Value |
| - | - |
| `base_url` | `https://api.machines.dev/v1` |
| `base_url_env` | `FLY_MACHINES_API_URL` |
| Credential | `FLY_API_TOKEN` (bearer) |

Assumptions:

* **FM1** (verified, [source](https://docs.fly.io/machines/api/working-with-machines-api/)): The Machines API's public base URL is `https://api.machines.dev`, its paths start with `/v1`, and requests carry `Authorization: Bearer <fly_api_token>`.
* **FM2** (verified, [source](https://docs.fly.io/machines/api/apps-resource/)): `POST /v1/apps` with `app_name` (unique) and `org_slug` (both required) and optional `network` creates an app and answers 201 with `id` and `created_at`.
* **FM3** (verified, [source](https://docs.fly.io/machines/api/apps-resource/)): `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](https://docs.fly.io/machines/api/apps-resource/)): `DELETE /v1/apps/{app_name}` answers 202; Machines should be stopped first, and `?force=true` stops them and deletes the app at once, which is what the recipe's destroy sends (a PR app has the PR's Machines running).
* **FM5** **(unverified)**: Deleting the app deletes its secrets and its Machines with it; an app name another organization holds is refused with a conflict, which the adapter then fails to read (403/404) and reports.

### `fly_machines.app`

**App** (`http.resource`). A Fly app per pull request, such as `demo-${ctx.scope}`, in an organization: created with `POST /v1/apps`, located by its name, deleted with its Machines on destroy (`?force=true`). Its secrets are `fly.secret` lines; the Machines are what the PR's `fly deploy` creates. Nothing about the app is compared besides its existence.

Covers: `fly-secrets` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_name` | string | yes | — | The app's name, unique across Fly.io. Matches `^[a-z0-9][a-z0-9-]*$`. |
| `org_slug` | string | yes | — | The organization the app is created in. |
| `network` | string | no | — | A private network (6PN) to put the app on. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: app
  adapter: http
  op: resource
  recipe: fly_machines.app
  app_name: demo-${ctx.scope}
  org_slug: acme
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /apps/{app_name}
create:
  path: /apps
  body:
    app_name: { param: app_name }
    org_slug: { param: org_slug }
    network: { param: network }
delete:
  path: /apps/{app_name}?force=true
```

## Google Cloud Storage (Firebase Storage)

Recipe file: [`packages/adapters/recipes/gcs.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/gcs.yaml). Verified on 2026-10-11 against:

* [Buckets: patch](https://docs.cloud.google.com/storage/docs/json_api/v1/buckets/patch)
* [Buckets resource (cors)](https://docs.cloud.google.com/storage/docs/json_api/v1/buckets)
* [Set up and view CORS configurations](https://docs.cloud.google.com/storage/docs/using-cors)
* [Performance tips (patch semantics)](https://docs.cloud.google.com/storage/docs/json_api/v1/how-tos/performance)

| API default | Value |
| - | - |
| `base_url` | `https://storage.googleapis.com/storage/v1` |
| `base_url_env` | `GCS_API_URL` |
| Credential | the token `gcloud auth print-access-token` prints (bearer) |

Assumptions:

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

### `gcs.bucket_cors`

**CORS entry of a bucket** (`http.list_item`). An entry of a bucket's `cors` list allowing one origin, such as the preview's URL on the Firebase Storage bucket `my-app.appspot.com`, with its methods, response headers and max age. The rest of the list is left as it is; a console edit of the entry is drift, and destroy removes only this entry.

Covers: `gcs-cors` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `bucket` | string | yes | — | The bucket's name (Firebase Storage's default bucket is `<project>.appspot.com` or `<project>.firebasestorage.app`). |
| `origin` | string | yes | — | The origin allowed, without a path (the preview's URL); the entry's identity (GC8). Matches `^(https?://\\S+\|\\*)$`. |
| `methods` | list | no | `["GET","HEAD"]` | The methods allowed. |
| `response_headers` | list | no | `["Content-Type"]` | The response headers the browser may read. |
| `max_age_seconds` | integer | no | `3600` | How long a preflight answer may be cached. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: bucket_cors
  adapter: http
  op: list_item
  recipe: gcs.bucket_cors
  bucket: my-app.appspot.com
  origin: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /b/{bucket}?fields=cors
  method: PATCH
list_path: /cors
key_field: /origin/0
item:
  origin:
    - { param: origin }
  method: { param: methods }
  responseHeader: { param: response_headers }
  maxAgeSeconds: { param: max_age_seconds }
```

## GitHub

Recipe file: [`packages/adapters/recipes/github.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/github.yaml). Verified on 2026-10-11 against:

* [REST API endpoints for repository webhooks](https://docs.github.com/en/rest/repos/webhooks)
* [Authenticating to the REST API](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api)
* [Getting started with the REST API (headers, API versions)](https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api)

| API default | Value |
| - | - |
| `base_url` | `https://api.github.com` |
| `base_url_env` | `GITHUB_API_URL` |
| Credential | `GITHUB_TOKEN` (bearer) |
| `headers` | \{"X-GitHub-Api-Version":"2022-11-28","User-Agent":"sponson"} |

Assumptions:

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

### `github.repo_webhook`

**Repository webhook** (`http.resource`). A repository webhook delivering to an environment's URL, such as the preview deployment's `/api/github` handler. Located by its URL; a console edit of its events, its active flag, its content type or its TLS check is drift. The secret is sent but not compared (GH6).

Covers: `github-webhook` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `owner` | string | yes | — | The repository's owner (user or organization). |
| `repo` | string | yes | — | The repository's name. |
| `url` | string | yes | — | The URL GitHub delivers to; it identifies the hook in the repository. Matches `^https?://\\S+$`. |
| `events` | list | no | `["push"]` | The events that trigger a delivery (`["*"]` for all). |
| `active` | boolean | no | `true` | Whether deliveries are sent. |
| `content_type` | string | no | `json` | The payload's media type. One of `json`, `form`. |
| `insecure_ssl` | string | no | `0` | "1" skips verifying the TLS certificate of the URL. One of `0`, `1`. |
| `secret` | string | no | — | The secret deliveries are signed with (write-only; GH6). Use `{ secret: … }`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: repo_webhook
  adapter: http
  op: resource
  recipe: github.repo_webhook
  owner: acme
  repo: app
  url: https://${ctx.scope}.preview.example.com/api/github
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /repos/{owner}/{repo}/hooks?per_page=100
  match:
    /config/url: { param: url }
create:
  path: /repos/{owner}/{repo}/hooks
  body:
    name: web
    config:
      secret: { param: secret }
update:
  method: PATCH
  path: /repos/{owner}/{repo}/hooks/{id}
  body:
    config:
      url: { param: url }
      secret: { param: secret }
delete:
  path: /repos/{owner}/{repo}/hooks/{id}
fields:
  active: { param: active }
  events: { param: events }
  /config/content_type: { param: content_type }
  /config/insecure_ssl: { param: insecure_ssl }
```

## GrowthBook

Recipe file: [`packages/adapters/recipes/growthbook.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/growthbook.yaml). Verified on 2026-10-11 against:

* [REST API reference (authentication, versioning)](https://docs.growthbook.io/api)
* [Get a feature (v2)](https://docs.growthbook.io/api#operation/getFeatureV2)
* [Update a feature (v2)](https://docs.growthbook.io/api#operation/updateFeatureV2)
* [OpenAPI spec](https://api.growthbook.io/api/v1/openapi.yaml)

| API default | Value |
| - | - |
| `base_url` | `https://api.growthbook.io/api` |
| `base_url_env` | `GROWTHBOOK_API_URL` |
| Credential | `GROWTHBOOK_API_KEY` (bearer) |

Assumptions:

* **GB1** (verified, [source](https://docs.growthbook.io/api)): GrowthBook Cloud's API is at `https://api.growthbook.io/api` (self-hosted: its API host plus `/api`), authenticated with a secret key or personal access token as `Authorization: Bearer`.
* **GB2** (verified, [source](https://docs.growthbook.io/api#operation/getFeatureV2)): The v1 feature endpoints (rules per environment) are deprecated; in v2 a feature's rules are one top-level `rules` array, each rule naming its `environments` (or `allEnvironments`).
* **GB3** (verified, [source](https://docs.growthbook.io/api#operation/getFeatureV2)): `GET /v2/features/{id}` answers `{ feature: { …, rules: [...] } }`.
* **GB4** (verified, [source](https://docs.growthbook.io/api#operation/updateFeatureV2)): `POST /v2/features/{id}` patch-merges the top-level fields it is sent, and `rules`, when sent, replaces the whole array: so the array is read, the rule added or changed, and the whole array sent back. Rules read with GET may be posted back unchanged. The update publishes a new revision at once.
* **GB5** (verified, [source](https://api.growthbook.io/api/v1/openapi.yaml)): A force rule is `{ type: force, description, condition, value, enabled, allEnvironments, environments }`; `condition` is a JSON query serialised as a string and `value` is a string (`"true"` for a boolean feature).
* **GB6** **(unverified)**: Rule ids may be chosen by the client but what the server does with them is not documented, so the rule is identified by its `description`, which it stores as written; two rules with that description are one rule to Sponson.
* **GB7** **(unverified)**: A rule is read back with `condition` exactly as sent (not reformatted), so the compared fields do not show as changed on every plan.
* **GB8** (verified, [source](https://docs.growthbook.io/api#operation/updateFeatureV2)): 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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `feature_id` | string | yes | — | The feature's key. |
| `environment` | string | yes | — | The environment the rule applies in. |
| `description` | string | yes | — | The rule's description, unique among the feature's rules; it identifies the rule. |
| `condition` | string | yes | — | The targeting condition, a JSON query as a string. |
| `value` | string | yes | — | The value served, as a string (`"true"` for a boolean feature, JSON for a JSON feature). |
| `enabled` | boolean | no | `true` | Whether the rule is on. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: force_rule
  adapter: http
  op: list_item
  recipe: growthbook.force_rule
  feature_id: new-checkout
  environment: staging
  description: Sponson preview ${ctx.scope}
  condition: '{"preview": "${ctx.scope}"}'
  value: "true"
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /v2/features/{feature_id}
  method: POST
  item_path: /feature
list_path: /rules
key_field: description
item:
  description: { param: description }
  type: force
  condition: { param: condition }
  value: { param: value }
  enabled: { param: enabled }
  allEnvironments: false
  environments:
    - { param: environment }
```

## Heroku

Recipe file: [`packages/adapters/recipes/heroku.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/heroku.yaml). Verified on 2026-10-11 against:

* [Platform API reference (clients, Config Vars, Review App)](https://devcenter.heroku.com/articles/platform-api-reference)
* [Platform API JSON schema (config-var)](https://api.heroku.com/schema)
* [Configuration and config vars](https://devcenter.heroku.com/articles/config-vars)
* [CLI authentication (HEROKU\_API\_KEY)](https://devcenter.heroku.com/articles/authentication)

| API default | Value |
| - | - |
| `base_url` | `https://api.heroku.com` |
| `base_url_env` | `HEROKU_API_URL` |
| Credential | `HEROKU_API_KEY` (bearer) |

Assumptions:

* **HK1** (verified, [source](https://devcenter.heroku.com/articles/authentication)): Requests authenticate as `Authorization: Bearer <token>`; the Heroku CLI reads the token from `HEROKU_API_KEY`.
* **HK2** (verified, [source](https://devcenter.heroku.com/articles/platform-api-reference)): Every request must send `Accept: application/vnd.heroku+json; version=3`; without it the API refuses the request (documented as 406 `not_acceptable`; a request without it was answered 400 `missing_version` when checked).
* **HK3** (verified, [source](https://devcenter.heroku.com/articles/platform-api-reference)): `GET /apps/{app_id_or_name}/config-vars` answers the app's config vars as one flat map of name to string value.
* **HK4** (verified, [source](https://devcenter.heroku.com/articles/platform-api-reference)): `PATCH /apps/{app_id_or_name}/config-vars` with a map sets the named vars and removes those set to `null`, leaving every other var as it is: one var is written without reading or rewriting the others, so concurrent writers of other vars cannot lose them (no lock needed).
* **HK5** (verified, [source](https://api.heroku.com/schema)): Config var names match `^\w+$` and values are strings (`null` only as a delete in a PATCH).
* **HK6** (verified, [source](https://devcenter.heroku.com/articles/config-vars)): 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](https://devcenter.heroku.com/articles/platform-api-reference)): Review apps are created asynchronously (`POST /review-apps` answers `pending` with a nullable `app`), by Heroku itself when the pipeline has automatic review apps; this recipe does not create them, it writes vars on an app that exists (the review app's name or id).
* **HK9** **(unverified)**: A PATCH whose `Content-Type` is the same vendor media type (`media_type` sends it as both `Accept` and `Content-Type`) is accepted like `application/json`; the reference's examples send `application/json`.
* **HK8** **(unverified)**: A missing app answers 404 `not_found` on GET and PATCH, which destroy treats as the var being gone.

### `heroku.config_var`

**App config var** (`http.list_item`). One config var on a Heroku app, such as a review app's `API_URL`. Written as a merge patch of that one var (`null` deletes it), so other vars, including other scopes', are never rewritten. A console edit of its value is drift; destroy removes only this var. Heroku restarts the app on every change (HK6).

Covers: `heroku-config-vars` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app` | string | yes | — | The app's name or id (a review app's, such as `myapp-pr-42`). |
| `key` | string | yes | — | The config var's name. Matches `^\\w+$`. |
| `value` | string | yes | — | The value: a literal, `{ from }` or `{ secret }`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: config_var
  adapter: http
  op: list_item
  recipe: heroku.config_var
  app: myapp-${ctx.scope}
  key: API_URL
  value: https://api-${ctx.scope}.example.com
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /apps/{app}/config-vars
list_path: ""
shape: map
item: { param: key }
value: { param: value }
```

## Honeycomb

Recipe file: [`packages/adapters/recipes/honeycomb.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/honeycomb.yaml). Verified on 2026-10-11 against:

* [Create a Marker](https://docs.honeycomb.io/api/markers/create-a-marker)
* [List All Markers](https://docs.honeycomb.io/api/markers)
* [Update a Marker](https://docs.honeycomb.io/api/markers/update-a-marker)
* [Delete a Marker](https://docs.honeycomb.io/api/markers/delete-a-marker)

| API default | Value |
| - | - |
| `base_url` | `https://api.honeycomb.io` |
| `base_url_env` | `HONEYCOMB_API_URL` |
| Credential | `HONEYCOMB_API_KEY` (header `X-Honeycomb-Team`) |

Assumptions:

* **HC1** (verified, [source](https://docs.honeycomb.io/api/markers/create-a-marker)): `POST /1/markers/{datasetSlug}` creates a marker from `start_time`, `end_time` (Unix seconds), `message`, `type` and `url` (all optional; `start_time` defaults to the request's arrival), answering 201 with the marker and its `id`; `__all__` as the dataset slug makes an environment-wide marker. Requests carry a Configuration Key with the Manage Markers permission in `X-Honeycomb-Team`.
* **HC2** (verified, [source](https://docs.honeycomb.io/api/markers)): `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](https://docs.honeycomb.io/api/markers/update-a-marker)): `PUT /1/markers/{datasetSlug}/{markerId}` replaces the marker: a field left out of the body is erased.
* **HC4** (verified, [source](https://docs.honeycomb.io/api/markers/delete-a-marker)): `DELETE /1/markers/{datasetSlug}/{markerId}` removes the marker and answers it (200).
* **HC5** (verified, [source](https://docs.honeycomb.io/api/markers/create-a-marker)): The API is served from `api.honeycomb.io` (US) and `api.eu1.honeycomb.io` (EU); EU teams set `base_url` in the provider block.
* **HC6** **(unverified)**: Message and type identify the marker this recipe created among the dataset's markers (Honeycomb does not require them to be unique: two equal ones make `find` refuse with PARAM\_INVALID), and the list holds every marker of the dataset in one answer.
* **HC7** **(unverified)**: An update (a changed `url`) re-sends message, type and url but not `start_time`, which Honeycomb then sets to the time of the update (HC3), moving the marker; markers are rarely edited.
* **HC8** **(unverified)**: A deleted marker answers 404 to DELETE, which destroy treats as already gone.

### `honeycomb.marker`

**Deploy marker** (`http.resource`). A marker on an environment's dataset (or on every dataset of the environment with `__all__`) for a deploy, such as `deploy pr-42 0123abc`. Located by its message and type; a console edit of its URL is drift. Destroying the scope removes it; write `destroy: keep` to keep the deploy history.

Covers: `honeycomb-marker` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `dataset` | string | yes | — | The dataset's slug, or `__all__` for an environment-wide marker. |
| `message` | string | yes | — | The marker's text; with its type, its identity (HC6). |
| `type` | string | no | `deploy` | The marker's type, which groups markers (and their color). |
| `url` | string | no | — | The link opened from the marker (the preview or the build). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: marker
  adapter: http
  op: resource
  recipe: honeycomb.marker
  dataset: __all__
  message: deploy ${ctx.scope} ${ctx.git.sha}
  url: https://${ctx.scope}.preview.example.com
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /1/markers/{dataset}
  match:
    message: { param: message }
    type: { param: type }
create:
  path: /1/markers/{dataset}
update:
  method: PUT
  path: /1/markers/{dataset}/{id}
delete:
  path: /1/markers/{dataset}/{id}
fields:
  url: { param: url }
```

## Inngest

Recipe file: [`packages/adapters/recipes/inngest.yaml`](https://github.com/MishaBear94/Sponson/blob/main/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](https://www.inngest.com/llm-context.md)
* [Apps in Inngest Cloud (sync methods, when to sync)](https://www.inngest.com/docs/apps/cloud)
* [Environments and branch environments](https://www.inngest.com/docs/platform/environments)
* [The TypeScript SDK's serve handler (InngestCommHandler, PUT = register)](https://github.com/inngest/inngest-js/blob/main/packages/inngest/src/components/InngestCommHandler.ts)
* [REST API v2 OpenAPI document (V2\_SyncApp)](https://api-docs.inngest.com/api-specs/v2.json)

No API block: its ops send their request to an absolute URL (a param: the app's own, or an endpoint that authenticates by a key in the body, passed as a `{ secret }` param), with no API credential.

Assumptions:

* **IN1** (verified, [source](https://www.inngest.com/llm-context.md)): A `PUT` to the app's serve endpoint (by convention `/api/inngest`, `curl -X PUT https://your-app.com/api/inngest --fail-with-body`) makes the app's SDK register its functions with Inngest; the SDK authenticates that registration with the app's own `INNGEST_SIGNING_KEY`, so the caller sends no credential.
* **IN2** (verified, [source](https://www.inngest.com/docs/platform/environments)): The app registers into the environment its SDK is configured for: `INNGEST_ENV` (or the client's `env` option) names the branch environment and always wins; Vercel's `VERCEL_GIT_COMMIT_REF` is detected automatically, while Netlify, Cloudflare Pages, Railway and Render must pass their branch variable. Sponson does not choose the environment: the app must be configured so.
* **IN3** (verified, [source](https://www.inngest.com/docs/platform/environments)): 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](https://github.com/inngest/inngest-js/blob/main/packages/inngest/src/components/InngestCommHandler.ts)): 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](https://www.inngest.com/docs/apps/cloud)): 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](https://www.inngest.com/docs/platform/environments)): 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](https://api-docs.inngest.com/api-specs/v2.json)): The REST API's `POST /v2/apps/{appId}/syncs` (marked beta, `x-beta: true`) takes `{ url }` and answers a sync with `status` pending, success, error or duplicate, but the operation declares no header or parameter that selects a branch environment; this recipe does not use it.
* **IN8** **(unverified)**: Syncing the same deployment again is harmless (the same functions registered again, `modified: false`), so the at-least-once delivery of an action (sent again after a crash before it was recorded) needs no idempotency key.
* **IN9** **(unverified)**: A preview behind Vercel Deployment Protection answers the `PUT` with 401 unless it carries `x-vercel-protection-bypass` with the project's automation bypass secret; pass it with `headers`, as a `{ secret }`.

### `inngest.sync`

**App sync into its branch environment** (`http.action`). Registers a preview deployment's Inngest functions by sending `PUT` to its serve endpoint, once per deployment URL and revision; the app's SDK picks the branch environment (`INNGEST_ENV`, IN2) and signs the registration with its own key (IN1), so no Inngest credential is needed.

Covers: `inngest-sync` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `modified`. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `url` | string | yes | — | The deployment's base URL, without a trailing slash: usually `{ from: <deploy line>.preview_url }`, so the sync waits for the deploy (IN5). Matches `^https?://\\S+[^/]$`. |
| `path` | string | no | `/api/inngest` | Where the app serves Inngest's `serve()` handler, appended to `url`. Matches `^/\\S*$`. |
| `revision` | string | yes | — | What else makes it sync again when it changes, usually the commit: `"${ctx.git.sha}"`. |
| `headers` | map | no | — | Extra request headers, such as `x-vercel-protection-bypass: { secret: env://VERCEL_AUTOMATION_BYPASS_SECRET }` for a protected preview (IN9). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: sync
  adapter: http
  op: action
  recipe: inngest.sync
  url: https://acme-git-feat-x-acme.vercel.app
  revision: ${ctx.git.sha}
```

What the line expands into: the [`http.action`](/reference/plan-format#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.

```yaml theme={null}
trigger:
  url: { param: url }
  path: { param: path }
  revision: { param: revision }
request:
  method: PUT
  url:
    join:
      - { param: url }
      - { param: path }
  headers: { param: headers }
outputs:
  modified: /modified
```

## Kinde

Recipe file: [`packages/adapters/recipes/kinde.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/kinde.yaml). Verified on 2026-10-11 against:

* [Kinde Management API (OpenAPI specification)](https://api-spec.kinde.com/kinde-management-api-spec.yaml)
* [Access the Kinde Management API](https://docs.kinde.com/developer-tools/kinde-api/connect-to-kinde-api/)
* [Kinde Management API JS SDK (conventional variable names)](https://github.com/kinde-oss/management-api-js)

| API default | Value |
| - | - |
| `base_url` | none: set it in the provider block |
| `base_url_env` | `KINDE_API_URL` |
| Credential | `KINDE_MANAGEMENT_CLIENT_ID` / `KINDE_MANAGEMENT_CLIENT_SECRET` (OAuth2 client credentials) |
| Token endpoint | `/oauth2/token` on the origin of `base_url`; override: `KINDE_TOKEN_URL` |
| Audience | `/api` on the origin of `base_url` |

Assumptions:

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

### `kinde.callback_url`

**Application callback URL** (`http.list_item`). One URL in a Kinde application's Allowed callback URLs, such as the preview's `/api/auth/kinde_callback`. Read-modify-write: the list is read (`redirect_urls`) and replaced (`urls`) with the URL added, under a lock on the list; removed on destroy; every other URL is left as it is.

Covers: `kinde-callbacks` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The application's id (its Client ID in the Kinde dashboard). |
| `url` | string | yes | — | The callback URL, exactly as the SDK sends it (`KINDE_REDIRECT_URL` / `KINDE_SITE_URL` + the callback path). Matches `^https?://\\S+$`. |

```yaml theme={null}
providers:
  http:
    kinde:
      recipe: kinde
      base_url: https://acme.kinde.com/api/v1
changes:
  - id: callback_url
    adapter: http
    op: list_item
    recipe: kinde.callback_url
    app_id: 3b0b5c6c8fcc464fab397f4969b5f482
    url: https://${ctx.scope}.preview.example.app/api/auth/kinde_callback
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /applications/{app_id}/auth_redirect_urls
  method: PUT
  write_list_path: /urls
list_path: /redirect_urls
item: { param: url }
```

### `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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The application's id (its Client ID in the Kinde dashboard). |
| `url` | string | yes | — | The URL the SDK redirects to after logout (`KINDE_POST_LOGOUT_REDIRECT_URL`). Matches `^https?://\\S+$`. |

```yaml theme={null}
providers:
  http:
    kinde:
      recipe: kinde
      base_url: https://acme.kinde.com/api/v1
changes:
  - id: logout_url
    adapter: http
    op: list_item
    recipe: kinde.logout_url
    app_id: 3b0b5c6c8fcc464fab397f4969b5f482
    url: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /applications/{app_id}/auth_logout_urls
  method: PUT
  write_list_path: /urls
list_path: /logout_urls
item: { param: url }
```

## Mailgun

Recipe file: [`packages/adapters/recipes/mailgun.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/mailgun.yaml). Verified on 2026-10-12 against:

* [Mailgun API OpenAPI document (every operation and schema below)](https://documentation.mailgun.com/_bundle/docs/mailgun/api-reference/send/mailgun.yaml)
* [Authentication (HTTP Basic, user `api`)](https://documentation.mailgun.com/docs/mailgun/api-reference/authentication/)
* [Account Webhooks](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/account-webhooks)
* [Create an account-level webhook](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/account-webhooks/post-v1-webhooks)
* [Get account-level webhook by ID](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/account-webhooks/get-v1-webhooks--webhook-id-)
* [Update an account-level webhook](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/account-webhooks/put-v1-webhooks--webhook-id-)
* [Domain Webhooks (the v3 and v4 per-domain alternatives)](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/domain-webhooks)
* [Routes](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/routes)
* [Route actions](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/route-actions)

| API default | Value |
| - | - |
| `base_url` | `https://api.mailgun.net` |
| `base_url_env` | `MAILGUN_API_URL` |
| Credential | `MAILGUN_API_KEY` (basic, user `api`) |
| `encoding` | `form` |
| `form_arrays` | `repeat` |

Assumptions:

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

### `mailgun.webhook`

**Account webhook** (`http.resource`). An account-level webhook that posts the chosen email events to the preview's own URL, `url` + `path` (usually `{ from: web.preview_url }`). Identified by its description (`name`), so a new preview URL updates it in place. It receives every domain's events (MG10), and Mailgun takes at most three URLs per event type (MG2): at most three previews at a time. A console edit of its URL or events is drift.

Covers: `mailgun-webhook-route` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The webhook's identity, kept as its description: unique per preview, such as `sponson-${ctx.env}-${ctx.scope}`. Matches `^[A-Za-z0-9_.:/ -]{1,200}$`. |
| `url` | string | yes | — | The preview's base URL, without a trailing slash: usually `{ from: <deploy line>.preview_url }`. Matches `^https://\\S*[^/]$`. |
| `path` | string | no | `/api/webhooks/mailgun` | Where the app handles Mailgun's events, appended to `url`. Matches `^/\\S*$`. |
| `event_types` | list | no | `["delivered","permanent_fail","complained"]` | The events posted: accepted, clicked, opened, unsubscribed, delivered, permanent\_fail, temporary\_fail, complained (MG2). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: webhook
  adapter: http
  op: resource
  recipe: mailgun.webhook
  name: sponson-${ctx.env}-${ctx.scope}
  url: https://pr-42.preview.example.com
```

What the line expands into: the [`http.resource`](/reference/plan-format#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.

```yaml theme={null}
find:
  path: /v1/webhooks
  list_path: /webhooks
  match:
    description: { param: name }
read:
  path: /v1/webhooks/{id}
create:
  path: /v1/webhooks
  locate: true
update:
  method: PUT
  path: /v1/webhooks/{id}
id_path: /webhook_id
fields:
  url:
    join:
      - { param: url }
      - { param: path }
  event_types: { param: event_types }
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The route's identity, kept as its description: unique per preview, such as `sponson-${ctx.env}-${ctx.scope}`. Matches `^[A-Za-z0-9_.:/ -]{1,200}$`. |
| `expression` | string | yes | — | The filter (MG8), such as `match_recipient('${ctx.scope}@inbound.example.com')`. |
| `url` | string | yes | — | The preview's base URL, without a trailing slash: usually `{ from: <deploy line>.preview_url }`. Matches `^https://\\S*[^/"]$`. |
| `path` | string | no | `/api/inbound/mailgun` | Where the app receives forwarded mail, appended to `url`. Matches `^/[^\\s"]*$`. |
| `priority` | integer | no | `0` | Lower runs first (MG7). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: route
  adapter: http
  op: resource
  recipe: mailgun.route
  name: sponson-${ctx.env}-${ctx.scope}
  expression: match_recipient('${ctx.scope}@inbound.example.com')
  url: https://pr-42.preview.example.com
```

What the line expands into: the [`http.resource`](/reference/plan-format#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.

```yaml theme={null}
find:
  path: /v3/routes?limit=1000
  list_path: /items
  match:
    description: { param: name }
read:
  path: /v3/routes/{id}
create:
  path: /v3/routes
  send_fields: false
  body:
    priority: { param: priority }
    description: { param: name }
    expression: { param: expression }
    action:
      - join:
          - forward("
          - { param: url }
          - { param: path }
          - '")'
update:
  method: PUT
  path: /v3/routes/{id}
  send_fields: false
  body:
    priority: { param: priority }
    description: { param: name }
    expression: { param: expression }
    action:
      - join:
          - forward("
          - { param: url }
          - { param: path }
          - '")'
item_path: /route
fields:
  priority: { param: priority }
  expression: { param: expression }
  actions:
    - join:
        - forward("
        - { param: url }
        - { param: path }
        - '")'
```

## MongoDB Atlas

Recipe file: [`packages/adapters/recipes/mongodb_atlas.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/mongodb_atlas.yaml). Verified on 2026-10-11 against:

* [Atlas Administration API v2 (OpenAPI document)](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2.json)
* [Create One Database User in One Project](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-creategroupdatabaseuser)
* [Configure Atlas API access (API keys use HTTP Digest)](https://www.mongodb.com/docs/atlas/configure-api-access/)
* [Atlas CLI environment variables](https://www.mongodb.com/docs/atlas/cli/current/atlas-cli-env-variables/)

| API default | Value |
| - | - |
| `base_url` | `https://cloud.mongodb.com/api/atlas/v2` |
| `base_url_env` | `MONGODB_ATLAS_API_URL` |
| Credential | `MONGODB_ATLAS_PUBLIC_API_KEY` / `MONGODB_ATLAS_PRIVATE_API_KEY` (HTTP Digest) |

Assumptions:

* **MA1** (verified, [source](https://www.mongodb.com/docs/atlas/configure-api-access/)): 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](https://www.mongodb.com/docs/atlas/cli/current/atlas-cli-env-variables/)): The Atlas CLI keeps the key pair in `MONGODB_ATLAS_PUBLIC_API_KEY` and `MONGODB_ATLAS_PRIVATE_API_KEY`.
* **MA3** (verified, [source](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2.json)): Requests and answers use the versioned media type `application/vnd.atlas.2023-01-01+json` (in `Accept` and `Content-Type`); the database-user operations are published under that version.
* **MA4** (verified, [source](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2.json)): `POST /groups/{groupId}/databaseUsers` creates a user from `databaseName`, `groupId`, `roles` and `username` (required), with `password` for SCRAM users (write-only, never answered), optional `scopes` (absent: every cluster of the project) and `deleteAfterDate` (Atlas deletes the user then; within one week of the request). It answers 201 with the user.
* **MA5** (verified, [source](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2.json)): `GET`, `PATCH` and `DELETE /groups/{groupId}/databaseUsers/{databaseName}/{username}` read, update and delete one user; SCRAM users authenticate against `databaseName` `admin`.
* **MA6** (verified, [source](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-creategroupdatabaseuser)): A project supports 100 database users by default (900 at most).
* **MA7** **(unverified)**: A GET of a user that does not exist answers 404; a second create of the same user answers 409.
* **MA8** **(unverified)**: `roles` (each `{ databaseName, roleName, collectionName? }`) and `scopes` (each `{ name, type }`) are answered in the order they were sent, so they compare without drift.
* **MA9** **(unverified)**: `PATCH` takes the user's identifying fields (`databaseName`, `groupId`, `username`) with the changed ones and keeps the password when none is sent.

### `mongodb_atlas.database_user`

**Database user** (`http.resource`). A SCRAM database user per scope on a shared Atlas cluster, such as `app-pr-42` with `readWrite` on the database `app_pr_42`, instead of a cluster per preview. The password is an input: a `{ secret: … }` reference (a value per environment in a secret manager) or a sensitive output of another line, sent on create only and never compared. Located by its user name; a console edit of its roles or scopes is drift; destroy deletes the user (not the database's data).

Covers: `mongodb-atlas-user` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `group_id` | string | yes | — | The project (group) id, 24 hexadecimal digits. Matches `^[0-9a-f]{24}$`. |
| `username` | string | yes | — | The user name, unique in the project; its identity. |
| `password` | string | yes | — | The user's password, as a reference (`{ secret: env://… }`, or `{ from: <line>.<output> }` of a sensitive output); sent on create only (MA4). |
| `roles` | list | yes | — | The roles, each `{ databaseName, roleName }` (`readWrite`, `read`, `dbAdmin`, …). |
| `scopes` | list | no | — | The clusters it may reach, each `{ name, type: CLUSTER }`; absent: every cluster of the project (not compared then). |
| `delete_after` | string | no | — | An ISO 8601 time, within a week, at which Atlas deletes the user by itself (sent on create only). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: database_user
  adapter: http
  op: resource
  recipe: mongodb_atlas.database_user
  group_id: 32b6e34b3d91647abb20e7b8
  username: app-${ctx.scope}
  password:
    secret: env://PREVIEW_DB_PASSWORD
  roles:
    - databaseName: app_${ctx.scope}
      roleName: readWrite
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /groups/{group_id}/databaseUsers/admin/{username}
create:
  path: /groups/{group_id}/databaseUsers
  body:
    databaseName: admin
    groupId: { param: group_id }
    username: { param: username }
    password: { param: password }
    deleteAfterDate: { param: delete_after }
update:
  method: PATCH
  body:
    databaseName: admin
    groupId: { param: group_id }
    username: { param: username }
id_path: /username
fields:
  roles: { param: roles }
  scopes: { param: scopes }
```

## New Relic

Recipe file: [`packages/adapters/recipes/newrelic.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/newrelic.yaml). Verified on 2026-10-12 against:

* [Track changes with NerdGraph (changeTrackingCreateDeployment, changeTrackingCreateEvent)](https://docs.newrelic.com/docs/change-tracking/change-tracking-graphql/)
* [Introduction to NerdGraph (endpoints, the API-Key header)](https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/)
* [New Relic's Go client, the mutation as its CLI and Terraform provider send it (ChangeTrackingDeploymentInput)](https://github.com/newrelic/newrelic-client-go/blob/main/pkg/changetracking/changetracking_api.go)
* [User keys](https://docs.newrelic.com/docs/apis/intro-apis/new-relic-api-keys/)

| API default | Value |
| - | - |
| `base_url` | `https://api.newrelic.com/graphql` |
| `base_url_env` | `NEW_RELIC_API_URL` |
| Credential | `NEW_RELIC_API_KEY` (header `API-Key`) |

Assumptions:

* **NR1** (verified, [source](https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/)): NerdGraph is one GraphQL endpoint, `https://api.newrelic.com/graphql` (EU accounts: `https://api.eu.newrelic.com/graphql`, set `base_url` in the provider block), taking a user key in the `API-Key` header and a JSON body `{ query, variables }`.
* **NR2** (verified, [source](https://docs.newrelic.com/docs/change-tracking/change-tracking-graphql/)): `changeTrackingCreateDeployment(deployment: ChangeTrackingDeploymentInput!)` records a deployment marker on an entity from `version` and `entityGuid` (both required) and optionally `user`, `description`, `deploymentType` (`BASIC`, `BLUE_GREEN`, `CANARY`, `ROLLING`, `SHADOW`, `OTHER`), `deepLink`, `commit`, `changelog`, `groupId` and `timestamp` (within 24 hours of now), and answers `deploymentId` and `entityGuid`. New Relic recommends `changeTrackingCreateEvent` for new work but says this mutation 'isn't being retired and continues to work'.
* **NR3** (verified, [source](https://github.com/newrelic/newrelic-client-go/blob/main/pkg/changetracking/changetracking_api.go)): 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](/reference/coverage#coverage-matrix). Outputs: `deployment_id`. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `entity_guid` | string | yes | — | The GUID of the entity the environment reports as (NR6). |
| `version` | string | yes | — | The release's version, usually the commit: `"${ctx.git.sha}"`. A new one sends a new marker. |
| `description` | string | no | — | Text shown with the marker, such as `preview pr-42`. |
| `user` | string | no | `sponson` | Who deployed. |
| `commit` | string | no | — | The commit deployed. |
| `deep_link` | string | no | — | A link to the deployment (the preview URL, or the CI run). Matches `^https?://\\S+$`. |
| `deployment_type` | string | no | `BASIC` | The kind of deployment (NR2). One of `BASIC`, `BLUE_GREEN`, `CANARY`, `ROLLING`, `SHADOW`, `OTHER`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: deployment
  adapter: http
  op: action
  recipe: newrelic.deployment
  entity_guid: MXxBUE18QVBQTElDQVRJT058MTIzNDU2Nzg
  version: ${ctx.git.sha}
  description: ${ctx.env} ${ctx.scope}
```

What the line expands into: the [`http.action`](/reference/plan-format#http-action) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
trigger:
  entity_guid: { param: entity_guid }
  version: { param: version }
request:
  graphql:
    query: "mutation ($deployment: ChangeTrackingDeploymentInput!) { changeTrackingCreateDeployment(deployment: $deployment) { deploymentId entityGuid } }"
    variables:
      deployment:
        entityGuid: { param: entity_guid }
        version: { param: version }
        description: { param: description }
        user: { param: user }
        commit: { param: commit }
        deepLink: { param: deep_link }
        deploymentType: { param: deployment_type }
outputs:
  deployment_id: /changeTrackingCreateDeployment/deploymentId
```

## Okta

Recipe file: [`packages/adapters/recipes/okta.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/okta.yaml). Verified on 2026-10-11 against:

* [Okta Management API (OpenAPI specification, management-minimal.yaml)](https://raw.githubusercontent.com/okta/okta-management-openapi-spec/master/dist/current/management-minimal.yaml)
* [Applications API, replace an application](https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Application/#tag/Application/operation/replaceApplication)
* [Trusted Origins API](https://developer.okta.com/docs/api/openapi/okta-management/management/tag/TrustedOrigin/)
* [Okta Terraform provider, configuration (conventional variable names)](https://raw.githubusercontent.com/okta/terraform-provider-okta/master/docs/index.md)

| API default | Value |
| - | - |
| `base_url` | none: set it in the provider block |
| `base_url_env` | `OKTA_API_URL` |
| Credential | `OKTA_API_TOKEN` (header `Authorization`, after `SSWS`) |

Assumptions:

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

### `okta.redirect_uri`

**OIDC application sign-in redirect URI** (`http.list_item`). One URI in an Okta OIDC application's sign-in redirect URIs (`settings.oauthClient.redirect_uris`), such as the preview's `/login/callback`. The application is read, and written back whole with the URI added (Okta replaces applications only as a whole), under a lock on the application; removed on destroy; every other URI and setting is sent back as read.

Covers: `okta-redirect` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The application's id (`0oa…`). |
| `url` | string | yes | — | The redirect URI, exactly as the application sends it as `redirect_uri`. Matches `^\\S+://\\S+$`. |

```yaml theme={null}
providers:
  http:
    okta:
      recipe: okta
      base_url: https://acme.okta.com/api/v1
changes:
  - id: redirect_uri
    adapter: http
    op: list_item
    recipe: okta.redirect_uri
    app_id: 0oa1gjh63g214q0Hq0g4
    url: https://${ctx.scope}.preview.example.app/login/callback
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /apps/{app_id}
  method: PUT
  send: parent
list_path: /settings/oauthClient/redirect_uris
item: { param: url }
```

### `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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The application's id (`0oa…`). |
| `url` | string | yes | — | The URI the application passes as `post_logout_redirect_uri`. Matches `^\\S+://\\S+$`. |

```yaml theme={null}
providers:
  http:
    okta:
      recipe: okta
      base_url: https://acme.okta.com/api/v1
changes:
  - id: post_logout_redirect_uri
    adapter: http
    op: list_item
    recipe: okta.post_logout_redirect_uri
    app_id: 0oa1gjh63g214q0Hq0g4
    url: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /apps/{app_id}
  method: PUT
  send: parent
list_path: /settings/oauthClient/post_logout_redirect_uris
item: { param: url }
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The trusted origin's name, unique in the org. |
| `origin` | string | yes | — | The origin, scheme and host without a path (`https://pr-42.preview.example.app`); unique in the org. Matches `^\\S+://[^/\\s]+$`. |
| `scopes` | list | no | `[{"type":"CORS"},{"type":"REDIRECT"}]` | What the origin is trusted for: `[{ type: CORS }]`, `[{ type: REDIRECT }]`, both (the default), or `IFRAME_EMBED`. |

```yaml theme={null}
providers:
  http:
    okta:
      recipe: okta
      base_url: https://acme.okta.com/api/v1
changes:
  - id: trusted_origin
    adapter: http
    op: resource
    recipe: okta.trusted_origin
    name: sponson-${ctx.scope}
    origin: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /trustedOrigins?q={origin}&limit=200
  list_path: ""
  match:
    origin: { param: origin }
create:
  path: /trustedOrigins
update:
  method: PUT
  path: /trustedOrigins/{id}
delete:
  path: /trustedOrigins/{id}
item_path: ""
fields:
  name: { param: name }
  origin: { param: origin }
  scopes: { param: scopes }
```

## PostHog

Recipe file: [`packages/adapters/recipes/posthog.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/posthog.yaml). Verified on 2026-10-11 against:

* [Private API (hosts, personal API keys)](https://posthog.com/docs/api)
* [Feature flags API](https://posthog.com/docs/api/feature-flags)
* [OpenAPI schema (FeatureFlag, PersonalAPIKeyAuth)](https://us.posthog.com/api/schema/?format=json)
* [Feature flag serializer (filters validation and merge)](https://github.com/PostHog/posthog/blob/master/products/feature_flags/backend/api/feature_flag.py)
* [Feature flag filters schema (condition groups)](https://github.com/PostHog/posthog/blob/master/products/feature_flags/backend/api/filters_schema.py)

| API default | Value |
| - | - |
| `base_url` | `https://us.posthog.com` |
| `base_url_env` | `POSTHOG_API_URL` |
| Credential | `POSTHOG_PERSONAL_API_KEY` (bearer) |

Assumptions:

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

### `posthog.release_condition`

**Flag release condition** (`http.list_item`). A release condition (condition group) on an existing feature flag that matches the preview, such as `$host exact [pr-42.preview.example.com]` at 100%. Groups are OR'd; the flag's other groups, variants and payloads are left as they are. A console edit of its properties, rollout or variant is drift. Destroy removes the group (with the last group gone, the flag matches no one).

Covers: `posthog-release-condition` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project's numeric id (Project settings). Matches `^[0-9]+$`. |
| `flag_id` | string | yes | — | The flag's numeric id (in the flag's URL), not its key. Matches `^[0-9]+$`. |
| `property` | string | yes | — | The property the condition tests. It must be known when the flag is evaluated (a person property, or one passed with the evaluation). |
| `property_type` | string | no | `person` | Whether `property` is a person or a group property. One of `person`, `group`. |
| `operator` | string | no | `exact` | How the property is compared with `value`. One of `exact`, `is_not`, `icontains`, `regex`. |
| `value` | string | yes | — | The value that matches the preview (such as its host); it identifies the condition on the flag. |
| `rollout_percentage` | integer | no | `100` | The share of matching users the flag is on for (0–100). |
| `variant` | string | no | — | For a multivariate flag, the variant the condition serves. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: release_condition
  adapter: http
  op: list_item
  recipe: posthog.release_condition
  project_id: "12345"
  flag_id: "678"
  property: $host
  value: ${ctx.scope}.preview.example.com
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /api/projects/{project_id}/feature_flags/{flag_id}/
  method: PATCH
list_path: /filters/groups
key_field: /properties/0/value/0
item:
  properties:
    - key: { param: property }
      type: { param: property_type }
      operator: { param: operator }
      value:
        - { param: value }
  rollout_percentage: { param: rollout_percentage }
  variant: { param: variant }
```

## Postmark (Account API)

Recipe file: [`packages/adapters/recipes/postmark.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/postmark.yaml). Verified on 2026-10-11 against:

* [Servers API](https://postmarkapp.com/developer/api/servers-api)
* [API overview (authentication)](https://postmarkapp.com/developer/api/overview)

| API default | Value |
| - | - |
| `base_url` | `https://api.postmarkapp.com` |
| `base_url_env` | `POSTMARK_API_URL` |
| Credential | `POSTMARK_ACCOUNT_TOKEN` (header `X-Postmark-Account-Token`) |

Assumptions:

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

### `postmark.server`

**Server** (`http.resource`). A Postmark server per environment, such as `sponson-preview-pr-42`, with its inbound webhook URL and tracking settings. Its token is a sensitive output for the app's environment (`{ from: <line>.server_token }`). Located by its name; a console edit of its color, tracking or inbound hook URL is drift. Its webhooks: `postmark_server.webhook` with `credential: { from: <this line>.server_token }`.

Covers: `postmark-server-webhook` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `server_token` (sensitive). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The server's name, unique in the account (PM6); its identity. |
| `color` | string | no | `Blue` | The server's color in the Postmark dashboard. One of `Purple`, `Blue`, `Turquoise`, `Green`, `Red`, `Yellow`, `Grey`, `Orange`. |
| `delivery_type` | string | no | `Sandbox` | `Sandbox` accepts mail without delivering it; fixed once the server exists (sent on create only). One of `Live`, `Sandbox`. |
| `inbound_hook_url` | string | no | \`\` | The URL inbound mail is posted to (empty for none). |
| `track_opens` | boolean | no | `false` | Track opens of outbound mail. |
| `track_links` | string | no | `None` | Track link clicks of outbound mail. One of `None`, `HtmlAndText`, `HtmlOnly`, `TextOnly`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: server
  adapter: http
  op: resource
  recipe: postmark.server
  name: sponson-${ctx.env}-${ctx.scope}
  inbound_hook_url: https://${ctx.scope}.preview.example.com/api/postmark/inbound
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /servers?count=500&offset=0&name={name}
  list_path: /Servers
  match:
    Name: { param: name }
create:
  path: /servers
  body:
    DeliveryType: { param: delivery_type }
update:
  method: PUT
  path: /servers/{id}
delete:
  path: /servers/{id}
id_path: /ID
fields:
  Color: { param: color }
  InboundHookUrl: { param: inbound_hook_url }
  TrackOpens: { param: track_opens }
  TrackLinks: { param: track_links }
outputs:
  server_token:
    path: /ApiTokens/0
    sensitive: true
```

## Postmark (Server API)

Recipe file: [`packages/adapters/recipes/postmark_server.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/postmark_server.yaml). Verified on 2026-10-11 against:

* [Webhooks API](https://postmarkapp.com/developer/api/webhooks-api)
* [API overview (authentication)](https://postmarkapp.com/developer/api/overview)

| API default | Value |
| - | - |
| `base_url` | `https://api.postmarkapp.com` |
| `base_url_env` | `POSTMARK_API_URL` |
| Credential | `POSTMARK_SERVER_TOKEN` (header `X-Postmark-Server-Token`) |

Assumptions:

* **PS1** (verified, [source](https://postmarkapp.com/developer/api/webhooks-api)): 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](https://postmarkapp.com/developer/api/webhooks-api)): `POST /webhooks` creates a webhook from `Url` (required), `MessageStream` (default `outbound`), `HttpAuth`, `HttpHeaders` and `Triggers` (`Open { Enabled, PostFirstOpenOnly }`, `Click`, `Delivery`, `Bounce { Enabled, IncludeContent }`, `SpamComplaint { Enabled, IncludeContent }`, `SubscriptionChange`, each with `Enabled`), answering the webhook with its integer `ID`.
* **PS3** (verified, [source](https://postmarkapp.com/developer/api/webhooks-api)): `GET /webhooks` (optionally `?MessageStream=`) answers `{ Webhooks: [...] }` with each webhook's `ID`, `Url`, `MessageStream` and `Triggers`.
* **PS4** (verified, [source](https://postmarkapp.com/developer/api/webhooks-api)): `PUT /webhooks/{Id}` changes `Url`, `HttpAuth`, `HttpHeaders` and `Triggers` (not `MessageStream`), re-verifying the endpoint when the URL or the triggers change; `DELETE /webhooks/{Id}` removes it.
* **PS5** (verified, [source](https://postmarkapp.com/developer/api/webhooks-api)): Postmark tests the endpoint when a webhook is created; `Verify: false` saves it without the test (a preview that is not deployed yet would fail it).
* **PS6** **(unverified)**: A URL identifies one webhook of the server (two webhooks with the same URL, on different streams, make `find` refuse with PARAM\_INVALID), and every trigger's `Enabled` is answered as a boolean even when it was not sent.
* **PS7** **(unverified)**: `PostFirstOpenOnly` and `IncludeContent` are left at Postmark's defaults; only which events are delivered is compared.
* **PS8** **(unverified)**: A server's webhooks belong to it: deleting the server (`DELETE /servers/{id}`, Account API) removes them, so a webhook line authenticated with the server's own token can leave its destroy to the server's line.

### `postmark_server.webhook`

**Webhook of a server** (`http.resource`). A webhook of one Postmark server delivering to an environment's URL, such as the preview's `/api/postmark` handler, with the events it enables. Located by its URL; a console edit of an event's `Enabled` is drift. The server is the one whose token is in `POSTMARK_SERVER_TOKEN` (a shared staging server), or, for a server the same plan creates per environment (`postmark.server`), the line says `credential: { from: <server line>.server_token }` and `destroy: keep`: deleting the server removes its webhooks (PS8).

Covers: `postmark-server-webhook` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `url` | string | yes | — | The URL events are posted to; its identity (PS6). Matches `^https?://\\S+$`. |
| `message_stream` | string | no | `outbound` | The message stream whose events are delivered (sent on create only). |
| `verify` | boolean | no | `false` | Have Postmark test the URL when the webhook is created (PS5). |
| `delivery` | boolean | no | `true` | Deliver `Delivery` events. |
| `bounce` | boolean | no | `true` | Deliver `Bounce` events. |
| `spam_complaint` | boolean | no | `true` | Deliver `SpamComplaint` events. |
| `open` | boolean | no | `false` | Deliver `Open` events. |
| `click` | boolean | no | `false` | Deliver `Click` events. |
| `subscription_change` | boolean | no | `false` | Deliver `SubscriptionChange` events. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: webhook
  adapter: http
  op: resource
  recipe: postmark_server.webhook
  url: https://${ctx.scope}.preview.example.com/api/postmark
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /webhooks
  list_path: /Webhooks
  match:
    Url: { param: url }
create:
  path: /webhooks
  body:
    MessageStream: { param: message_stream }
    Verify: { param: verify }
update:
  method: PUT
  path: /webhooks/{id}
delete:
  path: /webhooks/{id}
id_path: /ID
fields:
  /Triggers/Delivery/Enabled: { param: delivery }
  /Triggers/Bounce/Enabled: { param: bounce }
  /Triggers/SpamComplaint/Enabled: { param: spam_complaint }
  /Triggers/Open/Enabled: { param: open }
  /Triggers/Click/Enabled: { param: click }
  /Triggers/SubscriptionChange/Enabled: { param: subscription_change }
```

## Prisma Postgres

Recipe file: [`packages/adapters/recipes/prisma.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/prisma.yaml). Verified on 2026-10-11 against:

* [Management API OpenAPI document](https://api.prisma.io/v1/doc)
* [Management API guide (base URL, service tokens)](https://www.prisma.io/docs/guides/management-api-basic)
* [Connecting to your database (connection strings)](https://www.prisma.io/docs/postgres/database/connecting-to-your-database)

| API default | Value |
| - | - |
| `base_url` | `https://api.prisma.io/v1` |
| `base_url_env` | `PRISMA_API_URL` |
| Credential | `PRISMA_SERVICE_TOKEN` (bearer) |

Assumptions:

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

### `prisma.database`

**Database** (`http.resource`). An empty Postgres database per scope in a Prisma Postgres project, such as `app-pr-42`. Its connection strings exist only in the create answer, so they are once-only outputs: reference them from the line that needs them (an env var) in the same plan. Located by name; only its existence is compared for drift; destroy deletes it and its data.

Covers: `prisma-postgres-database` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `status`, `direct_url` (sensitive, [once](/reference/plan-format#once-only-outputs): only from the create), `pooled_url` (sensitive, [once](/reference/plan-format#once-only-outputs): only from the create). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project's id. |
| `name` | string | yes | — | The database's name. |
| `region` | string | no | `us-east-1` | Where it runs. One of `us-east-1`, `us-west-1`, `eu-west-3`, `eu-central-1`, `ap-northeast-1`, `ap-southeast-1`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: database
  adapter: http
  op: resource
  recipe: prisma.database
  project_id: proj_cm1a2b3c4d5e6f7g8h9
  name: app-${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /projects/{project_id}/databases
  list_path: /data
  next: /pagination/nextCursor
  match:
    name: { param: name }
read:
  path: /databases/{id}
create:
  path: /projects/{project_id}/databases
  body:
    region: { param: region }
item_path: /data
outputs:
  status: /status
  direct_url:
    path: /connections/0/endpoints/direct/connectionString
    sensitive: true
    once: true
  pooled_url:
    path: /connections/0/endpoints/pooled/connectionString
    sensitive: true
    once: true
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`, `status`, `direct_url` (sensitive, [once](/reference/plan-format#once-only-outputs): only from the create), `pooled_url` (sensitive, [once](/reference/plan-format#once-only-outputs): only from the create). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project's id. |
| `name` | string | yes | — | The new database's name. |
| `source_database_id` | string | yes | — | The id of the database to copy. |
| `region` | string | no | `us-east-1` | Where it runs. One of `us-east-1`, `us-west-1`, `eu-west-3`, `eu-central-1`, `ap-northeast-1`, `ap-southeast-1`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: database_copy
  adapter: http
  op: resource
  recipe: prisma.database_copy
  project_id: proj_cm1a2b3c4d5e6f7g8h9
  name: app-${ctx.scope}
  source_database_id: db_cm9z8y7x6w5v4u3t2s1
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /projects/{project_id}/databases
  list_path: /data
  next: /pagination/nextCursor
  match:
    name: { param: name }
read:
  path: /databases/{id}
create:
  path: /projects/{project_id}/databases
  body:
    region: { param: region }
    source:
      type: database
      databaseId: { param: source_database_id }
item_path: /data
outputs:
  status: /status
  direct_url:
    path: /connections/0/endpoints/direct/connectionString
    sensitive: true
    once: true
  pooled_url:
    path: /connections/0/endpoints/pooled/connectionString
    sensitive: true
    once: true
```

## Upstash QStash

Recipe file: [`packages/adapters/recipes/qstash.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/qstash.yaml). Verified on 2026-10-11 against:

* [Authentication](https://upstash.com/docs/qstash/api/authentication)
* [Create a schedule](https://upstash.com/docs/qstash/api-reference/schedules/create-a-schedule)
* [Get a schedule](https://upstash.com/docs/qstash/api-reference/schedules/get-a-schedule)
* [Delete a schedule](https://upstash.com/docs/qstash/api-reference/schedules/delete-a-schedule)
* [API examples (raw destination URL in the path)](https://upstash.com/docs/qstash/overall/apiexamples)
* [Multi-region (base URLs, tokens)](https://upstash.com/docs/qstash/howto/multi-region)

| API default | Value |
| - | - |
| `base_url` | `https://qstash.upstash.io` |
| `base_url_env` | `QSTASH_API_URL` |
| Credential | `QSTASH_TOKEN` (bearer) |

Assumptions:

* **QS1** (verified, [source](https://upstash.com/docs/qstash/api/authentication)): Requests authenticate with the QStash token as `Authorization: Bearer`; the SDKs read it from `QSTASH_TOKEN`.
* **QS2** (verified, [source](https://upstash.com/docs/qstash/howto/multi-region)): Each region (`qstash.upstash.io` = EU, `qstash-us-east-1.upstash.io`) has its own token and its own schedules; the default here is the EU URL, and a US account overrides `base_url` in its block.
* **QS3** (verified, [source](https://upstash.com/docs/qstash/api-reference/schedules/create-a-schedule)): `POST /v2/schedules/{destination}` creates a schedule: the cron in `Upstash-Cron`, the method used to call the destination in `Upstash-Method` (default POST), the request body as the message delivered on every run. It answers `{ scheduleId }`.
* **QS4** (verified, [source](https://upstash.com/docs/qstash/api-reference/schedules/create-a-schedule)): 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](https://upstash.com/docs/qstash/overall/apiexamples)): 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](https://upstash.com/docs/qstash/api-reference/schedules/get-a-schedule)): `GET /v2/schedules/{scheduleId}` answers the schedule bare with `scheduleId`, `cron`, `destination`, `method` and `body`, and 404 when there is none; `DELETE` on the same path removes it.
* **QS8** **(unverified)**: Deleting a schedule that is already gone answers 404 (only 200 is documented), which destroy treats as done.
* **QS9** (verified, [source](https://upstash.com/docs/qstash/api-reference/schedules/create-a-schedule)): Schedule ids are alphanumerics, hyphens, periods and underscores (400 otherwise); a cron may carry a `CRON_TZ=<zone>` prefix and runs at most once a minute.
* **QS10** **(unverified)**: `GET` returns `cron`, `destination` and `method` exactly as they were sent (the cron string unnormalised), so comparing them is not drift on every plan.

### `qstash.schedule`

**Schedule** (`http.resource`). A cron schedule that calls a preview's URL, such as `/api/cron` of `pr-42`, under a schedule id per scope. Created and updated by the same upsert (QS4); a console edit of its cron, destination or method is drift; destroy deletes it. The message body is sent on create and update but not compared.

Covers: `qstash-schedule` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `schedule_id` | string | yes | — | The schedule's id, chosen by the plan (one per scope). Matches `^[A-Za-z0-9._-]+$`. |
| `destination` | string | yes | — | The URL the schedule calls. Matches `^https?://`. |
| `cron` | string | yes | — | The cron expression, optionally prefixed `CRON_TZ=<zone> `. |
| `method` | string | no | `POST` | The HTTP method used to call the destination. One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. |
| `body` | map | no | — | The JSON message delivered on every run (sent, not compared). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: schedule
  adapter: http
  op: resource
  recipe: qstash.schedule
  schedule_id: cron-${ctx.scope}
  destination: https://${ctx.scope}.preview.example.com/api/cron
  cron: "*/15 * * * *"
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /v2/schedules/{schedule_id}
create:
  path: /v2/schedules/{+destination}
  headers:
    Upstash-Schedule-Id: { param: schedule_id }
    Upstash-Cron: { param: cron }
    Upstash-Method: { param: method }
  send_fields: false
  body: { param: body }
update:
  method: POST
  path: /v2/schedules/{+destination}
  headers:
    Upstash-Schedule-Id: { param: schedule_id }
    Upstash-Cron: { param: cron }
    Upstash-Method: { param: method }
  send_fields: false
  body: { param: body }
id_path: /scheduleId
fields:
  cron: { param: cron }
  destination: { param: destination }
  method: { param: method }
```

## Railway

Recipe file: [`packages/adapters/recipes/railway.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/railway.yaml). Verified on 2026-10-11 against:

* [Public API (endpoint, tokens, errors)](https://docs.railway.com/reference/public-api)
* [Manage Variables with the Public API](https://docs.railway.com/guides/manage-variables)
* [Environments (PR environments)](https://docs.railway.com/reference/environments)
* [CLI (RAILWAY\_TOKEN and RAILWAY\_API\_TOKEN)](https://docs.railway.com/guides/cli)
* [railwayapp/cli GraphQL schema](https://github.com/railwayapp/cli/blob/master/src/gql/schema.json)

| API default | Value |
| - | - |
| `base_url` | `https://backboard.railway.com/graphql/v2` |
| `base_url_env` | `RAILWAY_API_URL` |
| Credential | `RAILWAY_API_TOKEN` (bearer) |

Assumptions:

* **RY1** (verified, [source](https://docs.railway.com/reference/public-api)): The public API is one GraphQL endpoint, `https://backboard.railway.com/graphql/v2`; account, workspace and OAuth tokens are sent as `Authorization: Bearer <token>`, project tokens in `Project-Access-Token` instead (a project token therefore needs a hand-written block with `auth: { header: Project-Access-Token, value_env: RAILWAY_TOKEN }`).
* **RY2** (verified, [source](https://docs.railway.com/guides/cli)): The Railway CLI reads an account or workspace token from `RAILWAY_API_TOKEN` and a project token from `RAILWAY_TOKEN`, so the bearer credential of this recipe is `RAILWAY_API_TOKEN`.
* **RY3** (verified, [source](https://docs.railway.com/reference/public-api)): Failures may be HTTP 200 with an `errors` array (a denied query among them); 400 is a parse or validation failure, 429 a rate limit with `Retry-After`. Queries are safe to retry; a mutation only when no answer arrived.
* **RY4** (verified, [source](https://docs.railway.com/guides/manage-variables)): `variables(projectId, environmentId, serviceId?, unrendered?)` answers the variables of a service in an environment (without `serviceId`, the environment's shared variables) as a map of name to value; `unrendered: true` answers references such as `${{Postgres.DATABASE_URL}}` as written instead of resolved, so a value is compared as it was set.
* **RY5** (verified, [source](https://docs.railway.com/guides/manage-variables)): `variableUpsert(input: { projectId, environmentId, serviceId?, name, value, skipDeploys? })` creates or replaces one variable (without `serviceId`, a shared variable); by default it triggers a redeploy of the service (`skipDeploys` defaults to false).
* **RY6** (verified, [source](https://docs.railway.com/guides/manage-variables)): `variableDelete(input: { projectId, environmentId, serviceId?, name })` deletes one variable.
* **RY7** (verified, [source](https://docs.railway.com/reference/environments)): 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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project's id. |
| `environment_id` | string | yes | — | The environment's id (for a PR environment, the one Railway created for the pull request; RY7). |
| `service_id` | string | no | — | The service's id; without it, the variable is shared by the environment's services. |
| `name` | string | yes | — | The variable's name. Matches `^[A-Za-z_][A-Za-z0-9_]*$`. |
| `value` | string | yes | — | The variable's value (may be a `{ secret }` reference). |
| `skip_deploys` | boolean | no | — | Do not redeploy the service after a write (Railway's default is to redeploy). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: variable
  adapter: http
  op: resource
  recipe: railway.variable
  project_id: prj-railway-demo
  environment_id: env-pr-42
  service_id: svc-web
  name: API_URL
  value: https://api-${ctx.scope}.example.app
```

What the line expands into: the [`http.resource`](/reference/plan-format#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.

```yaml theme={null}
read:
  graphql:
    query: "query variables($projectId: String!, $environmentId: String!, $serviceId: String, $unrendered: Boolean) { variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId, unrendered: $unrendered) }"
    variables:
      projectId: { param: project_id }
      environmentId: { param: environment_id }
      serviceId: { param: service_id }
      unrendered: true
    data_path:
      join:
        - /variables/
        - { param: name }
create:
  graphql:
    query: "mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }"
    variables:
      input:
        projectId: { param: project_id }
        environmentId: { param: environment_id }
        serviceId: { param: service_id }
        name: { param: name }
        value: { param: value }
        skipDeploys: { param: skip_deploys }
    idempotent: true
update:
  graphql:
    query: "mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }"
    variables:
      input:
        projectId: { param: project_id }
        environmentId: { param: environment_id }
        serviceId: { param: service_id }
        name: { param: name }
        value: { param: value }
        skipDeploys: { param: skip_deploys }
    idempotent: true
delete:
  graphql:
    query: "mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }"
    variables:
      input:
        projectId: { param: project_id }
        environmentId: { param: environment_id }
        serviceId: { param: service_id }
        name: { param: name }
fields:
  value: { param: value }
```

## Render

Recipe file: [`packages/adapters/recipes/render.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/render.yaml). Verified on 2026-10-11 against:

* [Authentication](https://api-docs.render.com/reference/authentication)
* [Retrieve environment variable](https://api-docs.render.com/reference/retrieve-env-var)
* [Add or update environment variable](https://api-docs.render.com/reference/update-env-var)
* [Delete environment variable](https://api-docs.render.com/reference/delete-env-var)
* [Update environment variables (replace all; deploy behaviour)](https://api-docs.render.com/reference/update-env-vars-for-service)
* [Service previews](https://render.com/docs/service-previews)
* [Render CLI (RENDER\_API\_KEY)](https://render.com/docs/cli)

| API default | Value |
| - | - |
| `base_url` | `https://api.render.com/v1` |
| `base_url_env` | `RENDER_API_URL` |
| Credential | `RENDER_API_KEY` (bearer) |

Assumptions:

* **RE1** (verified, [source](https://api-docs.render.com/reference/authentication)): Requests authenticate with an API key as `Authorization: Bearer`; the Render CLI reads it from `RENDER_API_KEY`.
* **RE2** (verified, [source](https://api-docs.render.com/reference/retrieve-env-var)): `GET /services/{serviceId}/env-vars/{envVarKey}` answers the variable bare, `{ key, value }`, and 404 when it does not exist.
* **RE3** (verified, [source](https://api-docs.render.com/reference/update-env-var)): `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](https://api-docs.render.com/reference/delete-env-var)): `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](https://api-docs.render.com/reference/update-env-vars-for-service)): 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](https://render.com/docs/service-previews)): Render creates service previews and preview environments itself (per pull request, from the service's Previews setting or `render.yaml`); a preview copies the base service's variables once, when it is created. No API looks up the preview of pull request N, so the line takes the service's id.
* **RE9** **(unverified)**: `GET` returns the value in plain text for every variable, including ones generated with `generateValue`, so the value can be compared for drift.

### `render.env_var`

**Service environment variable** (`http.resource`). One environment variable on a Render service, such as a preview service's `API_URL`, written by name (`PUT` adds or replaces it). A console edit of its value is drift; destroy deletes it. The service's id is a param: Render creates preview services itself, and writing a variable does not deploy (RE6).

Covers: `render-env` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `service_id` | string | yes | — | The service's id (`srv-…`, in the service's dashboard URL and settings). Matches `^[a-z]{3}-[a-z0-9]+$`. |
| `key` | string | yes | — | The variable's name. Matches `^[A-Za-z_][A-Za-z0-9_]*$`. |
| `value` | string | yes | — | The variable's value: a literal, `{ from }` or `{ secret }`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: env_var
  adapter: http
  op: resource
  recipe: render.env_var
  service_id: srv-d0a1b2c3d4e5f6g7h8i9
  key: API_URL
  value: https://api-${ctx.scope}.example.com
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /services/{service_id}/env-vars/{key}
create:
  method: PUT
  path: /services/{service_id}/env-vars/{key}
update:
  method: PUT
id_path: /key
fields:
  value: { param: value }
```

## Resend

Recipe file: [`packages/adapters/recipes/resend.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/resend.yaml). Verified on 2026-10-11 against:

* [Create API key](https://resend.com/docs/api-reference/api-keys/create-api-key)
* [List API keys](https://resend.com/docs/api-reference/api-keys/list-api-keys)
* [Delete API key](https://resend.com/docs/api-reference/api-keys/delete-api-key)
* [Create webhook](https://resend.com/docs/api-reference/webhooks/create-webhook)
* [Retrieve webhook](https://resend.com/docs/api-reference/webhooks/get-webhook)
* [List webhooks](https://resend.com/docs/api-reference/webhooks/list-webhooks)
* [Update webhook](https://resend.com/docs/api-reference/webhooks/update-webhook)
* [Delete webhook](https://resend.com/docs/api-reference/webhooks/delete-webhook)

| API default | Value |
| - | - |
| `base_url` | `https://api.resend.com` |
| `base_url_env` | `RESEND_API_URL` |
| Credential | `RESEND_API_KEY` (bearer) |

Assumptions:

* **RS1** (verified, [source](https://resend.com/docs/api-reference/api-keys/create-api-key)): `POST /api-keys` creates an API key from `name` (at most 50 characters), `permission` (`full_access` or `sending_access`) and `domain_id` (with `sending_access` only), answering `{ id, token }`; requests carry `Authorization: Bearer <key>`.
* **RS2** (verified, [source](https://resend.com/docs/api-reference/api-keys/list-api-keys)): `GET /api-keys` answers `{ object: list, has_more, data: [{ id, name, created_at, last_used_at }] }`, every key when no `limit` is sent, and never the token: the token is shown only in the create answer, so it is a once-only output.
* **RS3** (verified, [source](https://resend.com/docs/api-reference/api-keys/delete-api-key)): `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](https://resend.com/docs/api-reference/webhooks/create-webhook)): `POST /webhooks` creates a webhook from `endpoint` and `events` (both required), answering `{ object, id, signing_secret }`.
* **RS5** (verified, [source](https://resend.com/docs/api-reference/webhooks/get-webhook)): `GET /webhooks/{webhook_id}` answers the webhook with `status` (`enabled`/`disabled`), `endpoint`, `events` and `signing_secret`: the secret stays readable, so it is an ordinary sensitive output.
* **RS6** (verified, [source](https://resend.com/docs/api-reference/webhooks/list-webhooks)): `GET /webhooks` answers `{ object: list, has_more, data: [{ id, created_at, status, endpoint, events }] }`, every webhook when no `limit` is sent.
* **RS7** (verified, [source](https://resend.com/docs/api-reference/webhooks/update-webhook)): `PATCH /webhooks/{webhook_id}` changes `endpoint`, `events` and `status`; `DELETE` on the same path removes the webhook.
* **RS8** **(unverified)**: Key names and webhook endpoints are not unique in Resend: the recipe takes the name (keys) and the endpoint URL (webhooks) as the identity, and refuses (PARAM\_INVALID) when two match, so give each environment its own name and URL.
* **RS9** **(unverified)**: Creating a key or a webhook needs a `full_access` key; a deleted object answers 404.

### `resend.api_key`

**API key** (`http.resource`). A restricted API key per environment, such as `sponson-preview-pr-42` allowed only to send from one domain. Its token is a once-only output: pass it to the app's environment line in the same run (`{ from: <line>.token }`); later runs keep what that line holds, and `apply --recreate <line>` issues a new one. Only its existence is compared for drift: Resend shows no permission or domain for a key.

Covers: `resend-key-webhook` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `token` (sensitive, [once](/reference/plan-format#once-only-outputs): only from the create). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The key's name, unique among the account's keys (RS8); its identity. Matches `^.{1,50}$`. |
| `permission` | string | no | `sending_access` | What the key may do. One of `full_access`, `sending_access`. |
| `domain_id` | string | no | — | With `sending_access`, the only domain the key may send from. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: api_key
  adapter: http
  op: resource
  recipe: resend.api_key
  name: sponson-${ctx.env}-${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /api-keys
  list_path: /data
  match:
    name: { param: name }
create:
  path: /api-keys
  body:
    permission: { param: permission }
    domain_id: { param: domain_id }
delete:
  path: /api-keys/{id}
outputs:
  token:
    path: /token
    sensitive: true
    once: true
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`, `signing_secret` (sensitive). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `endpoint` | string | yes | — | The URL events are delivered to; its identity (RS8). Matches `^https://\\S+$`. |
| `events` | list | yes | — | The event types to deliver, such as `email.delivered` and `email.bounced`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: webhook
  adapter: http
  op: resource
  recipe: resend.webhook
  endpoint: https://${ctx.scope}.preview.example.com/api/resend
  events:
    - email.delivered
    - email.bounced
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /webhooks
  list_path: /data
  match:
    endpoint: { param: endpoint }
read:
  path: /webhooks/{id}
create:
  path: /webhooks
update:
  method: PATCH
  path: /webhooks/{id}
delete:
  path: /webhooks/{id}
fields:
  events: { param: events }
outputs:
  signing_secret:
    path: /signing_secret
    sensitive: true
```

## Rollbar

Recipe file: [`packages/adapters/recipes/rollbar.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/rollbar.yaml). Verified on 2026-10-12 against:

* [Report a deploy](https://docs.rollbar.com/reference/post-deploy)
* [Deploy tracking](https://docs.rollbar.com/docs/deploy-tracking)
* [Project access tokens and their scopes](https://docs.rollbar.com/docs/access-tokens)
* [API reference index (deploy endpoints)](https://docs.rollbar.com/llms.txt)

| API default | Value |
| - | - |
| `base_url` | `https://api.rollbar.com/api/1` |
| `base_url_env` | `ROLLBAR_API_URL` |
| Credential | `ROLLBAR_ACCESS_TOKEN` (header `X-Rollbar-Access-Token`) |

Assumptions:

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

### `rollbar.deploy`

**Deploy** (`http.action`). A deploy of the release's revision in the environment's Rollbar `environment` (such as `preview`), sent once per environment and revision, so errors are attributed to the deploy. Nothing to undo: Rollbar deletes no deploys (RB3).

Covers: `rollbar-deploy` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `deploy_id`. On destroy: nothing is sent (there is nothing to undo); the record is forgotten.

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `environment` | string | yes | — | The Rollbar environment the deployment's SDK reports, such as `${ctx.env}` (RB5). |
| `revision` | string | yes | — | The revision deployed, usually the commit: `"${ctx.git.sha}"`. A new one sends a new deploy. |
| `comment` | string | no | — | Text shown with the deploy, such as the scope. |
| `local_username` | string | no | `sponson` | Who deployed. |
| `rollbar_username` | string | no | — | A Rollbar user to attribute the deploy to. |
| `status` | string | no | `succeeded` | The deploy's status. One of `started`, `succeeded`, `failed`, `timed_out`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: deploy
  adapter: http
  op: action
  recipe: rollbar.deploy
  environment: ${ctx.env}
  revision: ${ctx.git.sha}
  comment: ${ctx.scope}
```

What the line expands into: the [`http.action`](/reference/plan-format#http-action) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
trigger:
  environment: { param: environment }
  revision: { param: revision }
request:
  method: POST
  path: /deploy
  body:
    environment: { param: environment }
    revision: { param: revision }
    comment: { param: comment }
    local_username: { param: local_username }
    rollbar_username: { param: rollbar_username }
    status: { param: status }
outputs:
  deploy_id: /data/deploy_id
```

## Amazon S3 / Cloudflare R2 (S3 API)

Recipe file: [`packages/adapters/recipes/s3.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/s3.yaml). Verified on 2026-10-11 against:

* [PutBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html)
* [GetBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketCors.html)
* [DeleteBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html)
* [R2 S3 API compatibility](https://developers.cloudflare.com/r2/api/s3/api/)

| API default | Value |
| - | - |
| `base_url` | none: set it in the provider block |
| `base_url_env` | `S3_API_URL` |
| Credential | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (AWS SigV4, `s3` in `us-east-1`) |
| `encoding` | `xml` |

Assumptions:

* **SS1** (verified, [source](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html)): `PUT /?cors` on the bucket's endpoint replaces its whole CORS configuration with a `CORSConfiguration` document of up to 100 `CORSRule`s (each `ID`, `AllowedOrigin`s, `AllowedMethod`s, `AllowedHeader`s, `ExposeHeader`s, `MaxAgeSeconds`) and requires `Content-MD5`, which the SigV4 signer adds for `s3`.
* **SS2** (verified, [source](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketCors.html)): `GET /?cors` answers the configuration as XML.
* **SS3** (verified, [source](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html)): `DELETE /?cors` removes the configuration (the way to leave a bucket with no rule).
* **SS4** (verified, [source](https://developers.cloudflare.com/r2/api/s3/api/)): R2 implements PutBucketCors, GetBucketCors and DeleteBucketCors at `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`; its SigV4 region is `auto`, and `us-east-1` is accepted as an alias, so the recipe's region works for R2 unchanged.
* **SS5** **(unverified)**: A bucket without a CORS configuration answers 404 `NoSuchCORSConfiguration` (read as an empty list, `parent.if_missing: empty`), and an empty configuration is refused as `MalformedXML` (so the last rule is removed with DELETE, `parent.when_empty: delete`).
* **SS6** **(unverified)**: R2 accepts path-style requests (`https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<bucket>/?cors`), so its base URL names the bucket in the path.
* **SS7** **(unverified)**: S3 answers a rule's elements as they were written; a rule's `ID` identifies it among the bucket's rules (IDs are not required to be unique by S3, so other writers must not reuse the recipe's).

### `s3.bucket_cors`

**CORS rule of a bucket** (`http.list_item`). A CORS rule of an S3 or R2 bucket allowing the preview's origin, identified by its `ID` (such as `sponson-pr-42`); every other rule is left as it is. The base URL names the bucket: `https://<bucket>.s3.<region>.amazonaws.com` (set `auth.aws_sigv4.region` to the bucket's region), or `https://<account>.r2.cloudflarestorage.com/<bucket>` for R2. A console edit of the rule is drift; destroy removes only this rule (the whole configuration when it was the last one).

Covers: `s3-r2-cors` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `rule_id` | string | yes | — | The rule's `ID`, at most 255 characters; its identity (SS7). Matches `^.{1,255}$`. |
| `origin` | string | yes | — | The origin allowed (the preview's URL, without a path). Matches `^(https?://\\S+\|\\*)$`. |
| `methods` | list | no | `["GET"]` | The methods allowed: `GET`, `PUT`, `POST`, `DELETE`, `HEAD`. |
| `allowed_headers` | list | no | — | Request headers a preflight may ask for (`*` for any); absent: none. |
| `expose_headers` | list | no | — | Response headers the browser may read; absent, none. |
| `max_age_seconds` | string | no | `3000` | How long a preflight answer may be cached, in seconds (a string, as XML carries it). Matches `^[0-9]+$`. |

```yaml theme={null}
providers:
  http:
    s3:
      recipe: s3
      base_url: https://assets.s3.us-east-1.amazonaws.com
changes:
  - id: bucket_cors
    adapter: http
    op: list_item
    recipe: s3.bucket_cors
    rule_id: sponson-${ctx.scope}
    origin: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /?cors
  method: PUT
  if_missing: empty
  when_empty: delete
list_path: /CORSConfiguration/CORSRule
key_field: ID
item:
  AllowedHeader: { param: allowed_headers }
  AllowedMethod: { param: methods }
  AllowedOrigin: { param: origin }
  ExposeHeader: { param: expose_headers }
  ID: { param: rule_id }
  MaxAgeSeconds: { param: max_age_seconds }
```

## Sanity

Recipe file: [`packages/adapters/recipes/sanity.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/sanity.yaml). Verified on 2026-10-12 against:

* [Projects API reference (datasets, CORS origins)](https://www.sanity.io/docs/http-reference/projects-api)
* [Datasets (naming rules, plan limits, environments)](https://www.sanity.io/docs/content-lake/datasets)
* [CORS origins (preview deployments)](https://www.sanity.io/docs/content-lake/cors)
* [HTTP API authentication (robot tokens)](https://www.sanity.io/docs/content-lake/http-auth)
* [Cloud Clone (dataset copy)](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets)
* [CLI configuration (SANITY\_AUTH\_TOKEN)](https://www.sanity.io/docs/cli-reference/cli-config)
* [Pricing (datasets per plan)](https://www.sanity.io/pricing)

| API default | Value |
| - | - |
| `base_url` | `https://api.sanity.io/v2021-06-07` |
| `base_url_env` | `SANITY_API_URL` |
| Credential | `SANITY_AUTH_TOKEN` (bearer) |

Assumptions:

* **SN1** (verified, [source](https://www.sanity.io/docs/http-reference/projects-api)): The Projects API is served at `https://api.sanity.io/<version>` (default `v2021-06-07`) and authenticates with a robot token as `Authorization: Bearer`; the Sanity CLI reads a token for unattended use from `SANITY_AUTH_TOKEN`.
* **SN2** (verified, [source](https://www.sanity.io/docs/user-guides/roles)): 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](https://www.sanity.io/docs/http-reference/projects-api)): `PUT /projects/{projectId}/datasets/{name}` with an optional `{ aclMode }` (`public`, the default, or `private`, a paid feature) creates an empty dataset and answers 200 with `{ datasetName, aclMode }`, not the dataset itself, so the recipe finds it by name afterwards (`create.locate`).
* **SN4** (verified, [source](https://www.sanity.io/docs/http-reference/projects-api)): `GET /projects/{projectId}/datasets` answers a bare list of `{ name, aclMode, … }`; dataset names are unique in a project.
* **SN5** (verified, [source](https://www.sanity.io/docs/http-reference/projects-api)): `DELETE /projects/{projectId}/datasets/{name}` deletes the dataset and its documents and answers `{ deleted: true }`.
* **SN6** (verified, [source](https://www.sanity.io/docs/content-lake/datasets)): A dataset name is 1 to 64 characters of lowercase letters, digits, `-` and `_`, beginning and ending with a letter or digit.
* **SN7** (verified, [source](https://www.sanity.io/pricing)): 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](https://www.sanity.io/docs/content-lake/cors)): A browser querying the Content Lake (or a Studio, or the Presentation tool) from an origin must find that origin in the project's CORS origins; preview deployments get a new URL per branch, so the origin cannot be added ahead of time, and unused preview origins should be removed. `https://*.vercel.app` with credentials is unsafe.
* **SN9** (verified, [source](https://www.sanity.io/docs/http-reference/projects-api)): `GET /projects/{projectId}/cors` answers a bare list of `{ id, origin, allowCredentials, createdAt }` with integer ids; `POST /projects/{projectId}/cors` with `{ origin, allowCredentials? }` creates one (201); `DELETE /projects/{projectId}/cors/{id}` deletes it.
* **SN10** **(unverified)**: No update of a CORS origin or of a dataset's `aclMode` is documented, so neither is compared for drift: `allowCredentials` and `aclMode` are sent when the line creates the object, and only its existence is checked after.
* **SN11** **(unverified)**: A dataset being deleted (or a `PUT` of a name that exists) answers within the request; nothing waits, and a dataset is usable as soon as the `PUT` returns.

### `sanity.dataset`

**Dataset per preview** (`http.resource`). An empty Sanity dataset per scope in a project, such as `preview-pr-42`, for the preview to import or seed its content into (SN7: copying production into it is Enterprise's Cloud Clone and is not covered). Located by name; only its existence is compared for drift; destroy deletes the dataset and its documents.

Covers: `sanity-dataset` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `name`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project id (`projectId` in sanity.config.ts). Matches `^[a-z0-9]+$`. |
| `name` | string | yes | — | The dataset's name; its identity in the project (SN6). Matches `^(?!.*[-_]$)[a-z0-9][-_a-z0-9]{0,63}$`. |
| `acl_mode` | string | no | `public` | Who may read it without a token: `public` (Sanity's default) or `private` (Growth and up). Sent on create only (SN10). One of `public`, `private`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: dataset
  adapter: http
  op: resource
  recipe: sanity.dataset
  project_id: abc123de
  name: preview-${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /projects/{project_id}/datasets
  match:
    name: { param: name }
create:
  method: PUT
  path: /projects/{project_id}/datasets/{name}
  body:
    aclMode: { param: acl_mode }
  send_fields: false
  locate: true
delete:
  path: /projects/{project_id}/datasets/{id}
id_path: /name
outputs:
  name: /name
```

### `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](/reference/coverage#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](https://github.com/sanity-io/client/blob/main/src/http/errors.ts)).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_id` | string | yes | — | The project id (`projectId` in sanity.config.ts). Matches `^[a-z0-9]+$`. |
| `origin` | string | yes | — | The origin, scheme and host without a path or trailing slash: usually `{ from: <deploy line>.preview_url }`. Matches `^https?://[^/\\s]+$`. |
| `allow_credentials` | boolean | no | `false` | Allow requests with credentials (a token or a login), which a Studio needs; never for a wildcard origin (SN8). Sent on create only (SN10). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: cors_origin
  adapter: http
  op: resource
  recipe: sanity.cors_origin
  project_id: abc123de
  origin: https://${ctx.scope}.preview.example.app
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /projects/{project_id}/cors
  match:
    origin: { param: origin }
create:
  path: /projects/{project_id}/cors
  body:
    allowCredentials: { param: allow_credentials }
delete:
  path: /projects/{project_id}/cors/{id}
```

## SendGrid

Recipe file: [`packages/adapters/recipes/sendgrid.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/sendgrid.yaml). Verified on 2026-10-11 against:

* [Create an Event Webhook](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/create-an-event-webhook)
* [Get all Event Webhooks](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/get-all-event-webhooks)
* [Get an Event Webhook](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/get-an-event-webhook)
* [Update an Event Webhook](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/update-an-event-webhook)
* [Delete an Event Webhook](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/delete-an-event-webhook)
* [Create a parse setting](https://www.twilio.com/docs/sendgrid/api-reference/settings-inbound-parse/create-a-parse-setting)
* [Retrieve a specific parse setting](https://www.twilio.com/docs/sendgrid/api-reference/settings-inbound-parse/retrieve-a-specific-parse-setting)
* [Update a parse setting](https://www.twilio.com/docs/sendgrid/api-reference/settings-inbound-parse/update-a-parse-setting)
* [Delete a parse setting](https://www.twilio.com/docs/sendgrid/api-reference/settings-inbound-parse/delete-a-parse-setting)

| API default | Value |
| - | - |
| `base_url` | `https://api.sendgrid.com` |
| `base_url_env` | `SENDGRID_API_URL` |
| Credential | `SENDGRID_API_KEY` (bearer) |

Assumptions:

* **SG1** (verified, [source](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/create-an-event-webhook)): `POST /v3/user/webhooks/event/settings` creates an Event Webhook from `url` (required), `enabled`, one boolean per event type (`processed`, `delivered`, `dropped`, `deferred`, `bounce`, `open`, `click`, `spam_report`, `unsubscribe`, `group_unsubscribe`, `group_resubscribe`) and `friendly_name`, answering 201 with the webhook and its string `id`; requests carry the API key as `Authorization: Bearer`. EU regional subusers use `https://api.eu.sendgrid.com`.
* **SG2** (verified, [source](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/create-an-event-webhook)): 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](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/get-all-event-webhooks)): `GET /v3/user/webhooks/event/settings/all` answers `{ max_allowed, webhooks: [...] }` with every webhook's settings and `id`.
* **SG4** (verified, [source](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/update-an-event-webhook)): `PATCH /v3/user/webhooks/event/settings/{id}` requires `url` and updates the fields sent; `DELETE` on the same path answers 204, and 404 for an unknown id. Without an id these paths act on the oldest webhook, which the recipe never does (its paths always carry the id).
* **SG5** (verified, [source](https://www.twilio.com/docs/sendgrid/api-reference/webhooks/create-an-event-webhook)): 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](https://www.twilio.com/docs/sendgrid/api-reference/settings-inbound-parse/create-a-parse-setting)): `POST /v3/user/webhooks/parse/settings` creates an Inbound Parse setting from `url`, `hostname`, `spam_check` and `send_raw`, answering 201 with those four fields bare; `GET`, `PATCH` and `DELETE` (204) `/v3/user/webhooks/parse/settings/{hostname}` read, update and remove it. The hostname must be on a domain authenticated in SendGrid, with an MX record pointing at SendGrid.
* **SG7** **(unverified)**: A PATCH that leaves out an event flag keeps its current value (the documentation's examples send only `url`); the recipe sends every flag it compares anyway.
* **SG8** **(unverified)**: A parse setting for a hostname that exists already is refused with an error status (not documented which); the next run then finds it by its hostname.

### `sendgrid.event_webhook`

**Event Webhook** (`http.resource`). An Event Webhook delivering an environment's email events to its URL, such as the preview's `/api/sendgrid/events` handler. Located by its URL (unique in SendGrid); a console edit of its enabled flag, an event flag or its friendly name is drift.

Covers: `sendgrid-webhook` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `url` | string | yes | — | The URL events are posted to; its identity (SG2). Matches `^https?://\\S+$`. |
| `enabled` | boolean | no | `true` | Whether events are posted. |
| `friendly_name` | string | no | — | A label for the webhook (not unique). |
| `processed` | boolean | no | `false` | Post `processed` events. |
| `delivered` | boolean | no | `true` | Post `delivered` events. |
| `dropped` | boolean | no | `true` | Post `dropped` events. |
| `deferred` | boolean | no | `false` | Post `deferred` events. |
| `bounce` | boolean | no | `true` | Post `bounce` events. |
| `open` | boolean | no | `false` | Post `open` events. |
| `click` | boolean | no | `false` | Post `click` events. |
| `spam_report` | boolean | no | `true` | Post `spam_report` events. |
| `unsubscribe` | boolean | no | `false` | Post `unsubscribe` events. |
| `group_unsubscribe` | boolean | no | `false` | Post `group_unsubscribe` events. |
| `group_resubscribe` | boolean | no | `false` | Post `group_resubscribe` events. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: event_webhook
  adapter: http
  op: resource
  recipe: sendgrid.event_webhook
  url: https://${ctx.scope}.preview.example.com/api/sendgrid/events
  friendly_name: sponson ${ctx.env} ${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /v3/user/webhooks/event/settings/all
  list_path: /webhooks
  match:
    url: { param: url }
create:
  path: /v3/user/webhooks/event/settings
update:
  method: PATCH
  path: /v3/user/webhooks/event/settings/{id}
  body:
    url: { param: url }
delete:
  path: /v3/user/webhooks/event/settings/{id}
fields:
  enabled: { param: enabled }
  friendly_name: { param: friendly_name }
  processed: { param: processed }
  delivered: { param: delivered }
  dropped: { param: dropped }
  deferred: { param: deferred }
  bounce: { param: bounce }
  open: { param: open }
  click: { param: click }
  spam_report: { param: spam_report }
  unsubscribe: { param: unsubscribe }
  group_unsubscribe: { param: group_unsubscribe }
  group_resubscribe: { param: group_resubscribe }
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `hostname` | string | yes | — | The host mail is received for, lower-case; its identity. Matches `^[a-z0-9.-]+$`. |
| `url` | string | yes | — | The URL parsed mail is posted to. Matches `^https?://\\S+$`. |
| `spam_check` | boolean | no | `false` | Check mail for spam before posting it. |
| `send_raw` | boolean | no | `false` | Post the original MIME message. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: parse_setting
  adapter: http
  op: resource
  recipe: sendgrid.parse_setting
  hostname: ${ctx.scope}.parse.example.com
  url: https://${ctx.scope}.preview.example.com/api/sendgrid/inbound
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /v3/user/webhooks/parse/settings/{hostname}
create:
  path: /v3/user/webhooks/parse/settings
  body:
    hostname: { param: hostname }
update:
  method: PATCH
id_path: /hostname
fields:
  url: { param: url }
  spam_check: { param: spam_check }
  send_raw: { param: send_raw }
```

## Sentry

Recipe file: [`packages/adapters/recipes/sentry.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/sentry.yaml). Verified on 2026-10-11 against:

* [Create a New Release for an Organization](https://docs.sentry.io/api/releases/create-a-new-release-for-an-organization/)
* [Retrieve an Organization's Release](https://docs.sentry.io/api/releases/retrieve-an-organizations-release/)
* [Update an Organization's Release](https://docs.sentry.io/api/releases/update-an-organizations-release/)
* [Delete an Organization's Release](https://docs.sentry.io/api/releases/delete-an-organizations-release/)
* [Create a Deploy](https://docs.sentry.io/api/releases/create-a-deploy/)
* [List a Release's Deploys](https://docs.sentry.io/api/releases/list-a-releases-deploys/)
* [Release Management (sentry-cli)](https://docs.sentry.io/cli/releases/)

| API default | Value |
| - | - |
| `base_url` | `https://sentry.io/api/0` |
| `base_url_env` | `SENTRY_API_URL` |
| Credential | `SENTRY_AUTH_TOKEN` (bearer) |

Assumptions:

* **SE1** (verified, [source](https://docs.sentry.io/api/releases/create-a-new-release-for-an-organization/)): `POST /api/0/organizations/{organization_id_or_slug}/releases/` creates a release from `version` and `projects` (both required), `ref`, `url`, `dateReleased` and commit data, with a bearer token holding `project:releases`, `project:write`, `project:admin` or `org:ci`; it answers 201 (or 208).
* **SE2** (verified, [source](https://docs.sentry.io/api/releases/retrieve-an-organizations-release/)): 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](https://docs.sentry.io/api/releases/update-an-organizations-release/)): `PUT /api/0/organizations/{org}/releases/{version}/` updates `ref`, `url`, `dateReleased` and commit data (the documentation says an omitted `dateReleased` is the current time), and answers the release.
* **SE4** (verified, [source](https://docs.sentry.io/api/releases/delete-an-organizations-release/)): `DELETE /api/0/organizations/{org}/releases/{version}/` permanently removes a release and its files (`project:releases` or `project:admin`). The recipe keeps releases on destroy: one version is shared by every environment it is deployed to.
* **SE5** (verified, [source](https://docs.sentry.io/api/releases/create-a-deploy/)): `POST /api/0/organizations/{org}/releases/{version}/deploys/` records a deploy of the release from `environment` (required), `name`, `url`, `dateStarted`, `dateFinished` (default now) and `projects` (default: all of the release's).
* **SE6** (verified, [source](https://docs.sentry.io/cli/releases/)): `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](https://docs.sentry.io/api/releases/create-a-new-release-for-an-organization/)): The release's `dateReleased` defaults to the current time on create, so a release this recipe creates is finalized at once.
* **SE8** **(unverified)**: A create for a version that exists answers 208 with the existing release (not an error), so a release another tool made (sentry-cli in the build) is taken over without a second write; the recipe keeps it on destroy either way.
* **SE9** **(unverified)**: The deploy list answers a bare array of `{ id, environment, name, url, dateStarted, dateFinished }` with string ids, all of a release's deploys on the first page; `environment` and `name` together identify the deploy this recipe recorded.
* **SE10** **(unverified)**: Release `url` is answered as sent (null when unset); organizations hosted in another region (`de.sentry.io`) or self-hosted Sentry set `base_url` in the provider block.

### `sentry.release`

**Release** (`http.resource`). A release for the commit, such as `app@${ctx.git.sha}`, in the given projects, finalized when created (SE7). Named by its version; a console edit of its URL is drift. Kept on destroy (shared history: preview and production deploy the same version); `destroy: delete` on the line removes it.

Covers: `sentry-release` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: left in place by default (`destroy: delete` on the line removes it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `organization` | string | yes | — | The organization's slug or id. |
| `version` | string | yes | — | The release's version (no slashes or whitespace), such as `app@<sha>`. Matches `^[^/\\s]+$`. |
| `project` | string | yes | — | The slug of the project the release belongs to (a release of several projects is a hand-written `http` line). |
| `url` | string | no | — | A link for the release (its commit or build). |
| `ref` | string | no | — | A commit reference, sent on create. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: release
  adapter: http
  op: resource
  recipe: sentry.release
  organization: acme
  version: app@${ctx.git.sha}
  project: web
  url: https://github.com/acme/app/commit/${ctx.git.sha}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /organizations/{organization}/releases/{version}/
create:
  path: /organizations/{organization}/releases/
  body:
    version: { param: version }
    projects:
      - { param: project }
    ref: { param: ref }
update:
  method: PUT
id_path: /version
fields:
  url: { param: url }
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: left in place: the provider offers no way to delete it (`destroy: delete` is refused).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `organization` | string | yes | — | The organization's slug or id. |
| `version` | string | yes | — | The deployed release's version; the release must exist (`{ from: <release line>.id }` orders the lines). Matches `^[^/\\s]+$`. |
| `environment` | string | yes | — | The environment deployed to. |
| `name` | string | yes | — | The deploy's name, which with the environment identifies it among the release's deploys. |
| `url` | string | no | — | A link to the deployment (the preview URL), sent on create. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: deploy
  adapter: http
  op: resource
  recipe: sentry.deploy
  organization: acme
  version: app@${ctx.git.sha}
  environment: ${ctx.env}
  name: sponson-${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /organizations/{organization}/releases/{version}/deploys/
  match:
    environment: { param: environment }
    name: { param: name }
create:
  path: /organizations/{organization}/releases/{version}/deploys/
  body:
    url: { param: url }
delete:
  path: /organizations/{organization}/releases/{version}/deploys/{id}/
```

## Split (Harness FME)

Recipe file: [`packages/adapters/recipes/split.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/split.yaml). Verified on 2026-10-11 against:

* [Authentication](https://docs.split.io/reference/authentication)
* [Get feature flag definition in environment](https://docs.split.io/reference/get-feature-flag-definition-in-environment)
* [Full update feature flag definition in environment](https://docs.split.io/reference/full-update-feature-flag-definition-in-environment)
* [Feature flag definition, rule, condition, matcher](https://docs.split.io/reference/feature-flag-definition)
* [Matcher types](https://docs.split.io/reference/matcher-type)
* [API for Split admins after the move to Harness](https://developer.harness.io/docs/feature-management-experimentation/split-to-harness/api-for-split-admins/)

| API default | Value |
| - | - |
| `base_url` | `https://api.split.io/internal/api/v2` |
| `base_url_env` | `SPLIT_API_URL` |
| Credential | `SPLIT_ADMIN_API_KEY` (bearer) |

Assumptions:

* **SP1** (verified, [source](https://developer.harness.io/docs/feature-management-experimentation/split-to-harness/api-for-split-admins/)): The Admin API is at `https://api.split.io/internal/api/v2`, authenticated as `Authorization: Bearer` with a Split Admin API key (or a Harness PAT/SAT, accepted as Bearer on the non-deprecated endpoints). Host and paths are unchanged by the move to Harness; the workspace id is still Split's `wsId`.
* **SP2** (verified, [source](https://docs.split.io/reference/get-feature-flag-definition-in-environment)): `GET /splits/ws/{wsId}/{featureFlagName}/environments/{environmentIdOrName}` answers the flag's definition in the environment bare: `treatments`, `defaultTreatment`, `baselineTreatment`, `trafficAllocation`, `rules`, `defaultRule` and read-only fields (`name`, `environment`, `trafficType`, times).
* **SP3** (verified, [source](https://docs.split.io/reference/full-update-feature-flag-definition-in-environment)): `PUT` on the same path replaces the definition with `treatments`, `defaultTreatment`, `baselineTreatment`, `trafficAllocation`, `rules` and `defaultRule` (the first three of the required fields). Those are sent as read, with the rule list changed (`parent.send`); whether PUT takes the read-only fields back is not documented, so they are not sent.
* **SP4** (verified, [source](https://docs.split.io/reference/matcher-type)): A rule is `{ condition: { combiner: AND, matchers: [...] }, buckets: [{ treatment, size }] }` (bucket sizes sum to 100); rules have no id and are evaluated in order. `IN_LIST_STRING` with `attribute` and `strings` matches an attribute in a list.
* **SP5** **(unverified)**: A rule is read back with its matcher exactly as written (no added `negate: false` or null members); otherwise every plan shows the rule as changed.
* **SP6** **(unverified)**: A full update leaves the flag's killed state alone (`killed` is not a documented PUT field).
* **SP7** (verified, [source](https://developer.harness.io/docs/feature-management-experimentation/api/feature-flags/)): 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](/reference/coverage#coverage-matrix). Outputs: none. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `workspace_id` | string | yes | — | The workspace (project) id, Split's `wsId`. |
| `flag` | string | yes | — | The feature flag's name. |
| `environment` | string | yes | — | The environment's id or name; the flag must have a definition there. |
| `attribute` | string | yes | — | The attribute the rule tests (passed with the evaluation). |
| `match` | string | yes | — | The attribute value that is the preview; it identifies the rule. |
| `treatment` | string | yes | — | The treatment served to the preview; one of the flag's treatments. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: targeting_rule
  adapter: http
  op: list_item
  recipe: split.targeting_rule
  workspace_id: a3b6f5d0-0e8f-11ee-9b0f-0242ac120002
  flag: new_checkout
  environment: Staging
  attribute: preview
  match: ${ctx.scope}
  treatment: on
```

What the line expands into: the [`http.list_item`](/reference/plan-format#http-list_item) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
parent:
  path: /splits/ws/{workspace_id}/{flag}/environments/{environment}
  method: PUT
  send:
    - treatments
    - defaultTreatment
    - baselineTreatment
    - trafficAllocation
    - defaultRule
list_path: /rules
key_field: /condition/matchers/0/strings/0
item:
  condition:
    combiner: AND
    matchers:
      - type: IN_LIST_STRING
        attribute: { param: attribute }
        strings:
          - { param: match }
  buckets:
    - treatment: { param: treatment }
      size: 100
```

## Statsig

Recipe file: [`packages/adapters/recipes/statsig.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/statsig.yaml). Verified on 2026-10-11 against:

* [Console API introduction (base URL, STATSIG-API-KEY, STATSIG-API-VERSION)](https://docs.statsig.com/console-api/introduction)
* [Add gate rule](https://docs.statsig.com/api-reference/gates/add-gate-rule)
* [Read gate rules](https://docs.statsig.com/api-reference/gates/read-gate-rules)
* [Update gate rules (one rule)](https://docs.statsig.com/api-reference/gates/update-gate-rules)
* [Delete gate rule](https://docs.statsig.com/api-reference/gates/delete-gate-rule)
* [Console API OpenAPI spec (20240601)](https://api.statsig.com/openapi/20240601.json)

| API default | Value |
| - | - |
| `base_url` | `https://statsigapi.net/console/v1` |
| `base_url_env` | `STATSIG_API_URL` |
| Credential | `STATSIG_CONSOLE_API_KEY` (header `STATSIG-API-KEY`) |
| `headers` | \{"STATSIG-API-VERSION":"20240601"} |

Assumptions:

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

### `statsig.gate_rule`

**Feature gate rule** (`http.resource`). A rule on an existing feature gate that passes the preview, such as `custom_field preview any [pr-42]`. Located by its name in the gate's rules; a console edit of its pass percentage, conditions or environments is drift. Destroy deletes the rule.

Covers: `statsig-gate-rule` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `gate_id` | string | yes | — | The gate's id (its name in the console URL). |
| `name` | string | yes | — | The rule's name, unique within the gate; it identifies the rule. |
| `condition_type` | string | no | `custom_field` | The condition's type: `custom_field` (with `field`), `url`, `environment_tier`, `user_id` or `email`. One of `custom_field`, `url`, `environment_tier`, `user_id`, `email`. |
| `field` | string | no | — | The custom field's name, for `condition_type` `custom_field`. |
| `operator` | string | no | `any` | The condition's operator: `any`, `none`, `str_contains_any`, … (see the Statsig rule docs). |
| `values` | list | yes | — | The values the condition matches (its `targetValue`). |
| `pass_percentage` | number | no | `100` | The share of matching users that pass (0–100). |
| `environment_names` | list | no | — | The environments the rule applies in (for example `[development, staging]`); all when left out. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: gate_rule
  adapter: http
  op: resource
  recipe: statsig.gate_rule
  gate_id: new_checkout
  name: preview ${ctx.scope}
  field: preview
  values:
    - ${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /gates/{gate_id}/rules
  list_path: /data/rules
  match:
    name: { param: name }
create:
  path: /gates/{gate_id}/rule
  locate: true
update:
  method: PATCH
  path: /gates/{gate_id}/rules/{id}
delete:
  path: /gates/{gate_id}/rules/{id}
fields:
  passPercentage: { param: pass_percentage }
  conditions:
    - type: { param: condition_type }
      field: { param: field }
      operator: { param: operator }
      targetValue: { param: values }
  environments: { param: environment_names }
```

## Stripe

Recipe file: [`packages/adapters/recipes/stripe.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/stripe.yaml). Verified on 2026-10-11 against:

* [Authentication](https://docs.stripe.com/api/authentication)
* [API keys](https://docs.stripe.com/keys)
* [Idempotent requests](https://docs.stripe.com/api/idempotent_requests)
* [Create a webhook endpoint](https://docs.stripe.com/api/webhook_endpoints/create)
* [List all webhook endpoints](https://docs.stripe.com/api/webhook_endpoints/list)
* [Update a webhook endpoint](https://docs.stripe.com/api/webhook_endpoints/update)
* [Delete a webhook endpoint](https://docs.stripe.com/api/webhook_endpoints/delete)
* [Webhooks in the Dashboard (endpoint limit)](https://docs.stripe.com/development/dashboard/webhooks)
* [Create a product](https://docs.stripe.com/api/products/create)
* [Retrieve a product](https://docs.stripe.com/api/products/retrieve)
* [Delete a product](https://docs.stripe.com/api/products/delete)
* [Create a price](https://docs.stripe.com/api/prices/create)
* [List all prices](https://docs.stripe.com/api/prices/list)
* [Update a price](https://docs.stripe.com/api/prices/update)
* [Create a coupon](https://docs.stripe.com/api/coupons/create)

| API default | Value |
| - | - |
| `base_url` | `https://api.stripe.com/v1` |
| `base_url_env` | `STRIPE_API_URL` |
| Credential | `STRIPE_SECRET_KEY` (bearer) |
| `encoding` | `form` |
| `refuse_credential` | credentials starting `sk_live_` or `rk_live_` are refused (`PROVIDER_AUTH`) unless `SPONSON_STRIPE_ALLOW_LIVE=1`; a plan cannot override this |

Assumptions:

* **ST1** (verified, [source](https://docs.stripe.com/api/authentication)): 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](https://docs.stripe.com/keys)): Test-mode keys start with `sk_test_` / `rk_test_`, live-mode keys with `sk_live_` / `rk_live_`; objects of one mode are invisible to the other. The recipe refuses the live prefixes (`refuse_credential`) unless `SPONSON_STRIPE_ALLOW_LIVE=1`.
* **ST3** **(unverified)**: Organization keys (`sk_org_…`) and platform-managed keys do not carry the mode in their prefix, so they are not refused: use a test-mode secret or restricted key for previews.
* **ST4** (verified, [source](https://docs.stripe.com/api/idempotent_requests)): Every v1 `POST` accepts an `Idempotency-Key` (up to 255 characters, kept at least 24 hours); a retry with the same key returns the saved answer, and different parameters under one key are an error.
* **ST5** (verified, [source](https://docs.stripe.com/api/webhook_endpoints/create)): `POST /v1/webhook_endpoints` takes `url`, `enabled_events` (`['*']` for all), `description`, `metadata` and `api_version`, and answers the endpoint with its signing `secret` populated.
* **ST6** (verified, [source](https://docs.stripe.com/api/webhook_endpoints/list)): `GET /v1/webhook_endpoints` (`limit` 1–100) answers `{ data: [...], has_more }` whose endpoints carry `url`, `enabled_events`, `description` and `metadata` but no `secret`: the secret exists only in the create's answer, hence the `once` output.
* **ST7** (verified, [source](https://docs.stripe.com/development/dashboard/webhooks)): 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](https://docs.stripe.com/api/webhook_endpoints/update)): `POST /v1/webhook_endpoints/{id}` updates `url`, `enabled_events`, `description`, `metadata` and `disabled` in place (the secret is not rotated); `DELETE` on the same path removes the endpoint.
* **ST9** **(unverified)**: The API returns `url`, `enabled_events` and `description` as they were written, `enabled_events` in the order sent, so an unchanged endpoint hashes the same on every plan.
* **ST10** (verified, [source](https://docs.stripe.com/api/products/create)): `POST /v1/products` takes a client-chosen `id` (unique among the account's products), `name`, `description` and `active`; `GET /v1/products/{id}` retrieves it; `POST /v1/products/{id}` updates it.
* **ST11** (verified, [source](https://docs.stripe.com/api/products/delete)): 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](https://docs.stripe.com/api/prices/create)): `POST /v1/prices` takes `product`, `currency`, `unit_amount`, `recurring[interval]`, `nickname`, `lookup_key` and `transfer_lookup_key` (which moves the lookup key from the price holding it to the new one).
* **ST14** (verified, [source](https://docs.stripe.com/api/prices/list)): `GET /v1/prices` lists active prices by default, filters by up to 10 `lookup_keys` and by `active`, and answers `{ data: [...] }`.
* **ST15** (verified, [source](https://docs.stripe.com/api/prices/update)): A price's amount, currency and interval cannot change after creation; `POST /v1/prices/{id}` updates `active`, `nickname`, `lookup_key` and `metadata`. There is no delete: the recipe archives (`active: false`).
* **ST16** (verified, [source](https://docs.stripe.com/api/coupons/create)): `POST /v1/coupons` takes a client-chosen `id`, `name`, `percent_off` or `amount_off` with `currency`, `duration` (`once`, `repeating`, `forever`) and `duration_in_months`.
* **ST17** **(unverified)**: `GET /v1/coupons/{id}` retrieves a coupon, `POST /v1/coupons/{id}` updates its `name` and `metadata` only (the discount is fixed), and `DELETE /v1/coupons/{id}` deletes it, after which its id may be used again.
* **ST18** **(unverified)**: Stripe must reach the endpoint's URL over public HTTPS: a preview behind deployment protection (Vercel Authentication) receives no events unless it lets Stripe's requests through.

### `stripe.webhook_endpoint`

**Webhook endpoint** (`http.resource`). A test-mode webhook endpoint that sends the preview's events to its own URL, `url` + `path` (usually `{ from: web.preview_url }`). Identified by `name` in its metadata, so a new preview URL updates the endpoint in place. Its signing `secret` exists only in the answer that creates the endpoint: it is a once-only output, passed in the same run to the line that writes it (`STRIPE_WEBHOOK_SECRET` in a `vercel.env` or `netlify.env` line); later runs keep what that line holds, and `apply --recreate <line>` rotates it. Writing the secret rebuilds the preview, and a preview URL output follows the newest deploy, so the next run moves the endpoint to the rebuilt deploy's URL (in place: the secret is kept); a URL that does not change per deploy (`https://deploy-preview-${ctx.pr.number}--<site>.netlify.app`, a custom preview domain) avoids that update. A console edit of its URL, events or description is drift.

Covers: `stripe-webhook` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `secret` (sensitive, [once](/reference/plan-format#once-only-outputs): 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](https://docs.stripe.com/webhooks)).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The endpoint's identity, kept in its metadata (`sponson_endpoint`): unique per preview, such as `preview-${ctx.scope}`. Matches `^[A-Za-z0-9_.:/-]{1,200}$`. |
| `url` | string | yes | — | The preview's base URL, without a trailing slash: usually `{ from: <vercel.env or netlify.env line>.preview_url }`. Matches `^https://\\S*[^/]$`. |
| `path` | string | no | `/api/webhooks/stripe` | The path of the app's webhook handler, appended to `url`. Matches `^/\\S*$`. |
| `enabled_events` | list | no | `["*"]` | The event types sent to the endpoint (`['*']`: all that need no explicit selection). |
| `description` | string | no | — | A note shown in the Dashboard. |
| `api_version` | string | no | — | The Stripe API version events are rendered with (the account's default when left out); sent on create only. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: webhook_endpoint
  adapter: http
  op: resource
  recipe: stripe.webhook_endpoint
  name: preview-${ctx.scope}
  url: https://pr-42.preview.example.com
  enabled_events:
    - checkout.session.completed
    - customer.subscription.updated
  description: Preview webhook managed by Sponson
```

What the line expands into: the [`http.resource`](/reference/plan-format#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.

```yaml theme={null}
find:
  path: /webhook_endpoints?limit=100
  list_path: /data
  match:
    /metadata/sponson_endpoint: { param: name }
create:
  path: /webhook_endpoints
  idempotency_key: true
  body:
    api_version: { param: api_version }
update:
  method: POST
  path: /webhook_endpoints/{id}
delete:
  path: /webhook_endpoints/{id}
fields:
  url:
    join:
      - { param: url }
      - { param: path }
  enabled_events: { param: enabled_events }
  description: { param: description }
outputs:
  secret:
    path: /secret
    sensitive: true
    once: true
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `product_id` | string | yes | — | The product's id, unique in the account, such as `pro-${ctx.scope}`. Matches `^[A-Za-z0-9_-]{1,100}$`. |
| `name` | string | yes | — | The product's name, shown to customers. |
| `description` | string | no | — | The product's description, shown to customers. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: product
  adapter: http
  op: resource
  recipe: stripe.product
  product_id: pro-${ctx.scope}
  name: Pro (preview)
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /products/{product_id}
create:
  path: /products
  idempotency_key: true
  body:
    id: { param: product_id }
update:
  method: POST
delete:
  method: POST
  body:
    active: false
fields:
  name: { param: name }
  description: { param: description }
  active: true
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `lookup` | string | yes | — | The price's `lookup_key`: what the preview's code looks it up by, unique among active prices. (Named `lookup` because a plan key ending in `_key` must hold a secret reference.) Matches `^\\S{1,200}$`. |
| `product` | string | yes | — | The product's id: usually `{ from: <stripe.product line>.id }`. |
| `currency` | string | yes | — | Three-letter ISO currency code, lower-case. Matches `^[a-z]{3}$`. |
| `unit_amount` | integer | yes | — | The amount in the currency's smallest unit (1500 is 15.00 USD). |
| `interval` | string | no | — | The billing interval of a recurring price; left out, the price is one-time. One of `day`, `week`, `month`, `year`. |
| `nickname` | string | no | — | A note on the price, hidden from customers. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: price
  adapter: http
  op: resource
  recipe: stripe.price
  lookup: pro-monthly-${ctx.scope}
  product: pro-pr-42
  currency: usd
  unit_amount: 1500
  interval: month
  nickname: Pro monthly (preview)
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /prices?lookup_keys[]={lookup}&active=true&limit=10
  list_path: /data
  match:
    lookup_key: { param: lookup }
    active: true
    product: { param: product }
    currency: { param: currency }
    unit_amount: { param: unit_amount }
    /recurring/interval: { param: interval }
create:
  path: /prices
  idempotency_key: true
  body:
    transfer_lookup_key: true
update:
  method: POST
  path: /prices/{id}
delete:
  method: POST
  path: /prices/{id}
  body:
    active: false
fields:
  nickname: { param: nickname }
```

### `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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `coupon_id` | string | yes | — | The coupon's id (the code applied to a customer), unique in the account. Matches `^[A-Za-z0-9_-]{1,100}$`. |
| `percent_off` | number | no | — | The discount in percent (0–100); or set `amount_off` and `currency`. |
| `amount_off` | integer | no | — | The discount in the currency's smallest unit, with `currency`. |
| `currency` | string | no | — | The currency of `amount_off`. Matches `^[a-z]{3}$`. |
| `duration` | string | no | `once` | How long the discount applies to a subscription. One of `once`, `repeating`, `forever`. |
| `duration_in_months` | integer | no | — | For `duration: repeating`, the number of months. |
| `name` | string | no | — | The name shown to customers (the id when left out). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: coupon
  adapter: http
  op: resource
  recipe: stripe.coupon
  coupon_id: preview-${ctx.scope}
  percent_off: 25
  name: Preview discount
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /coupons/{coupon_id}
create:
  path: /coupons
  idempotency_key: true
  body:
    id: { param: coupon_id }
    percent_off: { param: percent_off }
    amount_off: { param: amount_off }
    currency: { param: currency }
    duration: { param: duration }
    duration_in_months: { param: duration_in_months }
update:
  method: POST
fields:
  name: { param: name }
```

## Stytch

Recipe file: [`packages/adapters/recipes/stytch.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/stytch.yaml). Verified on 2026-10-11 against:

* [Create redirect URL (PWA v3)](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/create-redirect-url)
* [Get all redirect URLs (PWA v3)](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/get-all-redirect-urls)
* [Get redirect URL (PWA v3)](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/get-redirect-url)
* [Update redirect URL (PWA v3)](https://stytch.com/docs/workspace-management/pwa/update-redirect-url)
* [Delete redirect URL (PWA v3)](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/delete-redirect-url)
* [Limits](https://stytch.com/docs/workspace-management/pwa/limits)

| API default | Value |
| - | - |
| `base_url` | `https://management.stytch.com` |
| `base_url_env` | `STYTCH_MANAGEMENT_API_URL` |
| Credential | `STYTCH_WORKSPACE_KEY_ID` / `STYTCH_WORKSPACE_KEY_SECRET` (basic) |

Assumptions:

* **SY1** (verified, [source](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/create-redirect-url)): 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](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/create-redirect-url)): `POST /pwa/v3/projects/{project_slug}/environments/{environment_slug}/redirect_urls` takes `url`, `valid_types` (`{ type, is_default }`, type one of LOGIN, INVITE, SIGNUP, RESET\_PASSWORD, DISCOVERY) and `do_not_promote_defaults`, and answers `{ request_id, redirect_url: { url, valid_types }, status_code }`.
* **SY3** (verified, [source](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/get-all-redirect-urls)): `GET …/redirect_urls/all` answers `{ redirect_urls: [{ url, valid_types }], … }`: every redirect URL of the environment, unpaged.
* **SY4** (verified, [source](https://stytch.com/docs/api-reference/pwa/api/v3/redirect-urls/delete-redirect-url)): A redirect URL has no id of its own: `GET`, `PUT` (to change `valid_types`) and `DELETE` on `…/redirect_urls?url=<url>` address it by its URL.
* **SY5** (verified, [source](https://stytch.com/docs/workspace-management/pwa/limits)): A project has at most 100 redirect URLs.
* **SY6** **(unverified)**: `do_not_promote_defaults: true` on create and delete keeps a preview URL from becoming, or a deletion from changing, the environment's default for a type.
* **SY7** **(unverified)**: A `PUT` body that also carries `url` (the adapter sends the identifying field back on PUT) is accepted, and `valid_types` read back in the order and form they were written, so an unchanged URL hashes the same on every plan.
* **SY8** **(unverified)**: Each redirect URL is its own object (no shared list to rewrite), so concurrent previews need no lock.

### `stytch.redirect_url`

**Redirect URL** (`http.resource`). One redirect URL of a Stytch project environment, such as the preview's `/authenticate`, with the redirect types it is valid for (by default login and sign-up, never the default). Found by its URL; a console edit of its types is drift and is put back with a `PUT`. Destroy deletes it without promoting another URL to default.

Covers: `stytch-redirect` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project_slug` | string | yes | — | The project's slug. Matches `^[A-Za-z0-9_-]+$`. |
| `environment_slug` | string | yes | — | The environment's slug within the project (its test environment, or a custom one). Matches `^[A-Za-z0-9_-]+$`. |
| `url` | string | yes | — | The redirect URL, exactly as Stytch will receive it (`https://pr-42.preview.example.com/authenticate`). Matches `^https?://\\S+$`. |
| `valid_types` | list | no | `[{"type":"LOGIN","is_default":false},{"type":"SIGNUP","is_default":false}]` | The redirect types the URL is valid for: `{ type, is_default }` with type LOGIN, INVITE, SIGNUP, RESET\_PASSWORD or DISCOVERY. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: redirect_url
  adapter: http
  op: resource
  recipe: stytch.redirect_url
  project_slug: my-app
  environment_slug: test
  url: https://${ctx.scope}.preview.example.com/authenticate
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /pwa/v3/projects/{project_slug}/environments/{environment_slug}/redirect_urls/all
  list_path: /redirect_urls
  match:
    url: { param: url }
create:
  path: /pwa/v3/projects/{project_slug}/environments/{environment_slug}/redirect_urls
  body:
    do_not_promote_defaults: true
update:
  method: PUT
  path: /pwa/v3/projects/{project_slug}/environments/{environment_slug}/redirect_urls?url={id}
  body:
    do_not_promote_defaults: true
delete:
  path: /pwa/v3/projects/{project_slug}/environments/{environment_slug}/redirect_urls?url={id}&do_not_promote_defaults=true
item_path: /redirect_url
id_path: /url
fields:
  valid_types: { param: valid_types }
```

## Svix

Recipe file: [`packages/adapters/recipes/svix.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/svix.yaml). Verified on 2026-10-11 against:

* [Svix API reference (OpenAPI document)](https://api.svix.com/api/v1/openapi.json)
* [Svix API reference](https://api.svix.com/docs)
* [Entities overview (ids and uids)](https://docs.svix.com/overview)

| API default | Value |
| - | - |
| `base_url` | `https://api.us.svix.com` |
| `base_url_env` | `SVIX_API_URL` |
| Credential | `SVIX_AUTH_TOKEN` (bearer) |

Assumptions:

* **SV1** (verified, [source](https://api.svix.com/api/v1/openapi.json)): `POST /api/v1/app/{app_id}/endpoint` creates an endpoint from `url` (required), `uid`, `description`, `eventTypes`, `channels`, `disabled`, `metadata` and an optional `secret`, answering 201 with the endpoint (`id` `ep_…`, `uid`, `url`, …); requests carry `Authorization: Bearer <token>`.
* **SV2** (verified, [source](https://api.svix.com/api/v1/openapi.json)): `GET`, `PATCH` (partial update) and `DELETE` (204) `/api/v1/app/{app_id}/endpoint/{endpoint_id}` take the endpoint's id or its uid, and `{app_id}` takes the application's id or uid.
* **SV3** (verified, [source](https://api.svix.com/api/v1/openapi.json)): 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](https://api.svix.com/api/v1/openapi.json)): The endpoint's signing secret is generated when `secret` is null (recommended) and is readable at any time with `GET …/endpoint/{endpoint_id}/secret` (`{ key }`): it is not a once-only value, and this recipe does not output it (an endpoint answer does not carry it).
* **SV5** (verified, [source](https://api.svix.com/api/v1/openapi.json)): The API is served per region (`api.us.svix.com`, `api.eu.svix.com`, `api.ca.svix.com`, `api.au.svix.com`), and POST requests accept an `idempotency-key` header.
* **SV6** **(unverified)**: A create with a uid that exists already answers 409 (the specification lists 409 for every operation without saying when), after which the endpoint is found by its uid and claimed only if an earlier intent of the same scope named it.
* **SV7** **(unverified)**: `description` is answered as sent (default empty), `disabled` as a boolean and `eventTypes` as the list sent, so they compare without drift on every plan; accounts outside the US region set `base_url` in the provider block.

### `svix.endpoint`

**Application endpoint** (`http.resource`). An endpoint of a Svix application delivering to an environment's URL, named by a uid such as `preview-pr-42`. Its URL, description, event type filter and disabled flag are compared for drift; its signing secret stays readable in Svix (SV4).

Covers: `svix-endpoint` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `app_id` | string | yes | — | The application's id (`app_…`) or uid. |
| `uid` | string | yes | — | The endpoint's uid, unique in the application; its identity. Matches `^[a-zA-Z0-9\\-_.]{1,256}$`. |
| `url` | string | yes | — | The URL messages are delivered to. Matches `^https?://\\S+$`. |
| `description` | string | no | \`\` | A note shown in the Svix dashboard and the application portal. |
| `event_types` | list | no | — | Event type names to deliver (all when left out); at least one when set. |
| `disabled` | boolean | no | `false` | Whether deliveries are paused. |
| `secret` | string | no | — | A signing secret to use instead of a generated one (`whsec_…`); sent on create only. Use `&#123; secret: … &#125;`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: endpoint
  adapter: http
  op: resource
  recipe: svix.endpoint
  app_id: acme-customer
  uid: sponson-${ctx.env}-${ctx.scope}
  url: https://${ctx.scope}.preview.example.com/api/webhooks
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /api/v1/app/{app_id}/endpoint/{uid}
create:
  path: /api/v1/app/{app_id}/endpoint
  body:
    uid: { param: uid }
    secret: { param: secret }
  idempotency_key: true
update:
  method: PATCH
  path: /api/v1/app/{app_id}/endpoint/{id}
delete:
  path: /api/v1/app/{app_id}/endpoint/{id}
fields:
  url: { param: url }
  description: { param: description }
  disabled: { param: disabled }
  eventTypes: { param: event_types }
```

## Turso

Recipe file: [`packages/adapters/recipes/turso.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/turso.yaml). Verified on 2026-10-11 against:

* [Create database](https://docs.turso.tech/api-reference/databases/create)
* [Retrieve database](https://docs.turso.tech/api-reference/databases/retrieve)
* [Delete database](https://docs.turso.tech/api-reference/databases/delete)
* [Branching](https://docs.turso.tech/features/branching)

| API default | Value |
| - | - |
| `base_url` | `https://api.turso.tech/v1` |
| `base_url_env` | `TURSO_API_URL` |
| Credential | `TURSO_API_TOKEN` (bearer) |

Assumptions:

* **TU1** (verified, [source](https://docs.turso.tech/api-reference/databases/create)): `POST /v1/organizations/{organizationSlug}/databases` with `name`, `group` and `seed: { type: database, name: <parent> }` creates a database copied from the parent, authenticated with a Platform API token as `Authorization: Bearer`.
* **TU2** (verified, [source](https://docs.turso.tech/api-reference/databases/create)): The create answers `{ database: { DbId, Hostname, Name } }` (capitalised), and 409 when a database with that name exists already.
* **TU3** (verified, [source](https://docs.turso.tech/api-reference/databases/retrieve)): `GET /v1/organizations/{organizationSlug}/databases/{databaseName}` answers `{ database: { Name, DbId, Hostname, group, … } }`, and 404 when there is none.
* **TU4** (verified, [source](https://docs.turso.tech/api-reference/databases/delete)): `DELETE` on the same path removes the database; 404 when it is gone.
* **TU5** (verified, [source](https://docs.turso.tech/api-reference/databases/create)): 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](/reference/coverage#coverage-matrix). Outputs: `id`, `hostname`, `db_id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `organization` | string | yes | — | The organization (or user account) slug. |
| `name` | string | yes | — | The new database's name. Matches `^[a-z0-9-]{1,64}$`. |
| `parent` | string | yes | — | The parent database to copy. |
| `group` | string | no | `default` | The group the database is placed in; it must exist. |
| `timestamp` | string | no | — | Copy the parent as it was at this ISO 8601 time (point-in-time recovery window only). |

```yaml theme={null}
# under changes: (no provider block needed)
- id: database_branch
  adapter: http
  op: resource
  recipe: turso.database_branch
  organization: acme
  name: app-${ctx.scope}
  parent: app
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /organizations/{organization}/databases/{name}
create:
  path: /organizations/{organization}/databases
  body:
    name: { param: name }
    group: { param: group }
    seed:
      type: database
      name: { param: parent }
      timestamp: { param: timestamp }
item_path: /database
id_path: /Name
outputs:
  hostname: /Hostname
  db_id: /DbId
```

## Twilio

Recipe file: [`packages/adapters/recipes/twilio.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/twilio.yaml). Verified on 2026-10-12 against:

* [Requests to Twilio's APIs (authentication, form-encoded parameters)](https://www.twilio.com/docs/usage/requests-to-twilio)
* [Applications resource (TwiML Apps)](https://www.twilio.com/docs/usage/api/applications)
* [Access tokens (Voice grant, outgoingApplicationSid)](https://www.twilio.com/docs/iam/access-tokens)
* [API keys](https://www.twilio.com/docs/iam/api-keys)
* [Twilio's OpenAPI document for the 2010-04-01 API (Applications paths and schema)](https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json)

| API default | Value |
| - | - |
| `base_url` | `https://api.twilio.com` |
| `base_url_env` | `TWILIO_API_URL` |
| Credential | `TWILIO_API_KEY` / `TWILIO_API_SECRET` (basic) |
| `encoding` | `form` |

Assumptions:

* **TW1** (verified, [source](https://www.twilio.com/docs/usage/requests-to-twilio)): 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](https://www.twilio.com/docs/usage/api/applications)): 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](https://www.twilio.com/docs/usage/api/applications)): `POST /2010-04-01/Accounts/{AccountSid}/Applications.json` creates an application from `FriendlyName` (up to 64 characters), `VoiceUrl`, `VoiceMethod`, `StatusCallback`, `SmsUrl`, `SmsMethod` and more, answering 201; `GET …/Applications.json` lists them (`applications`, filterable by `FriendlyName`); `GET` and `POST …/Applications/{Sid}.json` read and update one; `DELETE` answers 204 and unassigns it from any phone number using it. An account holds at most 1000 applications.
* **TW4** (verified, [source](https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json)): Parameters are written in PascalCase (`FriendlyName`, `VoiceUrl`) and answers name the same fields in snake\_case (`friendly_name`, `voice_url`, `voice_method`, `sms_url`, `sms_method`, `status_callback`), with the object's id in `sid` (`AP…`); paths end in `.json`.
* **TW5** **(unverified)**: Twilio does not require a FriendlyName to be unique (the documentation does not say); the recipe gives each preview its own, and two applications of the same name make `find` refuse with PARAM\_INVALID. The list filtered by `FriendlyName` fits in its first page.
* **TW6** **(unverified)**: Twilio answers the URLs and methods as they were written, so an unchanged application hashes the same on every plan.
* **TW7** (verified, [source](https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json)): A phone number has one `sms_url` and one `voice_url` (or one `sms_application_sid` / `voice_application_sid`, single strings): pointing a shared number at a preview takes it from every other environment, so a number is not per-preview state and the recipe does not manage numbers. Inbound SMS or calls per preview need a number per preview (bought and, for US SMS, A2P-registered) assigned to the preview's application.

### `twilio.twiml_app`

**TwiML Application** (`http.resource`). A TwiML Application per preview whose voice (and optionally messaging and status callback) URLs are the preview's own, `url` + a path: the Voice SDK tokens the preview issues name it (`outgoingApplicationSid`, TW2), so outgoing calls from that preview reach that preview's handlers. Identified by its FriendlyName (`name`), so a new preview URL updates it in place. A console edit of a URL or method is drift. Phone numbers are not managed (TW7).

Covers: `twilio-twiml-app` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `account_sid` | string | yes | — | The account (or subaccount) the application belongs to (`AC` and 32 hexadecimal characters on Twilio; the example is a placeholder). Matches `^AC[0-9A-Za-z]{32}$`. |
| `name` | string | yes | — | The application's FriendlyName and identity: unique per preview, such as `sponson-${ctx.env}-${ctx.scope}`. Matches `^.{1,64}$`. |
| `url` | string | yes | — | The preview's base URL, without a trailing slash: usually `{ from: <deploy line>.preview_url }`. Matches `^https://\\S*[^/]$`. |
| `voice_path` | string | no | `/api/twilio/voice` | Where the app answers with TwiML for a call, appended to `url` (`VoiceUrl`). Matches `^/\\S*$`. |
| `voice_method` | string | no | `POST` | How Twilio requests the voice URL. One of `GET`, `POST`. |
| `sms_path` | string | no | — | Where the app answers an incoming message, appended to `url` (`SmsUrl`), for a number assigned to this application; unset leaves it empty. Matches `^/\\S*$`. |
| `status_callback_path` | string | no | — | Where Twilio posts call status changes, appended to `url` (`StatusCallback`); unset leaves it empty. Matches `^/\\S*$`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: twiml_app
  adapter: http
  op: resource
  recipe: twilio.twiml_app
  account_sid: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  name: sponson-${ctx.env}-${ctx.scope}
  url: https://pr-42.preview.example.com
```

What the line expands into: the [`http.resource`](/reference/plan-format#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.

```yaml theme={null}
find:
  path: /2010-04-01/Accounts/{account_sid}/Applications.json?FriendlyName={name}
  list_path: /applications
  match:
    friendly_name: { param: name }
read:
  path: /2010-04-01/Accounts/{account_sid}/Applications/{id}.json
create:
  path: /2010-04-01/Accounts/{account_sid}/Applications.json
  send_fields: false
  body:
    FriendlyName: { param: name }
    VoiceUrl:
      join:
        - { param: url }
        - { param: voice_path }
    VoiceMethod: { param: voice_method }
    SmsUrl:
      join:
        - { param: url }
        - { param: sms_path }
    StatusCallback:
      join:
        - { param: url }
        - { param: status_callback_path }
update:
  method: POST
  send_fields: false
  body:
    VoiceUrl:
      join:
        - { param: url }
        - { param: voice_path }
    VoiceMethod: { param: voice_method }
    SmsUrl:
      join:
        - { param: url }
        - { param: sms_path }
    StatusCallback:
      join:
        - { param: url }
        - { param: status_callback_path }
id_path: /sid
fields:
  voice_url:
    join:
      - { param: url }
      - { param: voice_path }
  voice_method: { param: voice_method }
  sms_url:
    join:
      - { param: url }
      - { param: sms_path }
  status_callback:
    join:
      - { param: url }
      - { param: status_callback_path }
```

## Unleash

Recipe file: [`packages/adapters/recipes/unleash.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/unleash.yaml). Verified on 2026-10-11 against:

* [Admin API overview (authorization, base URL)](https://docs.getunleash.io/reference/api/unleash)
* [Add a strategy to a flag in an environment](https://docs.getunleash.io/reference/api/unleash/add-feature-strategy)
* [Get feature flag strategies](https://docs.getunleash.io/reference/api/unleash/get-feature-strategies)
* [Update a strategy (replace)](https://docs.getunleash.io/reference/api/unleash/update-feature-strategy)
* [Delete a strategy](https://docs.getunleash.io/reference/api/unleash/delete-feature-strategy)
* [API tokens and client keys](https://docs.getunleash.io/reference/api-tokens-and-client-keys)

| API default | Value |
| - | - |
| `base_url` | none: set it in the provider block |
| `base_url_env` | `UNLEASH_API_URL` |
| Credential | `UNLEASH_AUTH_TOKEN` (header `Authorization`) |

Assumptions:

* **UN1** (verified, [source](https://docs.getunleash.io/reference/api/unleash)): Every request carries the token as it is in `Authorization` (`Authorization: <token>`, no `Bearer`); a personal access token or service account token (admin tokens are deprecated) reaches the Admin API. The spec also lists a Bearer scheme, but the server does not strip a `Bearer ` prefix, so the bare form is used.
* **UN2** (verified, [source](https://docs.getunleash.io/reference/api/unleash/add-feature-strategy)): `POST /api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategies` with `name`, `title`, `disabled`, `constraints` and `parameters` adds a strategy and answers it bare, with its server-assigned `id` (a UUID).
* **UN3** (verified, [source](https://github.com/Unleash/unleash/blob/main/src/lib/features/feature-toggle/feature-toggle-controller.ts)): `GET` on the same path answers a bare array of the environment's strategies (the source returns `FeatureStrategySchema[]`; the docs page's schema shows a single strategy, which is wrong).
* **UN4** (verified, [source](https://docs.getunleash.io/reference/api/unleash/update-feature-strategy)): `PUT .../strategies/{strategyId}` replaces a strategy's configuration (so it sends `name`, `title` and every compared field); `DELETE` on the same path removes it.
* **UN5** (verified, [source](https://docs.getunleash.io/reference/api/unleash/add-feature-strategy)): A constraint is `{ contextName, operator, values, caseInsensitive, inverted }` (`caseInsensitive` and `inverted` default to false, and are written explicitly so the comparison does not depend on defaults); `parameters` is a map of strings (`rollout: "100"`).
* **UN6** **(unverified)**: `title` is optional and not unique on the server: the strategy is identified by its title in the environment, and two strategies with that title are PARAM\_INVALID rather than a guess.
* **UN7** **(unverified)**: A strategy is read back with `constraints` and `parameters` exactly as written (no extra keys such as `value: ""`); otherwise every plan shows it as changed.
* **UN8** (verified, [source](https://github.com/Unleash/unleash/blob/main/src/lib/features/feature-toggle/feature-toggle-service.ts)): 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](https://github.com/Unleash/unleash/blob/main/src/lib/features/feature-toggle/feature-toggle-service.ts)): 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](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project` | string | no | `default` | The project id. |
| `feature` | string | yes | — | The flag's name; also the rollout's `groupId`. |
| `environment` | string | yes | — | The environment the strategy is added in. |
| `title` | string | yes | — | The strategy's title, unique among the environment's strategies; it identifies the strategy. |
| `context_name` | string | yes | — | The context field the constraint tests (a custom context field such as `preview`, or `environment`, `userId`, …). |
| `operator` | string | no | `IN` | The constraint's operator. One of `IN`, `NOT_IN`, `STR_ENDS_WITH`, `STR_STARTS_WITH`, `STR_CONTAINS`. |
| `values` | list | yes | — | The values the context field is compared with. |
| `rollout` | string | no | `100` | The share of matching users the strategy enables, as a string (0–100). Matches `^(100\|[1-9]?[0-9])$`. |
| `stickiness` | string | no | `default` | The rollout's stickiness (`default`, `userId`, `sessionId`, `random`, or a context field). |
| `disabled` | boolean | no | `false` | Keep the strategy but switch it off. |

```yaml theme={null}
providers:
  http:
    unleash:
      recipe: unleash
      base_url: https://unleash.example.com
changes:
  - id: flag_strategy
    adapter: http
    op: resource
    recipe: unleash.flag_strategy
    feature: new-checkout
    environment: development
    title: preview ${ctx.scope}
    context_name: preview
    values:
      - ${ctx.scope}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /api/admin/projects/{project}/features/{feature}/environments/{environment}/strategies
  match:
    title: { param: title }
create:
  path: /api/admin/projects/{project}/features/{feature}/environments/{environment}/strategies
  body:
    name: flexibleRollout
update:
  method: PUT
  path: /api/admin/projects/{project}/features/{feature}/environments/{environment}/strategies/{id}
  body:
    name: flexibleRollout
delete:
  path: /api/admin/projects/{project}/features/{feature}/environments/{environment}/strategies/{id}
fields:
  disabled: { param: disabled }
  constraints:
    - contextName: { param: context_name }
      operator: { param: operator }
      values: { param: values }
      caseInsensitive: false
      inverted: false
  parameters:
    rollout: { param: rollout }
    stickiness: { param: stickiness }
    groupId: { param: feature }
```

## Upstash Redis

Recipe file: [`packages/adapters/recipes/upstash.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/upstash.yaml). Verified on 2026-10-11 against:

* [Developer API authentication](https://upstash.com/docs/devops/developer-api/authentication)
* [Developer API introduction (supported accounts)](https://upstash.com/docs/devops/developer-api/introduction)
* [Create a Redis database (global)](https://upstash.com/docs/devops/developer-api/redis/create_database_global)
* [List Redis databases](https://upstash.com/docs/devops/developer-api/redis/list_databases)
* [Get a Redis database](https://upstash.com/docs/devops/developer-api/redis/get_database)
* [Delete a Redis database](https://upstash.com/docs/devops/developer-api/redis/delete_database)
* [Upstash CLI (UPSTASH\_EMAIL, UPSTASH\_API\_KEY)](https://upstash.com/docs/agent-resources/cli)
* [Terraform upstash\_redis\_database (exported credentials)](https://upstash.com/docs/devops/terraform/resources/upstash_redis_database)

| API default | Value |
| - | - |
| `base_url` | `https://api.upstash.com/v2` |
| `base_url_env` | `UPSTASH_API_URL` |
| Credential | `UPSTASH_EMAIL` / `UPSTASH_API_KEY` (basic) |

Assumptions:

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

### `upstash.redis_database`

**Redis database** (`http.resource`). A global Redis database per scope, such as `app-pr-42`, with its endpoint, port, password and REST token as outputs (the last two sensitive). Located by name; only its existence is compared for drift (region, plan and TLS are set on create and not compared); destroy deletes it and its data.

Covers: `upstash-redis-database` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `endpoint`, `port`, `password` (sensitive), `rest_token` (sensitive). On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `name` | string | yes | — | The database's name. |
| `platform` | string | no | `aws` | The cloud it runs on. One of `aws`, `gcp`. |
| `primary_region` | string | yes | — | The primary region, such as `us-east-1` or `eu-west-1`. |
| `read_regions` | list | no | — | Read replica regions. |
| `plan` | string | no | — | The plan (`free`, `payg`, `fixed_…`); the account's default when left out. |
| `tls` | boolean | no | `true` | Whether TLS is enabled. |
| `eviction` | boolean | no | — | Whether keys are evicted when the database is full. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: redis_database
  adapter: http
  op: resource
  recipe: upstash.redis_database
  name: app-${ctx.scope}
  primary_region: us-east-1
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /redis/databases
  match:
    database_name: { param: name }
read:
  path: /redis/database/{id}
create:
  path: /redis/database
  body:
    platform: { param: platform }
    primary_region: { param: primary_region }
    read_regions: { param: read_regions }
    plan: { param: plan }
    tls: { param: tls }
    eviction: { param: eviction }
id_path: /database_id
outputs:
  endpoint: /endpoint
  port: /port
  password:
    path: /password
    sensitive: true
  rest_token:
    path: /rest_token
    sensitive: true
```

## Vercel

Recipe file: [`packages/adapters/recipes/vercel.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/vercel.yaml). Verified on 2026-10-11 against:

* [Add a domain to a project](https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project)
* [Get a project domain](https://vercel.com/docs/rest-api/projects/get-a-project-domain)
* [Update a project domain](https://vercel.com/docs/rest-api/projects/update-a-project-domain)
* [Remove a domain from a project](https://vercel.com/docs/rest-api/projects/remove-a-domain-from-a-project)
* [Errors](https://vercel.com/docs/rest-api/errors)
* [CLI global options (VERCEL\_TOKEN)](https://vercel.com/docs/cli/global-options)
* [Working with domains (verification)](https://vercel.com/docs/domains/working-with-domains)

| API default | Value |
| - | - |
| `base_url` | `https://api.vercel.com` |
| `base_url_env` | `VERCEL_API_URL` |
| Credential | `VERCEL_TOKEN` (bearer) |

Assumptions:

* **VD1** (verified, [source](https://vercel.com/docs/cli/global-options)): Requests authenticate as `Authorization: Bearer <token>`; the Vercel CLI reads the token from `VERCEL_TOKEN` (the built-in `vercel` adapter uses the same variable).
* **VD2** (verified, [source](https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project)): `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](https://vercel.com/docs/rest-api/projects/get-a-project-domain)): `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](https://vercel.com/docs/rest-api/projects/update-a-project-domain)): `PATCH /v9/projects/{idOrName}/domains/{domain}` changes `gitBranch`, `redirect` and `redirectStatusCode`; `DELETE` on the same path removes the domain from the project (200, `{}`).
* **VD6** (verified, [source](https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project)): 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](https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project)): `gitBranch` and `redirect` cannot be set together, and the production branch cannot be a `gitBranch` (400).
* **VD8** (verified, [source](https://vercel.com/docs/domains/working-with-domains)): A domain the account does not own answers `verified: false` with a `verification` challenge (a TXT record) that must be completed before it serves traffic; a domain bought through Vercel needs none.
* **VD9** **(unverified)**: A free `<name>.vercel.app` domain that no other project uses is verified on the spot (`verified: true`).
* **VD10** **(unverified)**: Every account has a team id (personal accounts are Hobby teams), so `teamId` can always be sent; the recipe requires it.

### `vercel.project_domain`

**Branch-bound project domain** (`http.resource`). A domain on a Vercel project that serves one git branch's latest preview, such as `pr-42.preview.example.com` for `feat/x`: a stable preview hostname. Located by name; a console edit of its branch or redirect is drift; destroy removes it from the project. Its `verified` output says whether DNS verification is still needed (VD8).

Covers: `vercel-domain` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`, `verified`, `apex_name`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `project` | string | yes | — | The project's id or name. |
| `team_id` | string | yes | — | The team's id (`team_…`, under Settings → General). Matches `^team_[A-Za-z0-9]+$`. |
| `name` | string | yes | — | The domain, lower-case. Matches `^[a-z0-9*.-]+$`. |
| `git_branch` | string | no | — | The git branch whose latest deployment the domain serves. |
| `redirect` | string | no | — | Redirect to this domain instead of serving a branch (not with `git_branch`). |
| `redirect_status_code` | integer | no | — | The redirect's status code. One of `301`, `302`, `307`, `308`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: project_domain
  adapter: http
  op: resource
  recipe: vercel.project_domain
  project: prj_12HKQaOmR5t5Uy6vdcQsNIiZgHGB
  team_id: team_1a2b3c4d5e6f7g8h9i0j1k2l
  name: ${ctx.scope}.preview.example.com
  git_branch: ${ctx.git.branch}
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
read:
  path: /v9/projects/{project}/domains/{name}?teamId={team_id}
create:
  path: /v10/projects/{project}/domains?teamId={team_id}
  body:
    name: { param: name }
update:
  method: PATCH
id_path: /name
fields:
  gitBranch: { param: git_branch }
  redirect: { param: redirect }
  redirectStatusCode: { param: redirect_status_code }
outputs:
  verified: /verified
  apex_name: /apexName
```

## WorkOS

Recipe file: [`packages/adapters/recipes/workos.yaml`](https://github.com/MishaBear94/Sponson/blob/main/packages/adapters/recipes/workos.yaml). Verified on 2026-10-11 against:

* [AuthKit API reference, Redirect URI](https://workos.com/docs/reference/authkit/redirect-uri)
* [AuthKit, redirect URIs in the Dashboard](https://workos.com/docs/user-management/integrating-with-authkit)

| API default | Value |
| - | - |
| `base_url` | `https://api.workos.com` |
| `base_url_env` | `WORKOS_API_URL` |
| Credential | `WORKOS_API_KEY` (bearer) |

Assumptions:

* **WO1** (verified, [source](https://workos.com/docs/reference/authkit/redirect-uri)): `POST /user_management/redirect_uris` with `{ uri }` creates a redirect URI for the environment the API key (`Authorization: Bearer sk_…`) belongs to, and answers the `redirect_uri` object (`id`, `uri`, `default`).
* **WO2** (verified, [source](https://workos.com/docs/reference/authkit/redirect-uri)): `GET /user_management/redirect_uris` (`limit` 1–100, cursors `before`/`after`) answers `{ data: [...], list_metadata: { before, after } }`.
* **WO3** (verified, [source](https://workos.com/docs/reference/authkit/redirect-uri)): `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](https://workos.com/docs/user-management/integrating-with-authkit)): Redirect URIs may contain wildcards, except the default one.
* **WO5** **(unverified)**: Each URI is its own object (no shared list to rewrite), so concurrent previews need no lock; a URI is returned exactly as it was created, so `uri` identifies it.
* **WO6** **(unverified)**: Creating a URI that already exists is refused as a conflict (409) rather than duplicated; the line then finds the existing one.
* **WO7** **(unverified)**: The first redirect URI of an environment becomes its default; a preview's URI is never the first in an environment that has a production URI.

### `workos.redirect_uri`

**AuthKit redirect URI** (`http.resource`). One redirect URI on the WorkOS environment the API key belongs to, such as the preview's `/callback`: usually `{ from: <vercel.env line>.preview_url }` joined in the app. Found by its URI in the list (following `list_metadata.after`); removed by id on destroy. A URI has nothing else to edit, so the only drift is a URI deleted in the Dashboard, which the next apply creates again.

Covers: `workos-redirect` in [the coverage matrix](/reference/coverage#coverage-matrix). Outputs: `id`. On destroy: deleted (`destroy: keep` on the line leaves it).

| Param | Type | Required | Default | Meaning |
| - | - | - | - | - |
| `uri` | string | yes | — | The redirect URI, exactly as AuthKit will receive it (`https://pr-42.preview.example.com/callback`). Matches `^https?://\\S+$`. |

```yaml theme={null}
# under changes: (no provider block needed)
- id: redirect_uri
  adapter: http
  op: resource
  recipe: workos.redirect_uri
  uri: https://${ctx.scope}.preview.example.com/callback
```

What the line expands into: the [`http.resource`](/reference/plan-format#http-resource) params, `{ param: <name> }` standing for a param's value and `{<name>}` in a path for its URI-encoded value.

```yaml theme={null}
find:
  path: /user_management/redirect_uris?limit=100
  list_path: /data
  match:
    uri: { param: uri }
  next: /list_metadata/after
  cursor_param: after
create:
  path: /user_management/redirect_uris
delete:
  path: /user_management/redirect_uris/{id}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.