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:
- A manifest your service serves at
GET {baseUrl}/manifest, describing its config fields and operations. - A single HTTP endpoint (
{baseUrl}itself) that handles every operation call, including the reservedvalidateoperation.
See cmd/echo-connector
in the O2ID repository for a complete, runnable reference implementation
of both.
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"
}
| Field | Required | Description |
|---|---|---|
protocolVersion | No | Must be "1" if present — the only version this build speaks |
category | Yes | Free-form grouping, e.g. identity-verification, sms-provider |
displayName | Yes | Shown in o2idctl connections types / GET /connections/meta |
description | No | |
configSchema | No | Fields a connection of this type is configured with — same shape as a compiled-in connector's, see Managing Connections |
operations | No | Operation names your endpoint accepts, beyond the reserved validate (see below) |
capabilities | No | Well-known capabilities you're claiming — see Capabilities |
secretsMode | No | "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"}}
| Field | Description |
|---|---|
config | The calling connection's non-secret config, per your configSchema |
secrets | The calling connection's decrypted secret fields — omitted entirely when secretsMode is "none" |
input | Operation-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:
| Capability | Required operations | Used by |
|---|---|---|
identity-provider | authorize, exchange | A connection flow node (federated login) |
message-sender | send | The SMS/email OTP authenticators |
log-publisher | publish | Remote 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 insecrets. Right for a connector you also operate."none":secretsis 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 ofinputon aconnection_callnode, or byconfig). 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.