Running the CLI from a pipeline. The pieces you need are environment-variable authentication, --non-interactive, --json, and exit codes.
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.
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 .envBoth pipeline examples below include this step.
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-interactiveThere 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:
| Flag | Effect |
|---|---|
--public | Deploy to the public namespace (internet-accessible) |
--private | Deploy to the private namespace (internal access only) |
--force | Deploy despite validation warnings instead of prompting |
--managed-keys | Use a managed access key for this function |
--no-docs | Skip doc generation |
--strict-audit | Fail 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.
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 --publicdeploy:
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 --publicSet 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.
--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 --jsonSet 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 $?.
The CLI never checks for or installs updates in CI. The check is skipped when any of these is true:
CIis set, or the process is running in Docker- stdout is not a TTY (so anything piped or redirected)
--jsonor--non-interactiveis 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.1Read the changelog before you bump the pin.
The CLI follows BSD sysexits conventions plus a Sinch-specific range, so a pipeline can tell a bad credential apart from a bad request.
| Code | Meaning | Usual cause in CI |
|---|---|---|
| 0 | Success | |
| 1 | Generic failure | Also an unknown command, an unknown option or a missing argument |
| 64 | Usage error | A 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 |
| 65 | Data error | Malformed credentials file |
| 66 | No input | A 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 |
| 69 | Unavailable | API refused the connection, or answered HTTP 429 or 5xx |
| 70 | Internal error | |
| 73 | Cannot create | Not returned by any command today |
| 74 | I/O error | No response from the API, such as a DNS failure, a reset connection or a request past the 60-second timeout |
| 77 | No permission | Key lacks access to the project |
| 78 | Config error | Missing or contradictory configuration |
| 100 | Auth required | The three environment variables are not set |
| 101 | Auth failed | Wrong or revoked key secret |
| 102 | Not found | No such function or template |
| 103 | Conflict | Name already taken |
| 104 | Timeout | Deployment did not become ready in time, or the network connection to the API timed out |
| 130 | Interrupted | sinch 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 ;;
esacFor 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.
SINCH_DEBUG=2 sinch functions deploy --non-interactiveLevel 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.
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.
- Configuration — profiles, config file, global flags
- functions command reference



