Skip to main content
Version: Next

Connection Types

A connection type backed by a remote HTTP service lets you add a new integration to O2ID (send SMS through a vendor, verify a document, look up a value in an external system, federate login through a provider O2ID doesn't ship) without forking or rebuilding O2ID. O2ID calls out to your service over HTTP for every operation; you never see O2ID's source or database.

Two things make up a connection type:

  1. A manifest your service serves at GET {baseUrl}/manifest, describing its config fields and operations.
  2. A single HTTP endpoint ({baseUrl} itself) that handles every operation call, including the reserved validate operation.

See cmd/echo-connector in the O2ID repository for a complete, runnable reference implementation of both.

note

Registering a connection type requires a role with the connectiontypes:read/connectiontypes:create/connectiontypes:update/ connectiontypes:delete scopes — see Managing Roles. A connection type is owned by the tenant that registers it, exactly like a connection itself: usable (and visible) only there. Its type key only needs to be unique within your own tenant — a different tenant can register its own connection type under the same type name without colliding with yours.

The manifest

{
"protocolVersion": "1",
"category": "identity-verification",
"displayName": "Acme IDV",
"description": "Verifies documents through Acme.",
"configSchema": [
{ "key": "apiKey", "label": "API Key", "type": "string", "secret": true, "required": true }
],
"operations": [
{ "name": "verify", "description": "Verify a document.", "idempotent": false }
],
"capabilities": [],
"secretsMode": "send"
}
FieldRequiredDescription
protocolVersionNoMust be "1" if present — the only version this build speaks
categoryYesFree-form grouping, e.g. identity-verification, sms-provider
displayNameYesShown in o2idctl connections types / GET /connections/meta
descriptionNo
configSchemaNoFields a connection of this type is configured with — same shape as a compiled-in connector's, see Managing Connections
operationsNoOperation names your endpoint accepts, beyond the reserved validate (see below)
capabilitiesNoWell-known capabilities you're claiming — see Capabilities
secretsModeNo"send" (default) or "none" — see Secrets mode

Each configSchema entry: key, label, type (string/number/bool/select), secret (encrypted at rest, never sent back to a client), required, options (for select), default.

Each operations entry: name, description, input/output (each an optional configSchema-shaped array, documentation only — O2ID doesn't validate against them), idempotent (see Retries).

Registering

o2idctl connection-types create --type acme-idv --base-url https://acme.example.com/connector --fetch-manifest

O2ID fetches and pins your manifest — it's never re-fetched implicitly. The response includes a sharedSecret in plaintext, shown exactly once: configure your service with it immediately, since O2ID never shows it again.

{
"connectionType": { "id": "...", "type": "acme-idv", "...": "..." },
"sharedSecret": "9f8e7d6c5b4a... "
}

Changed your manifest since registering? o2idctl connection-types refresh <id> re-fetches it and applies whatever changed — response includes both the previous and current state so you can see the diff. Nothing changes implicitly; a refresh only happens when you ask for one.

No manifest endpoint? Register with the schema spelled out explicitly instead:

o2idctl connection-types create --type acme-idv --base-url https://acme.example.com/connector \
--category identity-verification --display-name "Acme IDV" \
--config-field apiKey:string:required:secret \
--operation verify

Once registered, a connection type works exactly like a compiled-in one, within your own tenant: o2idctl connections create --type acme-idv ... creates a configured instance of it, holding your configSchema's fields.

The invoke endpoint

O2ID calls POST {baseUrl} for every operation, including validate (what o2idctl connections test calls) and whatever you declared. Full example request:

POST /connector HTTP/1.1
Content-Type: application/json
X-O2ID-Protocol-Version: 1
X-O2ID-Operation: verify
X-O2ID-Timestamp: 1735689600
X-O2ID-Idempotency-Key: 4f3c2b1a...
X-O2ID-Signature: 8a1b2c3d...

{"config": {"apiKey": "..."}, "secrets": {}, "input": {"documentId": "doc-123"}}
FieldDescription
configThe calling connection's non-secret config, per your configSchema
secretsThe calling connection's decrypted secret fields — omitted entirely when secretsMode is "none"
inputOperation-specific input — for a connection_call flow node, whatever properties.input evaluated to (see Flow Definition Reference)

Respond 2xx with a JSON object — its fields become the operation's output (e.g. step.<nodeId>.result in a flow, or {"valid": true} from connections test for validate). Respond non-2xx (optionally with {"error": "message"}) to fail the call.

Verifying the signature

Recompute the HMAC-SHA256 over protocolVersion + "." + timestamp + "." + operation + "." + body under your shared secret, and compare to X-O2ID-Signature (hex-encoded). Binding the timestamp and operation into the signature — not just the body — means a captured request can't be replayed indefinitely, and a body valid for one operation can't be replayed as another.

You are responsible for freshness checking: reject any request whose X-O2ID-Timestamp (Unix seconds) is more than a few minutes from your own clock, in either direction. O2ID signs the timestamp; it does not enforce a window on your behalf.

Retries

X-O2ID-Idempotency-Key is a random value, unique per logical call — identical across the original attempt and its retry. O2ID retries an operation once, only on a transport-level failure (connection refused, timeout — never a non-2xx response), and only when you declared it "idempotent": true in your manifest. Use the idempotency key to dedupe if your operation has a side effect (e.g. sending an SMS).

Capabilities

A capability lets O2ID's own internal call sites (authenticators, the connection flow node, log delivery) address your connector through a typed Go interface instead of only the generic connection_call node. Declaring one requires declaring the operations it needs — checked at registration time:

CapabilityRequired operationsUsed by
identity-providerauthorize, exchangeA connection flow node (federated login)
message-sendersendThe SMS/email OTP authenticators
log-publisherpublishRemote log delivery (see Managing Logs)

authorize receives {state, nonce, redirectUri} and must return {"url": "..."}. exchange receives {code, nonce, redirectUri} and must return {"subject": "...", "email": "...", "emailVerified": bool, "name": "...", "claims": {...}} (subject required). send receives {to, body}. publish receives a log entry's fields directly as the input object.

A connection type declaring no capabilities is still fully usable — from a connection_call flow node, by any operation name it declares.

Secrets mode

  • "send" (default): every call includes the connection's decrypted secrets in secrets. Right for a connector you also operate.
  • "none": secrets is omitted; your service holds its own credentials out of band, keyed however you like (e.g. by connection ID, which you can request as part of input on a connection_call node, or by config). Use this for a third-party-operated connector you don't fully trust with tenant credentials.

API Reference

See the API Reference for the full request/response schemas.