Add a connector
Go from an API's docs to a working, published connector spec.
This walks through authoring a connector for a paginated REST API with a login exchange and a bit of enrichment — the shape most vendor APIs take.
Three ways to start
New connector (/connectors/new) offers three entry points, all ending in the
visual builder where you review, test, and publish:
- Draft with AI — paste a sample API response and Spec Assist drafts the record path, id, transform, and output columns for you.
- Import spec — paste an existing connector spec as YAML or JSON; it's validated, then opens in the builder.
- Start from scratch — configure everything by hand.
The rest of this guide shows the spec each of those produces.
1. Declare identity and credentials
Start with metadata and the connection params. Mark anything sensitive secret.
apiVersion: siphon/v1
kind: ConnectorSpec
metadata: { key: acme-crm, name: Acme CRM, version: 1.0.0 }
connectionParams:
- { key: baseUrl, type: url, required: true, default: "https://app.acme.com" }
- { key: email, type: string, required: true, secret: true }
- { key: password, type: string, required: true, secret: true }2. Wire auth
If the vendor has a login endpoint rather than a static key, use
custom_token_exchange — Siphon caches the token and re-mints it on expiry.
auth:
strategy: custom_token_exchange
request:
method: POST
url: "{{ params.baseUrl }}/api/Auth/login"
headers: { content-type: application/json }
body: { email: "{{ secrets.email }}", password: "{{ secrets.password }}" }
tokenPath: "$.accessToken"
expiresInPath: "$.expiresIn"
inject: { type: header, name: Authorization, format: "Bearer {{ token }}" }3. Fetch and paginate a resource
defaults:
baseUrl: "{{ params.baseUrl }}"
headers: { Accept: application/json }
resources:
- key: deals
canonicalEntity: Deal
sync: { mode: full }
fetch:
id: list
path: /api/Deals
pagination:
strategy: page
pageSize: 100
pageParam: PageNumber
sizeParam: PageSize
stopWhen: { path: "$.hasNextPage", equals: false }
extract: { recordsPath: "$.items", idPath: "$.id", updatedAtPath: "$.createdAt" }4. Enrich (optional)
If the list is thin, fan out detail calls. Here the list has no clientId, so it
takes a two-hop join.
enrich:
- { id: detail, dependsOn: [list], path: "/api/Deals/{{ record.id }}", extract: { dataPath: "$" }, assignTo: _detail, onError: continue }
- { id: client, dependsOn: [detail], when: "record._detail.clientId", path: "/api/Clients/{{ record._detail.clientId }}", extract: { dataPath: "$" }, assignTo: _client, onError: continue }5. Transform to canonical fields
transform:
engine: jsonata
expression: |
{
"externalId": $string(id),
"DEAL_ID": $string(id),
"CLIENT_NAME": clientName,
"CLIENT_EMAIL": _client.email,
"CREATED_AT_UTC": createdAt
}
validate: { required: [externalId], onInvalid: quarantine }
output:
fields:
- { name: DEAL_ID, type: string }
- { name: CLIENT_NAME, type: string }
- { name: CLIENT_EMAIL, type: string }
- { name: CREATED_AT_UTC, type: timestamp }6. Choose a change-detection strategy
- API can filter by
updatedSince? Usesync.mode: incrementaland template the watermark into the query. - API can't filter, records have a timestamp?
filter: client,changeDetection: watermark. - API can't filter, no timestamp?
filter: client,changeDetection: hashwith a scopedhashFields.
7. Validate, test, publish
pnpm siphon validate acme-crm.yaml
pnpm siphon run acme-crm.yaml --connection conn.json # smoke-test liveThen publish the version (immutable) and onboard a connection. Author transforms and preview them live in the Connector Builder — no YAML required.