# Connected services

> Call third-party APIs with ctx.http and auth aliases. Helix injects credentials server-side, so tokens never appear in your code or logs.

Your functions call third-party APIs through `ctx.http`, a [ky](https://github.com/sindresorhus/ky) HTTP client. For authenticated calls, `ctx.http.authed('alias')` routes the request through a Helix proxy that looks up the credentials and injects them server-side. Your code references an alias, never a token, so secrets stay out of your source, your logs, and your repository.

:::note{title="Signatures are provisional"}
The alias model on this page is how connected services work. The exact shape of the `ctx.http` surface is still being confirmed with engineering, so treat the method signatures and options in the samples below as provisional and check them against your SDK version before you depend on them.
:::

## How credential injection works

When you call `ctx.http.authed('salesforce_prod').get(url)`:

1. The SDK rewrites the request to the Helix proxy service, passing the target URL and the authentication UUID mapped to the alias.
2. The proxy fetches the credential record and refreshes the OAuth token if it has expired.
3. The proxy injects the credential according to the authentication's type, then forwards the request to the target API and streams the response back to your function.
4. The full request and response are logged with credentials redacted, tagged with your project ID, execution ID, and auth alias.

Credential data and injection rules live entirely on Helix's servers. Your project stores only a mapping from aliases to authentication UUIDs. For the trust boundaries behind this design, see [the security model](/documentation/concepts/security-model/).

:::roadmap{title="Self-hosted proxy"}
The proxy service is cloud-hosted today. Running it locally or on-premise is a future deployment option for the proxy itself. Function code would not change: `ctx.http.authed('alias')` stays the same, only the proxy endpoint differs.
:::

## Where authentications come from

An authentication is the credential record itself: the account you connected, the tokens behind it, and the scopes it carries. Create authentications in your **Tray iPaaS account**, not in Helix. Helix then references them by UUID.

| Type | What the proxy sends | Scopes |
|---|---|---|
| OAuth 2.0 | A token obtained from the service's OAuth flow, refreshed by the proxy when it expires | Chosen when you authorize the connection |
| API Token | A token or key you supply | Whatever the token was issued with |

Both types carry scopes, and the scopes decide what your app can do with the connected account. Grant the narrowest set the app needs: an authentication is shared by every function in the project, and if you widen who can open the published app, you widen who can act through that authentication. See [identity and roles](/documentation/guides/identity-and-roles/).

:::roadmap{title="Creating authentications inside Helix"}
Creating an authentication without leaving Helix is planned. Today the record is created in Tray iPaaS and mapped into your project by UUID.
:::

## Map aliases in helix.config.ts

```typescript
// helix.config.ts
import { defineConfig } from '@trayai/helix-sdk';

export default defineConfig({
  projectId: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d',
  workspaceId: 'f8e7d6c5-4b3a-2190-8765-abcdef012345',

  authentications: {
    salesforce_prod: '1a0fbf5c-2c9e-4aa1-ada3-ccbba65019ab',
    slack: '7b2e4f91-3d1a-4c8b-9e5f-1234567890ab',
  },
});
```

Each key is an alias you choose. Each value is the UUID of an authentication created in your workspace. Calling `authed()` with an alias missing from this map throws immediately, and the error lists the aliases that are available.

## Connect a service

Once the authentication exists in Tray iPaaS, create the alias from the CLI:

```bash
# Interactive: pick a service and an authentication, and name the alias
helix auth connect [service]

# Non-interactive: map an alias straight to an authentication UUID
helix auth add salesforce_prod 1a0fbf5c-2c9e-4aa1-ada3-ccbba65019ab

# List the aliases mapped in this project
helix auth list
```

`helix auth connect` prompts you through the choice and writes the alias into `helix.config.ts`. `helix auth add` is the same result in one line when you already know the UUID.

You can also ask Claude. From inside your project, the agent can wire up an authenticated service and drive these commands for you. The full command surface is on the [auth CLI reference](/documentation/reference/cli/auth/).

## Make requests

`ctx.http` is a standard ky instance, so requests look like ky everywhere else: `searchParams` for query strings, `json:` for JSON bodies, `.json()` to parse responses.

```typescript
// functions/api/v1/report.get.ts
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  // Authenticated: proxied through Helix, credentials injected server-side
  const contacts = await ctx.http
    .authed('salesforce_prod')
    .get('https://acme.my.salesforce.com/services/data/v59.0/query', {
      searchParams: { q: 'SELECT Id, Name FROM Contact' },
    })
    .json();

  // Unauthenticated: direct request, still logged
  const weather = await ctx.http
    .get('https://api.open-meteo.com/v1/forecast', {
      searchParams: { latitude: 51.5, longitude: -0.1 },
    })
    .json();

  return { contacts, weather };
});
```

Every ky feature is available, including retries with backoff and per-request timeouts:

```typescript
const data = await ctx.http
  .get('https://flaky-api.example.com/data', {
    retry: { limit: 3, backoffLimit: 3000 },
    timeout: 10_000,
  })
  .json();
```

Because `authed()` returns a normal ky instance, you can `.extend()` it into a pre-configured client for one API:

```typescript
const sf = ctx.http.authed('salesforce_prod').extend({
  prefixUrl: 'https://acme.my.salesforce.com/services/data/v59.0',
  headers: { 'Sforce-Auto-Assign': 'FALSE' },
});

const contacts = await sf.get('query', {
  searchParams: { q: 'SELECT Id FROM Contact' },
}).json();

const account = await sf.get('sobjects/Account/001xx').json();
```

:::roadmap{title="Working offline"}
An offline mode for `helix dev` that returns mock responses instead of making network calls is platform design. The shipped `helix dev` flags are `-p`/`--port`, `--pretty-logs`, and `--raw-logs`. Local runs make real calls through the proxy.
:::

Write full URLs. Where a service uses a per-account hostname, such as a Salesforce instance URL or a Jira subdomain, put the literal value in your code or read it from your own configuration.

:::roadmap{title="Template variables, ctx.auth(), and the wider signing set"}
The platform design goes further than the two authentication types that ship. None of the following is available today:

- **URL template variables.** `{{instance_url}}` and `{{subdomain}}` placeholders in an authed URL, resolved by the proxy from non-sensitive fields on the credential record.
- **`ctx.auth(alias)`.** Reading those non-sensitive fields in your handler, for logging or building identifiers.
- **The wider signing set.** Basic auth, OAuth 1.0a, AWS SigV4, HMAC body signing, and connector-specific custom schemes. The shipped types are OAuth 2.0 and API Token.
:::

## Automatic token refresh

If a target API returns a 401 and the credential is OAuth-based, the proxy refreshes the token and retries the request once. Expired tokens are also refreshed when the proxy resolves the credential record, before the first attempt. Your function sees only the final response and never handles refresh logic.

## Request logging and audit

Every authenticated request and response is logged with credentials redacted and tagged with project ID, execution ID, and auth alias. Unauthenticated `ctx.http` requests are logged too. Full request and response visibility is available in the [Helix dashboard](https://app.helix.tray.ai), so an admin can audit exactly which functions called which APIs under which authentication. Open an execution to see the third-party calls it made. See [observability](/documentation/guides/observability/).

## Worked example: Salesforce

```typescript
// functions/api/v1/salesforce/contacts.get.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  limit: z.coerce.number().min(1).max(200).default(50),
});

interface SalesforceQueryResult {
  totalSize: number;
  done: boolean;
  records: Array<{ Id: string; Name: string; Email: string | null }>;
}

export default defineFunction<typeof input>(async (ctx) => {
  const result = await ctx.http
    .authed('salesforce_prod')
    .get('https://acme.my.salesforce.com/services/data/v59.0/query', {
      searchParams: {
        q: `SELECT Id, Name, Email FROM Contact LIMIT ${ctx.input.limit}`,
      },
    })
    .json<SalesforceQueryResult>();

  return {
    total: result.totalSize,
    contacts: result.records.map((r) => ({
      id: r.Id,
      name: r.Name,
      email: r.Email,
    })),
  };
});
```

## Worked example: Slack

Slack returns HTTP 200 with `ok: false` on failure, so check the body rather than the status code:

```typescript
// functions/api/v1/notify.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  channel: z.string().min(1),
  text: z.string().min(1),
});

interface SlackPostMessageResponse {
  ok: boolean;
  ts?: string;
  error?: string;
}

export default defineFunction<typeof input>(async (ctx) => {
  const result = await ctx.http
    .authed('slack')
    .post('https://slack.com/api/chat.postMessage', {
      json: { channel: ctx.input.channel, text: ctx.input.text },
    })
    .json<SlackPostMessageResponse>();

  if (!result.ok) {
    ctx.log.error('Slack rejected the message', { error: result.error });
    throw ctx.error(502, 'Slack API error');
  }

  return { messageTs: result.ts };
});
```

## Handle upstream errors

ky throws on 4xx and 5xx responses. Catch the error, log the upstream body, and return a controlled error to your caller:

```typescript
export default defineFunction(async (ctx) => {
  try {
    return await ctx.http
      .authed('salesforce_prod')
      .get('https://acme.my.salesforce.com/services/data/v59.0/query', {
        searchParams: { q: 'SELECT Id FROM Contact' },
      })
      .json();
  } catch (error: any) {
    if (error.response) {
      // HTTP error from the target API (4xx, 5xx)
      const body = await error.response.json();
      ctx.log.error('Salesforce error', { status: error.response.status, body });
      throw ctx.error(502, 'Upstream API error');
    }
    // Network error, proxy error, etc.
    throw error;
  }
});
```

## HTTP and HTTPS only

The proxy covers HTTP and HTTPS, which includes REST, GraphQL, SOAP, webhooks, and AWS APIs. It is the only way a function reaches an outside system today: there is no database connection and no queue. For state between requests, use the project-scoped [key-value store](/documentation/guides/key-value-store/).

:::roadmap{title="FTP and SFTP"}
Non-HTTP protocols are not routed through the proxy today. FTP/SFTP support is a future consideration, likely as a dedicated context method with its own transport, with credential injection still handled by Helix through an alias. Direct SMTP is out of scope: use an HTTP email API such as SendGrid or SES instead.
:::

---

Canonical: https://helix.tray.ai/documentation/guides/connected-services/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt