Building A Custom Connector

Goal

Wrap an external service — a CRM, a document store, a payment API, anything reachable over HTTP — as a reusable connector so its operations can be driven from automations, custom PHP, custom apps, and the Cloud Console, with credentials managed safely by the platform.

This recipe walks through both ways to build one, with full code, and the strategies for designing operations, authentication, and error handling.

Core vs Bundle: Which To Build

Bundle connectorCore connector
Who builds itImplementers, via the admin UIPlatform developers, in the codebase
ShippingUpload a .zip (manifest + handlers)A PHP class in the codebase
Where operations runSandboxed handlersNative PHP, full platform access
Builder actionsGenerated automatically on activateHand-written action classes
Reach for it whenYou are implementing a tenant and want a deployable integrationYou need shared helper logic, performance, or access the sandbox forbids

Default to a bundle connector. It needs no code merged into the platform — you upload it, connect an account, and activate. Choose core only when you are extending the platform itself.

The Shared Vocabulary

Both kinds describe themselves with the same manifest shape and share the same model:

  • A definition is the connector type (one manifest).
  • A connection is one authenticated account of that type. Operations always run against a chosen connection. Non-secret settings live in the connection's config; secrets live encrypted in the platform and are never handed to callers.
  • An operation is a named function. Each operation can become its own automation builder action and is callable via $api->connectors.
Definition "contactsApi"
  ├─ connection "contacts-prod"   → API key A
  └─ connection "contacts-staging"→ API key B

$api->connectors->get('contacts-prod')->listContacts(['page' => 1])

Strategy: Designing Operations

Before writing code, decide the operation surface:

  • One operation = one intent. Prefer listContacts, getContact, createContact over a single catch-all request. Named operations become self-describing builder actions and autocomplete entries.
  • Inputs are the action's form. Each input becomes a field in the generated builder action and a key in the handler's $input. Give every input a clear name; set sensible defaults.
  • Outputs shape the success branch. Declare outputs for the values automations will want to read. On success the action exposes the whole result plus each declared output key found in it (when the result is an associative array) — so return an associative array whose keys match your outputs.
  • exposeAsAction vs exposeInApi. Mark the operations users will wire in the builder as exposeAsAction. Keep low-level or chaining helpers exposeInApi only (still callable from code, no palette clutter).
  • Make operations idempotent where you can. A callback or automation may retry; a createOrUpdate keyed on an external id is safer than a blind create.

Building A Bundle Connector

We'll build a contactsApi connector for a fictional REST contacts service with three operations.

1. Scaffold the bundle

Start from a template (Connectors → Connector templates → download) or create the layout by hand:

contactsApi.zip
├── connector.json
└── server/
    ├── listContacts.php
    ├── getContact.php
    └── createContact.php

2. Write the manifest

connector.json is the single source of truth. It is re-read on every request, so redeploying or editing it takes effect immediately.

{
  "name": "Contacts API",
  "slug": "contactsApi",
  "version": "1.0",
  "description": "Read and create contacts in the Contacts service.",
  "icon": "<i class=\"bi bi-person-rolodex\"></i>",
  "auth": { "type": "apiKey" },
  "operations": {
    "listContacts": {
      "name": "List contacts",
      "info": "<p>Returns one page of contacts.</p>",
      "inputs": {
        "page":    { "type": "int|null",    "name": "Page",     "default": 1 },
        "perPage": { "type": "int|null",    "name": "Per page", "default": 50 },
        "search":  { "type": "string|null", "name": "Search" }
      },
      "outputs": {
        "contacts": { "type": "array", "name": "Contacts" },
        "nextPage": { "type": "int|null", "name": "Next page" }
      },
      "handler": "server/listContacts.php",
      "exposeAsAction": true,
      "exposeInApi": true
    },
    "getContact": {
      "name": "Get contact",
      "inputs": { "id": { "type": "scalar", "name": "Contact ID" } },
      "outputs": { "contact": { "type": "object|null", "name": "Contact" } },
      "handler": "server/getContact.php",
      "exposeAsAction": true,
      "exposeInApi": true
    },
    "createContact": {
      "name": "Create contact",
      "inputs": {
        "email": { "type": "string",      "name": "Email" },
        "name":  { "type": "string|null", "name": "Name" },
        "tags":  { "type": "array|null",  "name": "Tags" }
      },
      "outputs": { "contact": { "type": "object|null", "name": "Created contact" } },
      "handler": "server/createContact.php",
      "exposeAsAction": true,
      "exposeInApi": true
    }
  }
}

3. Write the operation handlers

A handler runs in the platform sandbox. It receives:

  • $connector — the operation context. Key methods: configValue($key, $default), config(), credentials(), accessToken(), apiKey(), connection(), http(), api().
  • $input — the resolved inputs, keyed as in the manifest.
  • $api / $jetstack — the full Internal API.

Return the operation's result. Throw any exception to fail — the platform converts a handler exception into a connector error, so it lands on a builder action's On error endpoint and surfaces to $api->connectors callers. (Throw a plain \Exception; the sandbox does not allow constructing platform exception classes.)

Keep shared logic in one place with $api->include(). It runs a sibling PHP file in the sandbox and returns its return value, resolving the path relative to the handler file (and only within the tenant's local/, apps/, and connectors/ areas — see Sharing code between handlers). Put a small request helper beside the handlers in server/:

server/_client.php

<?php
// Returns an authenticated-request helper. $connector is passed in by the caller
// (included files do not inherit the handler's local variables).
return function ($connector, string $method, string $path, array $query = [], $body = null): array {
    $base = rtrim((string) $connector->configValue('base_url', ''), '/');
    if ($base === '') {
        throw new \Exception('This connection has no base_url configured.');
    }
    $url = $base . '/' . ltrim($path, '/');
    if ($query) {
        $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query);
    }

    $headers = ['Accept: application/json'];
    if ($connector->apiKey() !== null) {
        $headers[] = 'Authorization: Bearer ' . $connector->apiKey();
    }

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
    curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    $response = curl_exec($ch);
    $status   = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error    = curl_error($ch);
    curl_close($ch);

    if ($response === false) {
        throw new \Exception('Request to ' . $path . ' failed: ' . $error);
    }
    if ($status >= 400) {
        throw new \Exception('Contacts API returned HTTP ' . $status . ': ' . (string) $response);
    }
    return [$status, json_decode((string) $response, true)];
};

server/listContacts.php

<?php
$client = $api->include('_client.php'); // resolves to server/_client.php (next to this handler)

[$status, $body] = $client($connector, 'GET', 'contacts', [
    'page'     => (int) ($input['page'] ?? 1),
    'per_page' => (int) ($input['perPage'] ?? 50),
    'q'        => (string) ($input['search'] ?? ''),
]);

// Return an associative array whose keys match the manifest `outputs`, so the
// builder action exposes `contacts` and `nextPage` on its On-success branch.
return [
    'contacts' => $body['data'] ?? [],
    'nextPage' => $body['next_page'] ?? null,
];

server/getContact.php

<?php
$client = $api->include('_client.php');
$id = (string) ($input['id'] ?? '');
if ($id === '') {
    throw new \Exception('A contact ID is required.');
}
[$status, $body] = $client($connector, 'GET', 'contacts/' . rawurlencode($id));
return ['contact' => $body];

server/createContact.php

<?php
$client = $api->include('_client.php');
$email = trim((string) ($input['email'] ?? ''));
if ($email === '') {
    throw new \Exception('An email is required.');
}
[$status, $body] = $client($connector, 'POST', 'contacts', [], [
    'email' => $email,
    'name'  => $input['name'] ?? null,
    'tags'  => $input['tags'] ?? [],
]);
return ['contact' => $body];

Sharing code between handlers

$api->include($name) resolves $name relative to the directory of the running handler — so a handler in server/ includes server/_client.php as $api->include('_client.php'), or a file at the bundle root as $api->include('../_client.php'). A path that begins with / is instead tenant-root-relative, useful for a shared library that lives outside the bundle — for example $api->include('/local/lib/http.php') loads TENANT_DIR/local/lib/http.php from any context. An /apps/<slug>/… or /connectors/<slug>/… path resolves against that item's active version automatically, so you omit the v<id> segment — e.g. $api->include('/connectors/acmeDocs/server/_client.php') loads it from whichever version is currently active (add an explicit v<n> to pin one). For safety, includes are restricted to the current tenant's local/, apps/, and connectors/ directories (resolved through realpath, so symlinks and ../ cannot escape them); anything else is refused. Included files run in the same sandbox but do not inherit the handler's variables — pass $connector (and anything else) in as arguments, as above.

4. Deploy, connect, activate

  1. Zip the bundle and Connectors → Deploy a connector (or upload a new version of an existing one), then Activate the version. On activation, one builder action is generated per exposeAsAction operation, under the Connectors group, named "Contacts API: List contacts", etc.
  2. Under Connections, add a connection: set the Base URL (https://api.contacts.example/v1) and the API key. It connects immediately for apiKey auth.
  3. (Optional) Set Access to restrict the connector to specific roles.

5. Use it

From the Cloud Console or a Custom PHP action:

$page = $api->connectors->get('contacts-prod')->listContacts(['page' => 1, 'perPage' => 100]);
foreach ($page['contacts'] as $c) {
    // …
}
$created = $api->connectors->get('contacts-prod')->createContact(['email' => 'a@b.com', 'name' => 'Ada']);

In an automation, add Contacts API: List contacts, choose the connection, set inputs, and read contacts / nextPage from the On success endpoint (or result for the whole payload). Wire On error to handle failures (its message carries the thrown text).

Authentication Strategies

Set auth.type in the manifest; the connection form then collects the right fields and the platform stores secrets encrypted. In a handler, read them via $connector:

auth.typeConnection providesRead in handler
nonenothing (often a url/base_url in config)$connector->configValue('url')
apiKeyan API key$connector->apiKey()
bearera static token$connector->accessToken()
basicusername + password$connector->credentials()['username'] / ['password']
oauth2full authorization-code flow$connector->accessToken() (auto-refreshed)

For OAuth2, put the endpoints, scope, and any provider-specific authorize params in the manifest so operators never type them. Do not put client_id/client_secret here — those are operator/tenant-specific and entered once in the UI (below):

"auth": {
  "type": "oauth2",
  "base_url": "https://api.service.com",
  "authorization_endpoint": "https://service.com/oauth/authorize",
  "token_endpoint": "https://service.com/oauth/token",
  "scope": "contacts.read contacts.write",
  "authorize_params": { "access_type": "offline", "prompt": "consent" }
}

Omitted endpoints are discovered from base_url (RFC 8414/9728).

The OAuth client is registered once, not per connection. OAuth always needs a client_id/client_secret from the provider — there's no credential-free OAuth. So:

  1. Register the OAuth client with the provider once and add the redirect URI it shows on the connector page:
    https://<your-tenant-host>/connector/<slug>/oauth/callback
    
  2. Save its client id + secret under the connector's OAuth application card (the oauth2 connector page shows it). This is a one-time admin step.
  3. Now each connection is one click: add it (just a name), click Connect, authorize — the platform generates a PKCE challenge + state, exchanges the code, stores tokens encrypted, and marks it connected. Reconnect re-runs the flow. (A connection can still carry its own client id/secret to use a different OAuth app, and connectors whose provider supports RFC 7591 register a client automatically.)

At run time the platform refreshes a near-expiry access token before each call, so the handler just reads the current token. Send it from the shared client as a bearer token:

// in server/_client.php
if ($connector->accessToken() !== null) {
    $headers[] = 'Authorization: Bearer ' . $connector->accessToken();
}
// server/listDocuments.php
$client = $api->include('_client.php');
[$status, $body] = $client($connector, 'GET', 'documents'); // token applied inside the client
return ['documents' => $body['items'] ?? []];

Never put secrets in the manifest or in config. Config is non-secret and visible; credentials — tokens, the client secret, basic-auth passwords — are encrypted and entered per connection. See OAuth Connections for the end-to-end flow.

Building A Core Connector

A core connector is a codebase class — reach for it when an operation needs full platform access (no sandbox), shared services, or performance the sandbox can't give. It uses the same manifest shape, declared in a static manifest(), and implements one public method per operation. The base driver maps an operation id to a like-named method (list-contactslistContacts). The platform discovers it automatically and registers a core definition.

app/model/connectors/ContactsApiConnector.php

<?php
declare(strict_types=1);

namespace App\Model\Connectors;

class ContactsApiConnector extends BaseConnectorDriver
{
    public static function manifest(): array
    {
        return [
            'slug' => 'contactsApi',
            'name' => 'Contacts API',
            'icon' => '<i class="bi bi-person-rolodex"></i>',
            'auth' => ['type' => 'apiKey'],
            'operations' => [
                'listContacts' => [
                    'name' => 'List contacts',
                    'inputs'  => ['page' => ['type' => 'int|null', 'name' => 'Page', 'default' => 1]],
                    'outputs' => ['contacts' => ['type' => 'array', 'name' => 'Contacts']],
                    'exposeInApi' => true,
                ],
            ],
        ];
    }

    /** @param array<string,mixed> $input */
    public function listContacts(ConnectorContext $ctx, array $input): array
    {
        $base = rtrim((string) $ctx->configValue('base_url', ''), '/');
        if ($base === '') {
            throw new ConnectorException('This connection has no base_url configured.');
        }
        $ch = curl_init($base . '/contacts?page=' . (int) ($input['page'] ?? 1));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $ctx->apiKey()]);
        $response = curl_exec($ch);
        $status   = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($status >= 400) {
            throw new ConnectorException('Contacts API HTTP ' . $status);
        }
        $body = json_decode((string) $response, true);
        return ['contacts' => $body['data'] ?? []];
    }
}

