Connections
11 operations under /v1/connections (GET POST DELETE PATCH PUT): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Base URL: https://api.codespar.dev
Every operation below requires a Bearer token. See Authentication.
A connection stores a provider credential (encrypted in the vault) plus its merchant config, scoped to your account and project. Connections are org-scoped resources under /v1/connections; sessions read them, they don't own them. The one session-scoped surface is the read-only list of what a given session can currently reach.
Base URL: https://api.codespar.dev
All endpoints require authentication via Bearer token. See Authentication.
POST /v1/connections
Creates a connection (or rotates the credential of an existing one for the same server_id/user_id). The secret is encrypted into the vault; it is never returned by any read endpoint.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
server_id | string | Yes | Provider identifier from the servers catalog |
secret | string | object | Yes | The credential. A string for single-secret providers; an object of named secrets for path-secret providers (each key must match the provider's declared secret names; extra or missing keys are rejected) |
display_name | string | No | Operator-facing label, max 128 chars |
user_id | string | No | End-user binding for per-user connections |
connection_metadata | object | No | Provider-specific merchant config the agent shouldn't have to know (fiscal company id, split wallet id, default customer id). Max 64 keys / 32 KB serialized. Merged into upstream calls at execute time |
Response -- 201 Created (or 200 OK on rotation)
{
"id": "conn_abc123",
"user_id": null,
"server_id": "asaas",
"auth_type": "api_key",
"status": "connected",
"display_name": "Asaas production",
"metadata": {},
"connection_metadata": { "default_customer_id": "cus_000042" },
"cert_metadata": {},
"created_at": "2026-07-01T12:00:00Z",
"connected_at": "2026-07-01T12:00:00Z",
"revoked_at": null,
"expires_at": null
}metadata is OAuth-derived provider metadata (set by callback flows); connection_metadata is your operator-supplied merchant config; cert_metadata is parsed X.509 info for cert-auth connections.
GET /v1/connections
Lists the project's connections, newest first.
Query parameters
| Parameter | Type | Description |
|---|---|---|
limit | int | Page size |
user_id | string | Filter by end-user binding |
server_id | string | Filter by provider |
status | string | pending | connected | revoked | expired |
Response -- 200 OK
{
"connections": [ { "id": "conn_abc123", "server_id": "asaas", "status": "connected", "...": "..." } ]
}GET /v1/connections/:id
Returns one connection (same shape as create). 404 not_found for unknown or cross-tenant ids. The secret is never included.
POST /v1/connections/:id/revoke
Marks the connection revoked AND purges the backing vault rows atomically — after revoke, the credential is gone at both the status gate and the vault lookup. The row itself remains for audit.
Response -- 200 OK
{ "id": "conn_abc123", "status": "revoked" }Revoking an already-revoked connection returns { "id": "...", "status": "revoked", "already": true }.
PATCH /v1/connections/:id/metadata
Updates connection_metadata (merchant config) without touching the credential. Same 64-key / 32 KB cap as create.
PUT /v1/connections/:id/webhook-secret
Sets or rotates the provider webhook signing secret bound to this connection (used to verify inbound POST /v1/webhooks/:server_id/:connection_id deliveries).
DELETE /v1/connections/:id
Hard-deletes the connection row (vault rows included). Prefer revoke in production — it preserves the audit trail.
Credential validation helpers also live here: POST /v1/connections/hmac-validate and POST /v1/connections/jwt-validate round-trip a candidate credential against the provider before you store it.
GET /v1/sessions/:id/connections
The session-scoped, read-only view: which providers this session can currently reach, with status and tool counts.
Auth required: Yes
curl example
curl https://api.codespar.dev/v1/sessions/ses_abc123/connections \
-H "Authorization: Bearer csk_live_abc123..."Response -- 200 OK
{
"data": [
{
"id": "stripe",
"name": "Stripe",
"category": "payments",
"country": "GLOBAL",
"auth_type": "api_key",
"connected": true,
"tools_count": 8,
"status": "ready"
},
{
"id": "mercado-pago",
"name": "Mercado Pago",
"category": "payments",
"country": "BR",
"auth_type": "oauth",
"connected": true,
"tools_count": 6,
"status": "ready"
}
],
"total": 2,
"session_id": "ses_abc123"
}End-user OAuth (a customer connecting their own Mercado Pago / Shopify) runs through Connect Links (POST /v1/connect/start plus a hosted callback), not through a session endpoint. See Connect Links.
Error codes
| Code | HTTP | Description |
|---|---|---|
invalid_body / invalid_query | 400 | Request didn't match the schema |
not_found | 404 | Connection unknown or cross-tenant |
keys_mismatch | 400 | Path-secret object keys don't match the provider's declared names |
Next steps
Every operation, from the spec
Generated from the published OpenAPI document, so it never drifts from what the API actually serves. The section above is written by hand and carries what a schema cannot: the object model, field rules, and the order to call things in.
GET /v1/connections
https://api.codespar.dev/v1/connectionsList the connections in this project
Active and historical both: a revoked or expired row stays for audit and is returned unless status filters it out. Newest first, capped by limit (default 50, maximum 100). The route does not paginate beyond that cap.
Scope is the calling credential's org AND project. A connection belonging to a sibling project of the same org is absent here and 404s on read, so this list and the point read agree.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | — |
server_id | string | no | — |
status | "pending" | "connected" | "revoked" | "expired" | no | — |
user_id | string | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Bad Request — the body or query did not match the schema. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
connections | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/connections \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/connections HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/connections",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/connections");{
"connections": [
{
"id": "obj_0000000000000000",
"user_id": "user_0000000000000000",
"server_id": "srv_0000000000000000",
"auth_type": "string",
"status": "pending",
"display_name": "Example",
"metadata": {},
"connection_metadata": {},
"cert_metadata": {},
"is_shared_sandbox": true,
"shared_sandbox_tools": [
"string"
],
"created_at": "2026-01-15T12:00:00.000Z",
"connected_at": "2026-01-15T12:00:00.000Z",
"revoked_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z"
}
]
}POST /v1/connections
https://api.codespar.dev/v1/connectionsRegister a provider key, or rotate the one already there
Encrypts secret and writes the connection in one transaction: either the ciphertext and the row both land, or neither does.
THIS ENDPOINT IS NOT AN INSERT. A status=connected connection for the same (org, project, server) makes the call a ROTATION: the vault entry is overwritten, display_name and connection_metadata are refreshed only when the body carries them, expires_at is cleared, and the EXISTING row comes back with 200. A first connection comes back with 201. user_id is not part of that identity, so a second team member reconnecting the same server rotates the shared credential rather than adding a second one.
secret takes two shapes and the server picks which one is legal from the catalog's auth_type. A plain string is for single-key providers. An object is for the multi-ref kinds (path_secret, cert, hmac_signed, jwt_ecdsa, two_header, cdp) and its keys must match the provider's declared refs EXACTLY: a missing or extra key is 400 path_secret_keys_mismatch with both lists, never a silent drop, because dropping one leaves the provider unusable at call time with no trace of why.
OAuth providers are refused here with 400 not_api_key_server; they go through POST /v1/connections/start. An unknown catalog id is 404 server_unknown. A provider whose catalog row declares no refs is 500 endpoint_missing_refs, which is a seed defect on our side and not a bad request.
Service-auth callers must send x-codespar-user and hold admin or above. Bearer callers are gated by possession of the key alone. The secret is never echoed back by this or any other operation.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
connection_metadata | object | no | — |
display_name | string | no | — |
secret | string | object | yes | — |
server_id | string | yes | — |
user_id | string | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
201 | object | OK |
400 | object | The body did not parse, the owner could not be resolved, the server is not key-authenticated, or the secret's shape or keys do not match what the provider declares. |
403 | object | Service auth without x-codespar-user, or with a role below admin. Or Coinbase CDP credentials while this deployment holds CDP self-serve connect (CDP_SELF_SERVE_CONNECT off, the default): cdp is refused for every caller, jwt_ecdsa for the dashboard (service auth) only. |
404 | object | No such server in the catalog. |
500 | object | The provider's catalog row declares no path-secret refs, so there is nowhere to put the values. A seeding defect, not a bad request. |
503 | object | The vault or the connection write failed. Nothing was persisted. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
auth_type | string | yes | Mirrors the catalog's auth_type at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text. |
cert_metadata | object | yes | Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. {} for every non-cert connection. |
connected_at | string,null (date-time) | yes | — |
connection_metadata | object | yes | Operator-supplied merchant config the router merges into upstream calls. {} when nothing was set. |
created_at | string (date-time) | yes | — |
display_name | string,null | yes | — |
expires_at | string,null (date-time) | yes | — |
id | string | yes | ca_-prefixed. Globally unique, visible only inside the owning org and project. |
is_shared_sandbox | boolean | yes | True for the platform's shared sandbox connection a test project is given (one operator credential that every test project uses). On such a row metadata is null, cert_metadata is {} and connection_metadata carries only the keys the project fills itself; the operator's own values are never returned. Revoking or deleting it removes it from this project only. |
metadata | object,null | yes | Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it. |
revoked_at | string,null (date-time) | yes | — |
server_id | string | yes | Catalog id of the provider this connection authenticates. |
shared_sandbox_tools | array,null | yes | On a shared sandbox row of a provider the shared-sandbox policy restricts (today Asaas and Melhor Envio), the catalog tools it can run; every other operation, and proxy_execute, is refused with shared_sandbox_operation_refused. null on a shared sandbox row of any other provider: it is not restricted by the shared-sandbox policy. [] on every connection that is not a shared sandbox row. |
status | "pending" | "connected" | "revoked" | "expired" | yes | expired includes an OAuth connection whose access token passed expires_at: nothing refreshes it yet, calls through it answer connection_expired, and the fix is to reconnect. ?status= filters on this same value. |
user_id | string | yes | — |
curl -X POST https://api.codespar.dev/v1/connections \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server_id": "srv_0000000000000000",
"secret": "string",
"display_name": "Example",
"user_id": "user_0000000000000000",
"connection_metadata": {}
}'POST /v1/connections HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"server_id": "srv_0000000000000000",
"secret": "string",
"display_name": "Example",
"user_id": "user_0000000000000000",
"connection_metadata": {}
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/connections",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"server_id": "srv_0000000000000000",
"secret": "string",
"display_name": "Example",
"user_id": "user_0000000000000000",
"connection_metadata": {}
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"server_id": "srv_0000000000000000",
"secret": "string",
"display_name": "Example",
"user_id": "user_0000000000000000",
"connection_metadata": {}
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/connections", {
body: {
server_id: "srv_0000000000000000",
secret: "string",
display_name: "Example",
user_id: "user_0000000000000000",
connection_metadata: {}
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"id": "obj_0000000000000000",
"user_id": "user_0000000000000000",
"server_id": "srv_0000000000000000",
"auth_type": "string",
"status": "pending",
"display_name": "Example",
"metadata": {},
"connection_metadata": {},
"cert_metadata": {},
"is_shared_sandbox": true,
"shared_sandbox_tools": [
"string"
],
"created_at": "2026-01-15T12:00:00.000Z",
"connected_at": "2026-01-15T12:00:00.000Z",
"revoked_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z"
}GET /v1/connections/engine/{run_id}/status
https://api.codespar.dev/v1/connections/engine/{run_id}/statusPoll a browser-driven signup run
Read-only. The run resolves only inside the calling credential's org and project, so a run id from another tenant is 404 rather than 403.
Poll this while status is running. When it becomes needs_verification the customer has to clear the provider's email gate, and verification.mechanism says how: code means a one-time code to relay, link means a link to click in their own inbox. Branch on the mechanism, never on the provider name. A paused run that has expired answers 410 run_stale and cannot be resumed.
On provisioned, connection_id names the connection the run produced, which then behaves like any other row on this resource. On failed, code is drawn from a closed first-party vocabulary and failed_step_id names the step, when the run recorded one; a failed run that recorded no reason reports its status alone. The bounded failure detail, the page URL and the screenshot references are deliberately not selected by this query, so they cannot leak from it.
The 404 body here is { error: { code, message } } with no request_id, unlike the 410 beside it.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
run_id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No run with that id inside the caller's org and project. |
410 | object | The paused run expired before it was resumed. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
code | "flow_failed" | "capacity" | "no_credential_captured" | "probe_failed" | "drive_deadline_exceeded" | "provider_account_not_registered" | "drive_stalled" | "resume_inputs_unavailable" | no | Why the run failed. A stored value outside this vocabulary is reported as absent rather than passed through, so a failed run can render as its status alone. |
connection_id | string | no | The ca_ id of the connection the run produced. Present once the run reaches provisioned. |
failed_step_id | string | no | — |
probe_result | — | no | Result of the post-provision reachability probe, when one ran. |
prompt | string | no | Present while the run waits on the customer. Fallback copy when the modal has none of its own. |
status | "running" | "needs_verification" | "provisioned" | "failed" | yes | — |
verification | object | no | Resolved from the provider's public descriptor. Present only while status is needs_verification. |
curl -X GET https://api.codespar.dev/v1/connections/engine/{run_id}/status \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/connections/engine/{run_id}/status HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/connections/engine/{run_id}/status",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/engine/{run_id}/status", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/connections/engine/{run_id}/status", {
path: {
run_id: "run_0000000000000000"
}
});{
"status": "running",
"prompt": "string",
"verification": {
"mechanism": "code",
"resend": true
},
"connection_id": "conn_0000000000000000",
"code": "flow_failed",
"failed_step_id": "failedstep_0000000000000000"
}POST /v1/connections/start
https://api.codespar.dev/v1/connections/startBegin the Connect Link OAuth flow for a provider
The entry point for providers that cannot be connected by posting a key. It mints a single-use state token, stores the caller's org, project and environment against it, and returns the provider's authorize URL with that token already embedded.
Send the user to authorize_url. The provider redirects to our callback, which exchanges the code, vaults the tokens under the project recorded at start, revokes any existing connection for the same (project, user, server) and writes the new one. The callback is not part of this document: it is unauthenticated by necessity, since the provider carries no CodeSpar credential.
link_token is the same value as the state parameter inside authorize_url; it is single use and expires at expires_at, ten minutes out. redirect_uri must be https and is where the callback sends the browser back, with status=connected&connection_id=…, status=denied&error=… or status=error&error=… appended.
POST /v1/connect/start is the same handler on a second path. A provider with no OAuth configuration is 404; a missing platform client credential is 500, which is our seeding defect and not the caller's error. Both bodies are bare { error, … }, not the error.code envelope.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
redirect_uri | string (uri) | yes | — |
scopes | string | no | — |
server_id | string | yes | — |
user_id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Bad Request — the body or query did not match the schema. |
404 | object | The provider has no OAuth configuration in the catalog. |
422 | object | The project is in environment="test" and this provider has no test venue, so the flow is refused BEFORE a state token is minted — a refused start leaves no half-begun link behind. Sending a test project to the provider's LIVE consent screen would have it authorise a real account and vault a real token under a project that is not supposed to hold one (ent#722). Not retryable; the same call from a live project succeeds. |
500 | object | The platform's OAuth client credential is not seeded for this provider. A configuration defect on our side. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
authorize_url | string (uri) | yes | Send the user here. Carries client_id, our callback as redirect_uri, the state token, response_type=code and the resolved scopes. |
expires_at | string (date-time) | yes | Ten minutes after the call. A callback arriving later is refused. |
link_token | string | yes | Single-use state token, also embedded in authorize_url. |
curl -X POST https://api.codespar.dev/v1/connections/start \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"server_id": "srv_0000000000000000",
"user_id": "user_0000000000000000",
"redirect_uri": "https://example.com/hook",
"scopes": "string"
}'POST /v1/connections/start HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"server_id": "srv_0000000000000000",
"user_id": "user_0000000000000000",
"redirect_uri": "https://example.com/hook",
"scopes": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/connections/start",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"server_id": "srv_0000000000000000",
"user_id": "user_0000000000000000",
"redirect_uri": "https://example.com/hook",
"scopes": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/start", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"server_id": "srv_0000000000000000",
"user_id": "user_0000000000000000",
"redirect_uri": "https://example.com/hook",
"scopes": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/connections/start", {
body: {
server_id: "srv_0000000000000000",
user_id: "user_0000000000000000",
redirect_uri: "https://example.com/hook",
scopes: "string"
}
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
console.log(r.data);
}{
"link_token": "string",
"authorize_url": "https://example.com/hook",
"expires_at": "2026-01-15T12:00:00.000Z"
}POST /v1/connections/subaccount/{id}/revoke
https://api.codespar.dev/v1/connections/subaccount/{id}/revokeRevoke a provisioned subaccount and try to delete it upstream
The revoke for accounts this platform minted on the customer's behalf. It is not POST /v1/connections/{id}/revoke: the generic purge targets the ref keyed on the server id, which is the wrong ref for a per-account subaccount credential, and it touches neither the provisioning record nor the account at the provider.
Local teardown first, atomically: the connection projection flips, the provisioning record flips, and the correct per-account vault ref is purged. A failure in that phase changes nothing and answers 503, so a retry is safe. Only then is a delete attempted at the provider.
READ upstream BEFORE TREATING THIS AS FINISHED. deleted means the account is gone at the provider. not_applicable means the lane has no delete to make. delete_failed and orphaned mean the credential is destroyed on our side but an account may still exist upstream, and closing it is a manual step. All four come back with 200, because the local teardown did succeed.
{id} is a connection id for the customer path, matched only inside the caller's own org and project. It is a provisioning record id for the operator-internal path, which additionally requires service auth. A customer credential can never reach an operator-internal record: it misses the connection lookup and gets 404, never a 403 that would confirm the id exists. A repeated revoke returns already: true and changes nothing.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | A ca_ connection id, or a pr_ provisioning record id on the operator-internal path. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
403 | object | This is a write, so it carries a role floor: a service-auth caller must send x-codespar-user and hold admin or above. |
404 | object | No connection or operator-internal record with that id is reachable by this caller. |
500 | object | The revoke threw unexpectedly. |
503 | object | The internal phase failed and left nothing changed. Retry. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
already | true | no | Present when the subaccount was already revoked. Nothing changed. |
connection_id | string | yes | The connection projection's id, or the provisioning record's id when there is no projection. |
record_id | string | yes | — |
status | "revoked" | yes | — |
upstream | "deleted" | "delete_failed" | "not_applicable" | "orphaned" | yes | What happened at the provider. delete_failed and orphaned mean an account may still exist there; the local credential is gone either way. |
curl -X POST https://api.codespar.dev/v1/connections/subaccount/{id}/revoke \
-H "Authorization: Bearer $CODESPAR_API_KEY"POST /v1/connections/subaccount/{id}/revoke HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.post(
"https://api.codespar.dev/v1/connections/subaccount/{id}/revoke",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/subaccount/{id}/revoke", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const r = await cs.api.response("post", "/v1/connections/subaccount/{id}/revoke", {
path: {
id: "subaccount_0000000000000000"
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"status": "revoked",
"record_id": "record_0000000000000000",
"connection_id": "conn_0000000000000000",
"upstream": "deleted",
"already": true
}GET /v1/connections/subaccount/{id}/status
https://api.codespar.dev/v1/connections/subaccount/{id}/statusRead whether a provisioned subaccount can transact yet
Read-only, with one live call to the provider. It writes nothing and changes no state.
charges_enabled is what the provider says right now, not a cached flag, which is why a provider that is unreachable answers 502 rather than a stale false. The one exception is a revoked record: it short-circuits to status: "revoked" and charges_enabled: false without calling the provider at all, since there is nothing left to poll. A confirmed account that never reached an upstream account ref reports not-yet-chargeable for the same reason.
capabilities carries the provider's per-capability activation states when it reports them, and is absent when it does not. An account can be provisioned and still not chargeable; gate any money movement on charges_enabled, not on the existence of the connection.
Same dual-id resolution and same cross-tenant 404 as the revoke beside it, minus its admin floor: a read does not need one.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | A ca_ connection id, or a pr_ provisioning record id on the operator-internal path. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No connection or operator-internal record with that id is reachable by this caller. |
500 | object | The status read threw unexpectedly. |
502 | object | The provider read did not produce a status. Usually the live call failed or timed out, and a retry is the right response. TWO OF THE CASES BEHIND THIS CODE ARE NOT TRANSIENT and retrying never clears them: the provider has no subaccount descriptor in this deployment, and no platform account credential is registered for it. Both are configuration defects on our side; error.message distinguishes them from a provider outage. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
account_ref | string | yes | The upstream account identifier. Empty string when the record never reached one. |
capabilities | object | no | Per-capability activation states as the provider reports them, for example card_payments: "active". |
charges_enabled | boolean | yes | — |
status | "provisioned" | "revoked" | yes | — |
curl -X GET https://api.codespar.dev/v1/connections/subaccount/{id}/status \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/connections/subaccount/{id}/status HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/connections/subaccount/{id}/status",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/subaccount/{id}/status", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/connections/subaccount/{id}/status", {
path: {
id: "subaccount_0000000000000000"
}
});{
"status": "provisioned",
"charges_enabled": true,
"capabilities": {},
"account_ref": "string"
}GET /v1/connections/{id}
https://api.codespar.dev/v1/connections/{id}Read one connection
404 covers three different situations and deliberately does not distinguish them: no such id, an id owned by another org, and an id owned by a sibling project of the same org. Answering 403 for the last two would confirm the id exists.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | ca_-prefixed connection id |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
auth_type | string | yes | Mirrors the catalog's auth_type at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text. |
cert_metadata | object | yes | Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. {} for every non-cert connection. |
connected_at | string,null (date-time) | yes | — |
connection_metadata | object | yes | Operator-supplied merchant config the router merges into upstream calls. {} when nothing was set. |
created_at | string (date-time) | yes | — |
display_name | string,null | yes | — |
expires_at | string,null (date-time) | yes | — |
id | string | yes | ca_-prefixed. Globally unique, visible only inside the owning org and project. |
is_shared_sandbox | boolean | yes | True for the platform's shared sandbox connection a test project is given (one operator credential that every test project uses). On such a row metadata is null, cert_metadata is {} and connection_metadata carries only the keys the project fills itself; the operator's own values are never returned. Revoking or deleting it removes it from this project only. |
metadata | object,null | yes | Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it. |
revoked_at | string,null (date-time) | yes | — |
server_id | string | yes | Catalog id of the provider this connection authenticates. |
shared_sandbox_tools | array,null | yes | On a shared sandbox row of a provider the shared-sandbox policy restricts (today Asaas and Melhor Envio), the catalog tools it can run; every other operation, and proxy_execute, is refused with shared_sandbox_operation_refused. null on a shared sandbox row of any other provider: it is not restricted by the shared-sandbox policy. [] on every connection that is not a shared sandbox row. |
status | "pending" | "connected" | "revoked" | "expired" | yes | expired includes an OAuth connection whose access token passed expires_at: nothing refreshes it yet, calls through it answer connection_expired, and the fix is to reconnect. ?status= filters on this same value. |
user_id | string | yes | — |
curl -X GET https://api.codespar.dev/v1/connections/{id} \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/connections/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/connections/{id}",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/{id}", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/connections/{id}", {
path: {
id: "conn_0000000000000000"
}
});{
"id": "obj_0000000000000000",
"user_id": "user_0000000000000000",
"server_id": "srv_0000000000000000",
"auth_type": "string",
"status": "pending",
"display_name": "Example",
"metadata": {},
"connection_metadata": {},
"cert_metadata": {},
"is_shared_sandbox": true,
"shared_sandbox_tools": [
"string"
],
"created_at": "2026-01-15T12:00:00.000Z",
"connected_at": "2026-01-15T12:00:00.000Z",
"revoked_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z"
}DELETE /v1/connections/{id}
https://api.codespar.dev/v1/connections/{id}Delete a revoked connection for good
Hard delete, allowed only once status is revoked or expired. An active connection is refused with 409 rather than deleted, because dropping the row while the credential still works would lose the audit trail and leave the vault entry with nothing pointing at it.
The purge runs inside the same transaction, before the row goes. That matters for an expired connection, which reaches here without ever having been revoked and so has never been purged; for an already-revoked one the purge is a no-op.
The 409 body is a bare { error, message } pair, not the error.code envelope the newer routes use.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | ca_-prefixed connection id |
Responses
| Status | Body | Description |
|---|---|---|
204 | — | No Content |
404 | object | Not Found |
409 | object | The connection is still active. Revoke it first. |
curl -X DELETE https://api.codespar.dev/v1/connections/{id} \
-H "Authorization: Bearer $CODESPAR_API_KEY"DELETE /v1/connections/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.delete(
"https://api.codespar.dev/v1/connections/{id}",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/{id}", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.delete("/v1/connections/{id}", {
path: {
id: "conn_0000000000000000"
}
});PATCH /v1/connections/{id}/metadata
https://api.codespar.dev/v1/connections/{id}/metadataMerge user-editable metadata into a connection
Merges fields into a connection's connection_metadata. This is a merge, not a replacement: the keys sent land on top and the rest stay.
What can be edited is decided by the row itself, by the requires_user_fields list it carries. A key outside that list is refused with fields_not_user_editable, and a connection that carries no list at all refuses EVERY patch with no_user_fields. Its path is to reconnect, not to edit.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
connection_metadata | object | yes | At least one key, and at most 32 KB serialized. The body is MERGED with what is already there, key by key. It does not replace the whole object. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The body is outside the schema (empty, a key that is too long, or above 32 KB), the connection declares no editable fields, or one of the keys is not in its list. |
404 | object | Not Found |
409 | object | The row disappeared between the read and the write: a race with a revoke or a delete. Read it again before retrying. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
auth_type | string | yes | Mirrors the catalog's auth_type at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text. |
cert_metadata | object | yes | Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. {} for every non-cert connection. |
connected_at | string,null (date-time) | yes | — |
connection_metadata | object | yes | Operator-supplied merchant config the router merges into upstream calls. {} when nothing was set. |
created_at | string (date-time) | yes | — |
display_name | string,null | yes | — |
expires_at | string,null (date-time) | yes | — |
id | string | yes | ca_-prefixed. Globally unique, visible only inside the owning org and project. |
is_shared_sandbox | boolean | yes | True for the platform's shared sandbox connection a test project is given (one operator credential that every test project uses). On such a row metadata is null, cert_metadata is {} and connection_metadata carries only the keys the project fills itself; the operator's own values are never returned. Revoking or deleting it removes it from this project only. |
metadata | object,null | yes | Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it. |
revoked_at | string,null (date-time) | yes | — |
server_id | string | yes | Catalog id of the provider this connection authenticates. |
shared_sandbox_tools | array,null | yes | On a shared sandbox row of a provider the shared-sandbox policy restricts (today Asaas and Melhor Envio), the catalog tools it can run; every other operation, and proxy_execute, is refused with shared_sandbox_operation_refused. null on a shared sandbox row of any other provider: it is not restricted by the shared-sandbox policy. [] on every connection that is not a shared sandbox row. |
status | "pending" | "connected" | "revoked" | "expired" | yes | expired includes an OAuth connection whose access token passed expires_at: nothing refreshes it yet, calls through it answer connection_expired, and the fix is to reconnect. ?status= filters on this same value. |
user_id | string | yes | — |
curl -X PATCH https://api.codespar.dev/v1/connections/{id}/metadata \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"connection_metadata": {}
}'PATCH /v1/connections/{id}/metadata HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"connection_metadata": {}
}import os
import requests
res = requests.patch(
"https://api.codespar.dev/v1/connections/{id}/metadata",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"connection_metadata": {}
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/{id}/metadata", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"connection_metadata": {}
}),
});
const data = await res.json();const result = await cs.api.patch("/v1/connections/{id}/metadata", {
path: {
id: "conn_0000000000000000"
},
body: {
connection_metadata: {}
}
});{
"id": "obj_0000000000000000",
"user_id": "user_0000000000000000",
"server_id": "srv_0000000000000000",
"auth_type": "string",
"status": "pending",
"display_name": "Example",
"metadata": {},
"connection_metadata": {},
"cert_metadata": {},
"is_shared_sandbox": true,
"shared_sandbox_tools": [
"string"
],
"created_at": "2026-01-15T12:00:00.000Z",
"connected_at": "2026-01-15T12:00:00.000Z",
"revoked_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z"
}POST /v1/connections/{id}/revoke
https://api.codespar.dev/v1/connections/{id}/revokeRevoke a connection and destroy its stored credential
Flips the row to revoked AND deletes the vault rows behind it, in one transaction. The row survives because audit needs connected_at after the credential is gone; the ciphertext does not. A revoke that could not purge fails with 503 and leaves the connection connected, which is the honest outcome: reporting revoked while the secret stays decryptable is the failure this route exists to prevent.
A vault entry is dropped only when no other still-connected row in the same org and project would resolve it. Sibling providers can share one bundle, so a shared key survives until the last connection using it is revoked.
TWO DIFFERENT 200 BODIES. A revoke that did work returns the full connection row. A connection already revoked returns the short { id, status, already } form and touches nothing, so a retry is safe. Branch on the presence of already, not on the status code.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | ca_-prefixed connection id |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | object | OK |
404 | object | Not Found |
503 | object | The atomic flip and purge failed. Nothing changed; the connection is still usable. Retry. |
curl -X POST https://api.codespar.dev/v1/connections/{id}/revoke \
-H "Authorization: Bearer $CODESPAR_API_KEY"POST /v1/connections/{id}/revoke HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.post(
"https://api.codespar.dev/v1/connections/{id}/revoke",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/{id}/revoke", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.post("/v1/connections/{id}/revoke", {
path: {
id: "conn_0000000000000000"
}
});{
"id": "obj_0000000000000000",
"user_id": "user_0000000000000000",
"server_id": "srv_0000000000000000",
"auth_type": "string",
"status": "pending",
"display_name": "Example",
"metadata": {},
"connection_metadata": {},
"cert_metadata": {},
"is_shared_sandbox": true,
"shared_sandbox_tools": [
"string"
],
"created_at": "2026-01-15T12:00:00.000Z",
"connected_at": "2026-01-15T12:00:00.000Z",
"revoked_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z"
}PUT /v1/connections/{id}/webhook-secret
https://api.codespar.dev/v1/connections/{id}/webhook-secretSeed or rotate the secret that verifies this provider's inbound webhooks
The value a provider signs its callbacks with (a Stripe signing secret, an Asaas access token, a Zoop Basic blob). The vault ref is derived from the provider internally, so a caller never has to know the naming convention.
Write-only: the plaintext is accepted once and no operation returns it. updated is the only thing the response says about the previous state, and it says exactly one thing: true when a secret was already stored under that ref, false when this call seeded the first one.
A provider with no inbound webhook adapter is refused with 400 provider_has_no_inbound_webhooks, since a secret nothing will ever verify is worse than no secret at all. This write is not transactional with anything else: a 503 means the vault write failed and the old secret, if any, is still in place.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | ca_-prefixed connection id |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
secret | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The body did not parse, or the provider has no inbound webhook adapter in this API. |
404 | object | Not Found |
503 | object | The vault write failed. Any previously stored secret is untouched. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
connection_id | string | yes | — |
server_id | string | yes | — |
updated | boolean | yes | True when a secret already existed under this ref and was replaced; false when this call seeded the first one. |
curl -X PUT https://api.codespar.dev/v1/connections/{id}/webhook-secret \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"secret": "string"
}'PUT /v1/connections/{id}/webhook-secret HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"secret": "string"
}import os
import requests
res = requests.put(
"https://api.codespar.dev/v1/connections/{id}/webhook-secret",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"secret": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/connections/{id}/webhook-secret", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"secret": "string"
}),
});
const data = await res.json();const result = await cs.api.put("/v1/connections/{id}/webhook-secret", {
path: {
id: "conn_0000000000000000"
},
body: {
secret: "string"
}
});{
"connection_id": "conn_0000000000000000",
"server_id": "srv_0000000000000000",
"updated": true
}Sessions
12 operations under /v1/sessions (GET POST DELETE PATCH): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Connect
1 operation under /v1/connect/start (POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.