Skip to content

CI/CD

Running the CLI from a pipeline. The pieces you need are environment-variable authentication, --non-interactive, --json, and exit codes.

Authentication

sinch auth login prompts for a project ID, key ID and key secret, then writes the secret to the OS keychain. Neither works on a runner with no stdin and no keychain. Instead, set SINCH_PROJECT_ID, SINCH_KEY_ID and SINCH_KEY_SECRET, described in Configuration → Environment variable override, and the CLI skips both the keychain and the login step entirely. All three values are in the Sinch Dashboard, under your project's Access Keys section.

Of the three, only SINCH_KEY_SECRET is sensitive: mask it. The other two are identifiers. Storing them as secrets too, as the GitHub Actions example below does, is fine.

Before the first deploy

Deploy builds a Node.js function's configuration from its .env file. sinch functions init adds .env to .gitignore, so a fresh checkout on a runner has none, and deploy fails validation with exit 78 (MISSING_ENV_FILE). Committing .env is not the fix: deploy warns when .env is tracked in git (ENV_IN_GIT).

Instead, commit a .env.example next to sinch.json that lists the same keys as your local .env, with secret values left blank (see Secrets a function needs at runtime). Then create .env from it in the job, before deploying:

cp .env.example .env

Both pipeline examples below include this step.

Deploying a function

Every command works from the function's own directory. deploy reads sinch.json out of the working directory and takes the name from it, so the job only has to be in the right folder:

cd my-function
sinch functions deploy --non-interactive

There is a --name flag, but pass it only when you deliberately want to deploy under a different name than sinch.json says. In a pipeline it is a liability: rename the function in the repo and the job keeps deploying to the old name without complaining.

--non-interactive answers prompts with their defaults rather than blocking on stdin, which a runner does not have. For deploy, a set CI environment variable has the same effect, so on GitHub Actions and GitLab CI, which both set CI, deploy runs non-interactively even without the flag.

One deploy prompt does not take its default. Deploy asks "Use a managed key for this function?", which defaults to yes, but a non-interactive run picks profile instead of managed. To use a managed key, set accessKey to "managed" in sinch.json or pass --managed-keys. The functions command reference covers how deploy picks the access key.

Without --non-interactive, commands still do not hang. When there is no terminal, a yes/no question declines, a question with a default takes it, and a question with no answerable default fails naming itself. Declining is deliberate: it means an unattended run never approves a billable or irreversible action that a person was supposed to approve. Pass the flag that authorises the action instead.

The one place that matters for deploy is validation warnings. A non-interactive deploy, which includes any deploy where CI is set, goes ahead despite them. With CI unset, no terminal and neither --force nor --non-interactive, deploy stops with exit 64 and tells you which flag settles it, rather than exiting successfully having shipped nothing.

Deploy waits for the deployment to finish and exits non-zero if it fails. Add --no-wait to return as soon as the upload is accepted — useful when a later job polls, but it means a green step no longer means a running function.

Other flags that matter in a pipeline:

FlagEffect
--publicDeploy to the public namespace (internet-accessible)
--privateDeploy to the private namespace (internal access only)
--forceDeploy despite validation warnings instead of prompting
--managed-keysUse a managed access key for this function
--no-docsSkip doc generation
--strict-auditFail the deploy on dependency audit findings at --audit-level
--audit-level <level>Severity the audit reports on (default high)

Pass --public or --private explicitly. Without either, deploy reuses the accessLevel that an earlier deploy saved in sinch.json, and a non-interactive deploy with nothing saved picks public. Relying on that means a change to sinch.json can change the namespace without the pipeline saying so. The examples below pass --public so the function can be called from the internet. Pass --private instead for internal access only.

GitHub Actions

name: Deploy function

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '24'

      - run: npm install -g @sinch/cli@0.8.1

      - name: Deploy
        env:
          SINCH_PROJECT_ID: ${{ secrets.SINCH_PROJECT_ID }}
          SINCH_KEY_ID: ${{ secrets.SINCH_KEY_ID }}
          SINCH_KEY_SECRET: ${{ secrets.SINCH_KEY_SECRET }}
          # Function secrets: one line per key left blank in .env.example
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
        working-directory: my-function
        run: |
          cp .env.example .env
          sinch functions deploy --non-interactive --public

GitLab CI

deploy:
  image: node:24
  only:
    - main
  before_script:
    - npm install -g @sinch/cli@0.8.1
  script:
    - cd my-function
    - cp .env.example .env
    - sinch functions deploy --non-interactive --public

Set SINCH_PROJECT_ID, SINCH_KEY_ID and SINCH_KEY_SECRET under Settings → CI/CD → Variables, with SINCH_KEY_SECRET marked Masked. Add each function secret there too, masked, with the same name as its blank key in .env.example.

Reading output in a script

--json prints the result to stdout with no spinner or table, so it can be piped straight into jq:

sinch functions list --json | jq -r '.functions[] | select(.status != "Running") | .name'
sinch functions status --json | jq -r '.deployment.status'
sinch templates list --runtime node --json | jq -r '.templates[].name'

functions list returns the first 50 functions. Pass --limit (up to 200) and --page to read more.

functions deploy --json prints one object and nothing else, so a pipeline can pick the deployed URL straight out of it:

URL=$(sinch functions deploy --non-interactive --public --json | jq -r '.url')
curl --fail "$URL"
{
  "name": "my-function",
  "functionId": "01M10PWQBM37KJSGJW0CZTP3CF",
  "runtime": "node",
  "status": "Running",
  "accessLevel": "public",
  "url": "https://supreme-hertz.fn.sinch.com"
}

warnings carries anything pre-flight validation flagged, a .env tracked in git for instance, and warnings from the deploy itself. Validation warnings also go to stderr, so they are visible in the job log even though stdout stays a single document. The key is absent when there are none.

