# Create a connection

**POST** `/api/connections`

Saves the sign-in details for one app, so workflow steps can use them.

Needs [an API token](https://docs.gorunner.io/api/authentication#scopes) with the **Write** permission for connections (`connection:write`).

## Parameters

### Headers

- `Idempotency-Key` · Optional · string: Up to 255 characters. The same request with the same key within 24 hours answers the first result again, instead of saving twice.

### Body

- `pieceId` · Required · string: The app, such as `postgres`. It must be an app that uses a connection.
- `name` · Required · string: Up to 255 characters, trimmed. Not blank.
- `config` · Optional · object: The details, by the field names the app lists in its `auth` ([Get an app](https://docs.gorunner.io/api/apps/get-app)). A field marked `required` must have a value. Anything else is stored as sent, so a mistake there shows when it is tested or used.
- `test` · Optional · boolean: `true` tests the details first and saves only if the service accepts them.

**What are the rarely used parameters?**

- `authType` · Optional · string: A label for the kind of sign-in, up to 50 characters. Left out: `api_key`. It changes nothing.
- `oauthApp` · Optional · string: Only with sign-in tokens in `config` that came from the installation’s app: `installation`.

## Use it in a workflow

A step or trigger names it by ID: `"connectionId": "…"` next to its `pieceId`. Never put the secret in a step’s `config`. A connection serves one app only; a trigger app’s connection is made under its action app (`gmailtrigger` under `gmail`).

The details are encrypted when saved and can’t be read back. Keep them on your side if you need them again. See [Create a connection from code](https://docs.gorunner.io/api/create-a-connection).

## Apps you sign in to

For an app that signs in, such as Gmail, [Start a sign-in for a new connection](https://docs.gorunner.io/api/connections/start-connection-oauth2-draft) instead: the connection is made only when the sign-in works.

Tokens you already got from your own sign-in can be saved here: `accessToken`, `refreshToken`, `expiresAt` (RFC 3339) and `tokenType`, with the app that issued them, your `clientId` and `clientSecret` or `oauthApp: "installation"`. They renew like a sign-in’s. A refresh token with no app to renew it is refused.

Saved with only a client ID and secret, a sign-in connection is `pending` until someone signs in.

## Responses

- 201 Created

  Saved. The answer holds no secret.

- 400 `VALIDATION_ERROR`

  `pieceId` or `name` is missing, the app isn’t one this installation runs or doesn’t use a connection, `config` isn’t valid or lacks a required field (`details.fields`), `authType` is over 50 characters, or `Idempotency-Key` is over 255 characters.

- 403 `FORBIDDEN`

  The token lacks the **Write** permission for connections.

- 409 `CONFLICT`

  A request with this `Idempotency-Key` is still in progress, or its answer was too large to keep.

- 422 `VALIDATION_ERROR`

  This `Idempotency-Key` was already used with a different request.

- 422 `CONNECTION_TEST_FAILED`

  With `test: true`: the service refused the details (`details.cause`). Nothing was saved.

- 500 `INTERNAL_ERROR`

  The connection wasn’t saved. Try again.

- 503 `NO_VERDICT`

  With `test: true`: no answer came back (`details.reason`: `no_worker`, `timeout` or `not_served`). Nothing was saved.

- 503 `UNAVAILABLE`

  The installation’s OAuth app for the service (`oauthApp: "installation"`) couldn’t be read. Nothing was saved. Try again.

Any call can also answer: [`401 UNAUTHORIZED`](https://docs.gorunner.io/errors#UNAUTHORIZED) · [`401 TOKEN_ENDED`](https://docs.gorunner.io/errors#TOKEN_ENDED) · [`403 TOKEN_SURFACE_OFF`](https://docs.gorunner.io/errors#TOKEN_SURFACE_OFF) · [`403 FEATURE_OFF`](https://docs.gorunner.io/errors#FEATURE_OFF) · [`403 EMBED_NOT_ALLOWED`](https://docs.gorunner.io/errors#EMBED_NOT_ALLOWED) · [`403 EMBED_NOT_AVAILABLE`](https://docs.gorunner.io/errors#EMBED_NOT_AVAILABLE) · [`403 ACCOUNT_SUSPENDED`](https://docs.gorunner.io/errors#ACCOUNT_SUSPENDED) · [`413 PAYLOAD_TOO_LARGE`](https://docs.gorunner.io/errors#PAYLOAD_TOO_LARGE) · [`415 INVALID_CONTENT_TYPE`](https://docs.gorunner.io/errors#INVALID_CONTENT_TYPE) · [`429 RATE_LIMITED`](https://docs.gorunner.io/errors#RATE_LIMITED) · [`503 BUSY`](https://docs.gorunner.io/errors#BUSY) · [`503 PAUSED`](https://docs.gorunner.io/errors#PAUSED) · [`503 SERVICE_UNAVAILABLE`](https://docs.gorunner.io/errors#SERVICE_UNAVAILABLE). See [Errors](https://docs.gorunner.io/api/errors#by-status).

Request

**curl**

```bash
GORUNNER="https://api.gorunner.io"
GORUNNER_TOKEN="grt_…"  # needs Write for connections

curl -X POST "$GORUNNER/api/connections" \
  -H "Authorization: Bearer $GORUNNER_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "pieceId": "postgres",
    "name": "Warehouse database",
    "config": {
      "host": "db.bakery.example",
      "port": "5432",
      "database": "orders",
      "user": "warehouse",
      "password": "…"
    },
    "test": true
  }'
```

**JavaScript**

```js
const GORUNNER = "https://api.gorunner.io";
const TOKEN = process.env.GORUNNER_TOKEN;

const res = await fetch(`${GORUNNER}/api/connections`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    pieceId: "postgres",
    name: "Warehouse database",
    config: {
      host: "db.bakery.example",
      port: "5432",
      database: "orders",
      user: "warehouse",
      password: "…",
    },
    test: true,
  }),
});
const { data } = await res.json();
```

**Python**

```python
import os, uuid, requests

GORUNNER = "https://api.gorunner.io"
TOKEN = os.environ["GORUNNER_TOKEN"]

res = requests.post(
    f"{GORUNNER}/api/connections",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
      "pieceId": "postgres",
      "name": "Warehouse database",
      "config": {
        "host": "db.bakery.example",
        "port": "5432",
        "database": "orders",
        "user": "warehouse",
        "password": "…",
      },
      "test": True,
    },
)
data = res.json()["data"]
```

Response · 201 Created, shortened

```json
{
  "data": {
    "id": "0f8b2c7e-4a51-4d3e-9b2a-6c1d2e3f4a5b",
    "pieceId": "postgres",
    "name": "Warehouse database",
    "authType": "custom",
    "status": "active",
    "lastTestedAt": "2026-10-24T16:00:00Z",
    "account": "warehouse@db.bakery.example:5432/orders",
    "workspaceId": "c41a7e02-9b3d-4f6a-8e15-7d2c0b9a4f68",
    "orgId": "e9d2b6f1-3a7c-4d05-b8e4-1c6f2a9d7b30",
    "createdBy": "5d8e1f3a-0b6c-4e2d-9f7a-1c3b5e8d2a40",
    "createdAt": "2026-10-20T08:00:00Z",
    "updatedAt": "2026-10-24T16:00:00Z"
  }
}
```

**Every field**

### Body

- `pieceId` · Required · string: The app this credential is for. Must be an app the live worker fleet runs, and that app must declare `auth.required: true` — see `GET /api/pieces/{id}`.
- `name` · Required · string: Human label. Trimmed; required and must not be blank; at most 255 characters (counted in characters, not bytes).
- `authType` · Optional · string: Optional free-text label for the auth shape, at most 50 characters. Defaults to `api_key`. Not validated against the app and does not change how the credential is used — the app decides that.
- `config` · Optional · object: The credential itself. Keys are the `name`s of the app’s `auth.fields[]` (from `GET /api/pieces/{pieceId}`); a field of `type: "password"` holds a secret. For an OAuth2 app this normally holds only `clientId` and `clientSecret` — the authorize/token URLs and scopes come from the app. For the generic `oauth2` app you also supply `authUrl`, `tokenUrl` and `scope` yourself. Encrypted at rest and NEVER returned by any endpoint. Fields the app marks `required` must have a value (400 naming them). The rest are stored as sent, and keys the app does not declare are stored and ignored, so a misspelled optional key surfaces only at test or run time.
- `test` · Optional · boolean: Test the config first and save only if the service accepts it: refused → 422 `CONNECTION_TEST_FAILED` (`details.cause`), no verdict → 503 `NO_VERDICT` (`details.reason`), nothing saved in either case. Accepted → 201 with the row tested now and `test`.
- `oauthApp` · Optional · string: Only with OAuth2 tokens in `config` (an `accessToken`): they came from the installation’s app for this service, which refreshes them. Refused without tokens, with `clientId` in `config`, or when the installation has no app for the service. One of `installation`.

### Response · 201 Created

- `data` · object: A stored credential set for one app. The encrypted credential itself is never present in this object — the model tags it `json:"-"` — so there is no field here, and no endpoint anywhere, that returns a secret you wrote.
  - `id` · UUID: The connection’s id. This is the value a workflow step or trigger carries as `connectionId` to use these credentials.
  - `pieceId` · string: The app these credentials belong to (e.g. `seatable`, `postgres`, `googlesheets`, or the generic `oauth2`). Fixed at creation and not changeable.
  - `name` · string: Human label. Not unique.
  - `authType` · string: Free-text label recording the auth shape. Defaults to `api_key` when not supplied at creation. Conventionally one of the app’s `auth.type` values, but NOT validated against the app — treat it as advisory.
  - `status` · string: The last known verdict on these credentials. `active` = created normally, or the last test passed. `pending` = an OAuth2 connection that has not completed the authorization flow (no access token yet). `unverified` = the last test proved nothing because the app has no connection test; credentials are checked on first real use. `error` = the last test was refused by the provider. `expired` = a refresh was refused for good — the provider rejected the stored grant or the client (revoked, aged out), or the OAuth app that issued the tokens was replaced or removed; signing in again (Re-authorize) mends it. The test endpoint (and `test: true` on create or update), the OAuth2 sign-in and a refused refresh set it; an update that sends `config` resets it to `active` (`pending` for a sign-in with no token); a rename leaves it. One of `active`, `pending`, `unverified`, `error`, `expired`.
  - `lastTestedAt` · time: When the connection was last tested. Absent if never tested.
  - `tokenExpiresAt` · time: OAuth2 access-token expiry, when known. Refresh happens automatically at run time; there is no manual refresh call.
  - `refreshable` · boolean: The stored config carries a refresh token, so `tokenExpiresAt` is when the access token is next RENEWED (on use), not when the connection ends. False for pasted access tokens and API keys. A connection whose refresh the provider refuses for good is marked `status: expired` on the next refresh attempt and needs signing in again.
  - `managedBy` · string: Present only on a connection the installation’s partner manages through the embed API. It is read-only here: update, delete and the OAuth2 sign-in answer 403 `MANAGED_CONNECTION`; test and replace stay open. One of `partner`.
  - `workspaceId` · UUID: Owning workspace. Populated on the create’s answer; the all-zero UUID on the list, the single read and an update’s answer, which read the row the way the list does and do not select it.
  - `orgId` · UUID: Owning organization. Same caveat as `workspaceId`: all-zero except in the create’s answer.
  - `createdBy` · UUID: The user who created it. Absent when no person did (the partner’s, a machine’s, rows from before creators were recorded).
  - `createdAt` · time
  - `updatedAt` · time
  - `account` · string: Who or what the credential is, never a secret: the Google address a test names, `user@host` (with port and database) from a typed credential’s non-secret fields, a SeaTable base’s name and server. Shown to every reader. Absent until known.
  - `createdByName` · string: The creator’s name (else their address), only while they are a member of the organization.
  - `createdByMember` · boolean: False once the creator is no longer a member of the organization. Absent with `createdBy`.
  - `lastError` · object: The last failure that still stands. Written by a failed test, a refused refresh (`expired`), and a run’s or poll’s definite refusal; a run’s 403 is kept as news (`cause: forbidden`) without changing `status`. Cleared by a working test, a sign-in, a config change, and the next run or poll that uses the connection and works.
    - `at` · time
    - `source` · string: One of `test`, `run`, `trigger`, `refresh`, `signin`.
    - `cause` · string: `refused` — the service refused the credential (401, SMTP 535, Postgres 28P01, MySQL 1045, …); `expired` — a refresh was refused for good; `server` — the server could not be reached; `forbidden` — a 403, news only; `other`. One of `refused`, `expired`, `server`, `forbidden`, `other`.
    - `message` · string: The service’s own words, masked of the step’s secrets, at most 1,000 characters.
    - `runId` · UUID
    - `workflowId` · UUID
    - `workflowName` · string: While the workflow exists.
  - `lastUse` · object: The newest use the audit trail records (its `connection.decrypt` rows). Absent when the trail has none.
    - `at` · time
    - `kind` · string: One of `run`, `trigger`, `test`, `editor`, `signin`.
    - `runId` · UUID
    - `workflowId` · UUID
    - `workflowName` · string
    - `byName` · string: The person, for a test, the builder or a sign-in, while a member of the organization.
  - `oauthClient` · string: For a connection that signs in: `managed` — the installation’s app; `byo` — its own client id; `pasted` — an access token with no refresh token, which cannot renew itself. One of `managed`, `byo`, `pasted`.
  - `lastSignInAt` · time: When a sign-in last stored tokens.
  - `expiresAt` · time: When the connection stops working with no way to renew: `tokenExpiresAt` of a connection that is not `refreshable`. Absent for a refreshable one and when unknown.
  - `test` · object: Only on the answer of a create or a change sent with `test: true`: the test that let it be saved.
    - `success` · boolean: Always `true` here: a refused test saves nothing and is a 422.
    - `message` · string: The app’s own words.
    - `unverified` · boolean: Present and true when the app cannot be tested without running a real step: saved, and nothing was proven.

Related: [Create a connection from code](https://docs.gorunner.io/api/create-a-connection) · [Test a connection](https://docs.gorunner.io/api/connections/test-connection) · [Start a sign-in for a new connection](https://docs.gorunner.io/api/connections/start-connection-oauth2-draft)

Source: https://docs.gorunner.io/api/connections/create-connection