In a core driver you can throw ConnectorException directly (full PHP access), and it routes to an action's On error endpoint just like a wrapped bundle error.

Core connectors do not auto-generate builder actions. Either call them from a Custom PHP action via $api->connectors, or ship a hand-written action subclass. The action bakes in the reserved connectorConnection input plus the operation's inputs/outputs:

app/automations/connectorsContactsApi.php

<?php
namespace App\Automations;

class ContactsApiListAction extends ConnectorAction
{
    public const CONNECTOR_SLUG = 'contactsApi';
    public const OPERATION = 'listContacts';

    protected static string $name = 'Contacts API: List contacts';
    protected static bool $inheritEndpointsDefinition = false;

    protected static array $inputParametersDefinition = [
        'connectorConnection' => ['type' => 'scalar|null', 'name' => 'Connection'],
        'page'                => ['type' => 'int|null',    'name' => 'Page', 'default' => 1],
    ];

    protected static array $endpointsDefinition = [
        'success' => ['name' => 'On success', 'providesParameters' => [
            'result'   => ['type' => 'mixed', 'name' => 'Result'],
            'contacts' => ['type' => 'array', 'name' => 'Contacts'],
        ]],
        'error' => ['name' => 'On error', 'providesParameters' => [
            'message' => ['type' => 'string', 'name' => 'Error message'],
        ]],
    ];
}

The run() is inherited: it reads the chosen connection, forwards the remaining inputs to callOperation(), and fires the success/error endpoint.

Error Handling

  • Fail loudly. Throw on a non-2xx response or a missing required input. In a bundle handler throw \Exception; in a core driver throw ConnectorException. Either way the failure reaches the action's On error endpoint (message) and propagates to $api->connectors callers.
  • Branch on outcomes in the builder. Wire On error to a notification or compensating step rather than letting the automation hard-fail, when the caller can recover.
  • Return structured results. Returning { status, body } or { contacts, nextPage } lets automations and code branch without re-parsing.

