Variables

Apply a set of variable changes atomically

The transactional way to sync an environment.

POST/projects/{slug}/environments/{environmentSlug}/variables/apply

The transactional way to sync an environment. A sync client should use this instead of N separate create, update and delete calls. Everything runs inside one database transaction behind a per-environment advisory lock, so the environment is never left half-written and two concurrent applies cannot interleave.

baseline is required and is how drift is detected. Send the full set of variables you last read, as { uniqueId, name, updatedAt } for every live variable in the environment, using the id and updatedAt you received from the list endpoint. Before touching anything, the server compares the baseline against the live rows and rejects the whole request with 409 variable.baseline_drifted if any live variable is missing from your baseline, any baseline entry no longer exists, or any updatedAt differs by even a millisecond. The drifted names are listed in message, sorted, and nowhere else. On a 409, re-read the environment and rebuild the change set. A partial baseline is not a partial check, it is guaranteed drift, because every live variable you omitted counts as drifted.

value in creates and updates is ciphertext encrypted client-side with the workspace key. Gotcha: this endpoint does not run the v1:/v2: prefix check that POST .../variables runs, it only enforces a length of 1 to 20000. Plaintext sent here is stored as plaintext and then propagates into every later version snapshot. Validate the prefix in your client.

Apply is value-only and name-only. creates insert name and value and take the column defaults for type, isSecret, visibility, required and defaultValue. updates set value alone. There is no rename and no defaultValue path here, use PATCH .../variables/{name} or PATCH /variables/{id} for those. Names are compared without regard to case throughout apply, so a creates entry for api_key conflicts with a live API_KEY unless the same request deletes it.

Caps: at most 1000 baseline entries, and at most 1000 items across creates plus updates plus deletes combined. At least one of the three must be non-empty. The request body may be at most 1 MiB (1,048,576 bytes), checked before anything else; a larger one is refused with 413 request.body_too_large, so a sync of many large values must be split across requests.

Requires the variables:write scope.

curl --request POST \
  --url 'https://api.envless.cloud/projects/<slug>/environments/<environmentSlug>/variables/apply' \
  --header 'Authorization: Bearer ev_sk_xxxxxxxxxxxx' \
  --header 'Content-Type: application/json' \
  --data '{
  "baseline": [
    {
      "uniqueId": "<string>",
      "name": "STRIPE_SECRET_KEY",
      "updatedAt": "2026-05-30T10:14:22.000Z"
    }
  ],
  "creates": [
    {
      "name": "SENTRY_DSN",
      "value": "v1:8Wm3kQ...ciphertext"
    }
  ],
  "updates": [
    {
      "uniqueId": "<string>",
      "value": "v1:8Wm3kQ...ciphertext",
      "name": "SENTRY_DSN"
    }
  ],
  "deletes": [
    {
      "uniqueId": "<string>"
    }
  ]
}'
Try itPOSThttps://api.envless.cloud/projects/{slug}/environments/{environmentSlug}/variables/apply
Path parameters
Query parameters

Authorization

  • Authorizationstringrequiredheader

    Bearer token: an Envless API key (ev_sk_…), created in the dashboard or with POST /keys. The key is bound to one workspace; every request runs inside it.

Path parameters

  • slugstringrequired

    Project slug or project uniqueId.

  • environmentSlugstringrequired

    Environment slug.

Query parameters

  • productstring

    Product slug, to disambiguate a project slug shared across products.

Request body

application/json · required

  • baselineobject[]requiredmax items 1000

    Every live variable as you last read it. May be empty only when the environment is genuinely empty. Omitting a live variable is drift.

    Show child attributesHide child attributes
    • uniqueIdstringrequired

      The variable id from a read.

    • namestringrequired

      Used only to name the variable in the drift message. Send it back exactly as you read it, no pattern is enforced here so a legacy name still round-trips.

    • updatedAtstringrequireddate-time

      Compared for exact millisecond equality against the live row.

  • createsobject[]

    New variables. Name must match ^[A-Za-z_][A-Za-z0-9_]*$ and be 1 to 100 characters, is stored with the casing you send, and must not repeat within the request. Repeats inside the request and collisions with live variables are judged without regard to case, so api_key collides with an existing API_KEY you are not deleting in the same request.

    Show child attributesHide child attributes
    • namestringrequiredmin length 1max length 100pattern ^[A-Za-z_][A-Za-z0-9_]*$

      Variable name. Stored as typed, compared without regard to case.

    • valuestringrequiredmin length 1max length 20000pattern ^v[12]:

      CIPHERTEXT, starting with v1: or v2:. A value without the prefix fails the request with 400 variable.ciphertext_invalid.

  • updatesobject[]

    Value replacements, optionally with a rename. value is always written; send name as well to rename the variable in the same transaction.

    Show child attributesHide child attributes
    • uniqueIdstringrequired
    • valuestringrequiredmin length 1max length 20000pattern ^v[12]:

      CIPHERTEXT, starting with v1: or v2:. A value without the prefix fails the request with 400 variable.ciphertext_invalid.

    • namestringmin length 1max length 100pattern ^[A-Za-z_][A-Za-z0-9_]*$

      Optional new name, which renames the variable. Validated like a creates name, and unique within the request without regard to case.

  • deletesobject[]

    Soft deletes by id. A name freed by a delete in the same request can be reused by a create in that request, including under a different casing.

    Show child attributesHide child attributes
    • uniqueIdstringrequired

Responses

200OK

Apply a set of variable changes atomically succeeded.

  • successbooleanrequired
  • codeintegerrequired

    The HTTP status of this response, repeated in the body.

  • messagestringrequired
  • errornullrequired
  • dataobjectrequired
    Show child attributesHide child attributes
    • addedintegerrequired
    • updatedintegerrequired
    • removedintegerrequired
  • _reqobject
    Show child attributesHide child attributes
    • reqIdstring
    • resTimeinteger | null
{
    "success": true,
    "code": 200,
    "message": "OK",
    "error": null,
    "data": {
        "added": 1,
        "updated": 1,
        "removed": 1
    },
    "_req": {
        "reqId": "<string>",
        "resTime": 1
    }
}

Error envelope

Every failure below returns the same shape with success: false; branch on error.code, never on message.

  • successbooleanrequired
  • codeintegerrequired

    The HTTP status of this response, repeated in the body.

  • messagestringrequired
  • errorobjectrequired

    Machine-readable failure detail. Null on success.

    Show child attributesHide child attributes
    • codeenum<string>required

      Stable error code. Branch on this, never on message.

      Options: "auth.api_key_required", "auth.api_key_not_found", "auth.api_key_user_missing", "auth.api_key_owner_not_member", "auth.scope_missing", "auth.scope_ceiling_exceeded", "auth.key_project_restricted", "auth.product_restricted", "auth.permission_denied", "auth.self_mutation_forbidden", "auth.step_up_required", "auth.step_up_fresh_required", "auth.cli_token_not_accepted", "auth.two_factor_required", "auth.workspace_two_factor_required", "auth.two_factor_invalid", "auth.two_factor_replayed", "auth.two_factor_locked", "auth.two_factor_not_enabled", "auth.two_factor_already_enabled", "request.body_must_be_json", "request.body_invalid", "request.body_too_large", "request.rate_limited", "request.invalid_pagination", "workspace.not_found", "workspace.slug_conflict", "workspace.limit_reached", "workspace.delete_last", "workspace.delete_too_early", "workspace.delete_has_projects", "domain.not_configured", "domain.not_found", "domain.invalid", "domain.taken", "domain.attempt_limit", "product.not_found", "product.slug_conflict", "product.locked", "project.not_found", "project.ambiguous_product", "project.slug_conflict", "environment.not_found", "environment.slug_conflict", "environment.expose_disabled", "environment.expose_private_product", "variable.not_found", "variable.name_conflict", "variable.ciphertext_invalid", "variable.baseline_drifted", "version.not_found", "member.not_found", "member.already_exists", "member.self_invite", "member.self_mutation", "member.owner_immutable", "member.owner_cannot_leave", "member.seat_limit", "invite.already_pending", "role.owner_not_assignable", "role.limit_reached", "role.immutable", "role.in_use", "invite.not_found", "invite.expired", "role.not_found", "role.name_conflict", "key.not_found", "key.limit_reached", "webhook.not_found", "webhook.delivery_not_found", "plan.limit_reached", "plan.upgrade_required", "request.not_found", "request.method_not_allowed", "request.conflict", "auth.unauthorized", "auth.forbidden", "request.invalid", "server.error"

    • resourcestring

      The resource or scope the failure relates to.

    • fieldstring

      The request field that caused the failure.

  • dataobject | nullrequired

    Null on most failures. A plan limit carries { limit, current }.

  • _reqobject
    Show child attributesHide child attributes
    • reqIdstring
    • resTimeinteger | null
{
    "success": false,
    "code": 400,
    "message": "Something went wrong.",
    "error": {
        "code": "auth.api_key_required",
        "resource": "<string>",
        "field": "<string>"
    },
    "data": {},
    "_req": {
        "reqId": "<string>",
        "resTime": 1
    }
}

400Bad Request

  • request.body_invalid - baseline is missing or not an array (error.field: "baseline"), all three change arrays are empty, the 1000 item cap is exceeded, an item has the wrong shape, or the body is not JSON (request.body_must_be_json). Also 400 request.body_invalid for a rejected name, a value outside 1 to 20000 characters, and, counterintuitively, for exceeding the plan's variable cap, whose message reads like a plan error but arrives as 400 rather than 402.
  • variable.ciphertext_invalid - a creates or updates value does not start with v1: or v2:; nothing is written and error.field names the field.

401Unauthorized

  • auth.api_key_required - the Authorization header is missing, or its value does not start with ev_sk_.
  • auth.api_key_not_found - the key is unknown, inactive or revoked.
  • auth.api_key_user_missing - the key is valid but its owner no longer exists.
  • auth.api_key_owner_not_member - the key's owner is no longer a member of the key's workspace.

402Payment Required

plan.upgrade_required - the project is locked because the workspace exceeds its plan's project limit.

403Forbidden

  • auth.key_project_restricted - the key's project allowlist excludes this project, or auth.product_restricted for an inaccessible private product.
  • auth.workspace_two_factor_required - the workspace requires two-factor authentication and the key's owner has not turned it on.
  • auth.scope_missing - the key lacks a scope this operation requires; error.resource names it.

404Not Found

project.not_found - unknown project, or environment.not_found for an unknown environment slug.

409Conflict

variable.baseline_drifted - the environment changed under you. The drifted names are only in message. The same code is reused for two neighbouring conflicts, a creates name already taken by a variable you are not deleting, and an updates or deletes id that is no longer live, so read message to tell them apart. 409 project.ambiguous_product also lands on this status when the project slug is not unique.

413Payload Too Large

request.body_too_large - the request body is larger than 1 MiB (1,048,576 bytes).

500Internal Server Error

server.error - an unexpected error; retry with backoff.

503Service Unavailable

server.error - the API is in maintenance mode or is missing its configuration; retry with backoff.