Deploy prints this document only after the function has shipped, so a check on warnings here cannot stop a deploy. To fail closed on warnings for a Node.js function, run validate first. It deploys nothing and prints valid, errors and warnings:

set -o pipefail
sinch functions validate --json | jq -e '.valid and (.warnings | length == 0)'
sinch functions deploy --non-interactive --public --json

Set pipefail in any step that pipes the CLI into jq. Without it, the step takes jq's exit code and can pass after the CLI failed.

status is the settled state. --json waits for the function to reach Running or Failed before printing, so a URL in the payload is a URL you can call. The wait has two parts. Deploy first follows the deployment for up to 5 minutes, and exits 104 if it does not finish. Then it checks the status for up to 3 more minutes. If neither state is reached by then, deploy prints the last status it read and still exits 0, so check that status is Running. Under --no-wait there is no URL yet and the field is omitted rather than reported as null.

On failure, deploy --json prints {"error": "...", "exitCode": 78} to stdout and exits with that code. Missing or rejected credentials (100 and 101) are the exception: the object is {"error": "..."} with no exitCode, so read the code from $?.

Update checks in CI

The CLI never checks for or installs updates in CI. The check is skipped when any of these is true:

  • CI is set, or the process is running in Docker
  • stdout is not a TTY (so anything piped or redirected)
  • --json or --non-interactive is present

If you run somewhere that does not set CI, set SINCH_NO_AUTO_UPDATE so the CLI never installs an update on its own:

env:
  SINCH_NO_AUTO_UPDATE: '1'

It stops the install only. Unless one of the conditions above holds, the check and the "Update available" notice still run.

Pin an exact version in CI regardless, so a pipeline cannot change behaviour because a new release landed:

npm install -g @sinch/cli@0.8.1

Read the changelog before you bump the pin.

Exit codes

The CLI follows BSD sysexits conventions plus a Sinch-specific range, so a pipeline can tell a bad credential apart from a bad request.

CodeMeaningUsual cause in CI
0Success
1Generic failureAlso an unknown command, an unknown option or a missing argument
64Usage errorA flag with an empty value, init --area-code without --rent-number, a voice v1 command with no application key, or deploy warnings with no terminal and CI unset
65Data errorMalformed credentials file
66No inputA named file does not exist, a sip or conversation delete, remove or stop needs confirmation and there is no terminal, or --json needs a flag in place of a prompt
69UnavailableAPI refused the connection, or answered HTTP 429 or 5xx
70Internal error
73Cannot createNot returned by any command today
74I/O errorNo response from the API, such as a DNS failure, a reset connection or a request past the 60-second timeout
77No permissionKey lacks access to the project
78Config errorMissing or contradictory configuration
100Auth requiredThe three environment variables are not set
101Auth failedWrong or revoked key secret
102Not foundNo such function or template
103ConflictName already taken
104TimeoutDeployment did not become ready in time, or the network connection to the API timed out
130Interruptedsinch functions init stopped with Ctrl+C

100 and 101 are the two worth branching on: both mean the pipeline's credentials are wrong, and no retry will help. Missing credentials do not always reach 100: with no credentials set, a credential key left blank in .env, such as PROJECT_ID_API_SECRET, fails validation first, so deploy exits 78.

Capture the code with || code=$?. By default GitHub Actions runs run steps with bash -e, and GitLab stops a job at its first failing line, so a plain case $? after a failed deploy is never reached:

code=0
sinch functions deploy --non-interactive || code=$?
case $code in
  0)   echo "deployed" ;;
  100|101) echo "check SINCH_KEY_ID / SINCH_KEY_SECRET"; exit 1 ;;
  104) echo "deploy timed out; check sinch functions status"; exit 1 ;;
  *)   echo "deploy failed"; exit 1 ;;
esac

Secrets a function needs at runtime

For a Node.js function, list each secret in .env.example (and your local .env) with an empty value, for example STRIPE_SECRET_KEY=. Then add a masked CI variable with the same name and pass it to the deploy step. At deploy, the CLI fills each empty key from the job's environment and sends it to the platform as a secret. Keys that have a value ship as plain configuration, except a name that looks like a credential: its last word is KEY, KEYS, APIKEY, SECRET, SECRETS, TOKEN, PASSWORD, PASSWD, PASS, PWD, PASSPHRASE, CREDENTIAL or CREDENTIALS, and no word in it is PUBLIC or PUBLISHABLE (OPENAI_API_KEY, but not MAX_TOKENS or PUBLIC_KEY). Those ship as secrets. PROJECT_ID_API_KEY and VOICE_APPLICATION_KEY are IDs, not secrets, so they stay plain. A key stays a secret once it has shipped as one, so rename a key to keep its value plain. Keep secret values out of .env.example either way.

sinch.json does not carry secrets to the deployed function. Deploy does not read its variables: sinch functions dev reads them, and sinch env init copies them into a new .env.

Do not add a sinch secrets add step to CI. It writes to the OS keychain of the machine that runs it, which on Linux needs a Secret Service daemon (secret-tool). Use a CI variable instead.

Debugging a failing job

SINCH_DEBUG=2 sinch functions deploy --non-interactive

Level 2 logs HTTP method, URL and status. Level 3 adds the request headers, redacted. Response bodies are not logged, except the body of a failed OAuth exchange, which is logged in full. See Configuration → Debug environment variables for every level.

Log output

Progress reporting goes to stderr and animates only when stderr is a terminal. With no terminal, as on any CI runner or with a local 2> file, each step prints as plain lines instead, so the log does not fill with spinner frames and cursor escapes. Nothing to configure.

Under --json there is no progress output at all: stdout carries the one document. Validation warnings still go to stderr, as described in Reading output in a script.

See also