Optional: A Connector Frontend

When a connection needs an interactive step — a Google-Drive-style file picker, a channel chooser — ship a small frontend with the bundle. It is served under the connector's own URL and is signed-in only (an operator tool, never public). Most connectors don't need one.

Add a public/ directory and a frontend block to the manifest:

"frontend": {
  "routes": {
    "GET /search": { "handler": "server/search.php", "auth": "user" }
  }
}
contactsApi.zip
├── connector.json
├── server/
│   ├── listContacts.php       # operation handler (run by callOperation / $api->connectors)
│   └── search.php             # frontend BFF handler (run at /connector/<slug>/api/search)
└── public/
    └── index.html             # the picker page (served at /connector/<slug>/)

Served where:

URLServes
/connector/contactsApi/ (and /<path>)static assets from public/ (SPA index.html fallback)
/connector/contactsApi/api/searchthe server/search.php BFF handler
/connector/contactsApi/oauth/callbackOAuth callback (oauth2 connectors)

The page (public/index.html) is plain static markup. It knows its own base path from window.location and calls its BFF directly — there is no injected platform bootstrap:

<!doctype html>
<meta charset="utf-8">
<title>Pick a contact</title>
<input id="q" placeholder="Search…">
<ul id="results"></ul>
<script>
  // The opener says which connection to use: /connector/contactsApi/?connection=contacts-prod
  const connection = new URLSearchParams(location.search).get('connection');
  const base = location.pathname.replace(/\/[^/]*$/, '');   // → /connector/contactsApi

  document.getElementById('q').addEventListener('input', async (e) => {
    const url = `${base}/api/search?connection=${encodeURIComponent(connection)}&q=${encodeURIComponent(e.target.value)}`;
    const data = await (await fetch(url)).json();
    document.getElementById('results').innerHTML =
      (data.contacts || []).map(c => `<li data-id="${c.id}">${c.name}</li>`).join('');
  });

  document.getElementById('results').addEventListener('click', (e) => {
    const id = e.target.closest('li')?.dataset.id;
    if (id && window.opener) {
      window.opener.postMessage({ contactId: id }, location.origin);
      window.close();
    }
  });
</script>

The BFF handler (server/search.php). A frontend route handler receives $request, $query, $post, $body, and $api / $jetstack — but not $connector (a page isn't tied to one connection, so it passes the connection slug in the request and the handler resolves it). It calls a connector operation and returns JSON with $jetstack->sendJson():

<?php
$connection = (string) ($query['connection'] ?? '');
if ($connection === '') {
    $jetstack->sendJson(['contacts' => []]);
    return;
}
$page = $api->connectors->get($connection)->listContacts([
    'search'  => (string) ($query['q'] ?? ''),
    'perPage' => 20,
]);
$jetstack->sendJson(['contacts' => $page['contacts'] ?? []]);

Where it renders. The platform does not embed the frontend anywhere automatically — it does not appear in the connector admin page or the automation builder. Open or embed it yourself, typically from a custom app or a custom button, and receive the user's choice back:

// In your app: open the picker and use the chosen id
window.addEventListener('message', (e) => {
  if (e.origin === location.origin && e.data?.contactId) {
    useContact(e.data.contactId);    // store it, or feed it into an operation call
  }
});
window.open('/connector/contactsApi/?connection=contacts-prod', 'picker', 'width=480,height=600');

The round-trip keeps tokens server-side: the page only ever talks to its own BFF, which talks to the connector operation.

Storing a choice on the connection (Google-Drive-folder pattern)

A very common setup is "connect the account, then pick one folder/channel/list to scope the connection to" — e.g. a Google Drive connector that only ever touches a single chosen folder. The platform supports this directly: make the frontend the connection's setup screen and have it save the pick into the connection's config. No opener or custom app is needed.

Add a setup path to the frontend block ("" = the public/index.html root):

"frontend": {
  "setup": "",
  "routes": {
    "GET /folders":      { "handler": "server/folders.php",    "auth": "user" },
    "POST /save-folder": { "handler": "server/saveFolder.php", "auth": "user" }
  }
}

Every connected connection now shows a Configure button on the connector's admin page that opens /connector/<slug>/?connection=<connection-slug>. The picker lists folders and saves the chosen one:

server/folders.php — list folders via an operation (note: a frontend handler resolves the connection from the request):

<?php
$folders = $api->connectors->get((string) $query['connection'])->listFolders();
$jetstack->sendJson(['folders' => $folders['folders'] ?? []]);

server/saveFolder.php — persist the pick into the connection config:

<?php
$connection = (string) ($body['connection'] ?? '');
$folderId   = (string) ($body['folderId'] ?? '');
if ($connection === '' || $folderId === '') {
    $jetstack->sendJson(['ok' => false, 'error' => 'connection and folderId are required'], 422);
    return;
}
// setConfig() merges into the connection's non-secret config (never credentials).
$config = $api->connectors->get($connection)->setConfig(['folderId' => $folderId]);
$jetstack->sendJson(['ok' => true, 'config' => $config]);

The page wires those together — list on load, POST the selection, then close:

<ul id="folders"></ul>
<script>
  const connection = new URLSearchParams(location.search).get('connection');
  const base = location.pathname.replace(/\/[^/]*$/, '');   // → /connector/<slug>

  (async () => {
    const { folders } = await (await fetch(`${base}/api/folders?connection=${encodeURIComponent(connection)}`)).json();
    document.getElementById('folders').innerHTML = folders.map(f => `<li data-id="${f.id}">${f.name}</li>`).join('');
  })();

  document.getElementById('folders').addEventListener('click', async (e) => {
    const folderId = e.target.closest('li')?.dataset.id;
    if (!folderId) return;
    await fetch(`${base}/api/save-folder`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ connection, folderId }),
    });
    alert('Folder saved. You can close this window.');
  });
</script>

Finally, every operation scopes itself to the saved folder by reading it from config:

<?php
// server/listFiles.php
$folderId = (string) $connector->configValue('folderId', '');
if ($folderId === '') {
    throw new \Exception('No folder selected yet — open Configure on the connection first.');
}
$client = $api->include('_client.php');
[$status, $body] = $client($connector, 'GET', 'files', ['folderId' => $folderId]);
return ['files' => $body['files'] ?? []];

setConfig() only ever writes the non-secret config blob; config() reads it back. The saved folderId is shown under the connection in the admin and is available to every operation through $connector->configValue('folderId').

The platform ships exactly this as the Google Drive connector template (Connectors → Connector templates → download). It is a complete, working bundle — OAuth2 with authorize_params for offline access, a frontend.setup folder picker, and listFiles/downloadFile/uploadFile operations scoped to the chosen folder. Use it as the reference implementation for this whole pattern.

Testing And Iterating

  • Cloud Console first. $api->connectors->get('…')->listContacts([...]) gives an immediate, credential-backed round-trip; autocomplete lists the operations.
  • Edit in place. A bundle's manifest and handlers are read live; use Edit files on the active version to iterate without re-zipping. Re-activate (or Regenerate builder actions) after changing operation inputs/outputs so the generated actions match.
  • Check the connection status. A red error status on a connection shows the last failure message — usually an auth or endpoint problem.

Best Practices

  • One operation per intent; clear input names and defaults; outputs that match the keys you return.
  • Keep secrets in credentials, settings in config; never in the manifest.
  • Centralize HTTP + auth in a small helper the handlers share.
  • Prefer bundle connectors for tenant work; reserve core connectors for codebase-level needs.
  • Make create/update operations idempotent against retries.
  • Restrict sensitive connectors with Access roles.