CCOSIGNET

Documentation

Cosignet docs

Cosignet provides Critical Action Control for automation, agents, and backend workflows. Your system requests approval for an exact action payload; an approver signs it with a passkey; the WebAuthn assertion is cryptographically bound to that payload via the challenge nonce ‖ SHA-256(payload).

Quickstart — five minutes to first approved

The activation metric for Cosignet is first approved critical action from external code: an approval request created by your own agent or script that an approver signs.

  1. 1. Get an invite and register an approver passkey

    Registration on cosignet.com is invite-only during early access. Ask an admin to create an invite code via the admin API:

    curl -s -X POST https://cosignet.com/api/invites \
      -H 'content-type: application/json' \
      -H "X-Api-Key: $COSIGNET_API_KEY" \
      --data '{"note":"alice","maxUses":1}'
    # => { "code": "…", "registerUrl": "https://cosignet.com/register?invite=…" }

    Open the registerUrl, choose a username, and create a resident passkey with biometrics. User verification is required.

    The public demo at cosignet.com does not require an account.

  2. 2. Create an approval request

    curl -s -X POST https://cosignet.com/api/confirmations \
      -H 'content-type: application/json' \
      -H "X-Api-Key: $COSIGNET_API_KEY" \
      --data '{"username":"alex","action":"Deploy to production",
        "payload":{"service":"api","sha":"abc123"}}'

    The response contains an id, a confirmation url, the payloadHash, and expiresAt.

  3. 3. Approve and read the result

    Open the url, review the action card and its SHA-256, and approve it with a passkey. Then poll — or long-poll with ?wait so the request blocks until the decision lands (no inbound webhook, port, or public IP needed; your client just keeps an outbound connection open):

    # long-poll: returns as soon as the human decides, or after `wait` seconds
    curl -s "https://cosignet.com/api/confirmations/$id?wait=25" -H "X-Api-Key: $COSIGNET_API_KEY"
    # => { "status": "approved", "payloadHash": "…", "rawAssertion": { … } }

MCP server reference

The remote MCP endpoint is https://cosignet.com/mcp over streamable HTTP. Send your API key as the X-Api-Key header.

claude mcp add --transport http cosignet https://cosignet.com/mcp \
  --header "X-Api-Key: $COSIGNET_API_KEY"

Tool: request_human_approval

Call before any irreversible, privileged, or risky action. The payload must contain the exact operation parameters so the biometric signature binds to that operation.

Returns approved / rejected / expired / pending, the payload hash, and the confirmation URL.

Tool: get_approval_status

Fetch the current status of a request by id to resume polling or audit a decision.

REST API reference

All /api/* routes require the X-Api-Key header.

POST /api/confirmations

Create an approval request.

{
  "username": "alex",            // required, an existing approver
  "action": "Deploy to production", // required, shown to the human
  "payload": { "service": "api" },  // required, exact action object
  "ttlSeconds": 180,             // optional, 1–86400
  "notify": "none"               // optional, "none" | "telegram"
}

Response: { id, url, status: "pending", payloadHash, expiresAt }.

GET /api/confirmations/:id

Read status. Response includes status, action, payloadHash, expiresAt, signedAt, credentialId, and the raw WebAuthn rawAssertion once approved. Optional query ?wait=0–25 long-polls: the request holds open until the status leaves pending or the wait elapses, so clients behind NAT/firewalls get the decision over their own outbound connection.

Admin invite routes

These routes are protected by X-Api-Key and manage invite-only registration.

Public demo endpoint

POST /demo/confirmations is unauthenticated and exists only for the landing demo. It accepts a demo-* username, fixes the payload to {"action":"demo","amount":"$0"}, and is hard rate limited per IP and globally. It cannot create confirmations for real accounts.

SDK & recipes

TypeScript SDK

A thin, zero-dependency client wraps the REST API and ships a webhook signature verifier. It runs on Node 18+, Cloudflare Workers, Deno, and browsers.

import { Cosignet } from '@cosignet/sdk';

const cosignet = new Cosignet({ apiKey: process.env.COSIGNET_API_KEY });

const decision = await cosignet.requestApproval(
  { username: 'alex', action: 'Wire transfer to vendor',
    payload: { to: 'acct_8821', amount_usd: 4200 }, notify: 'telegram' },
  { onCreated: (c) => console.log('Approve here:', c.url) },
);
// decision.status === 'approved' | 'rejected' | 'expired' | 'pending'

requestApproval long-polls over your own outbound connection, so it works behind NAT/firewalls. Verify webhooks with verifyWebhookSignature({ body, signature, secret }) — it mirrors the server's hex HMAC-SHA256 of the raw body.

The SDK lives in sdk/ in the repo; npm publish is pending. Vendor sdk/src/index.ts or build it locally for now.

Recipe: biometric approval for dangerous CLI commands

cosignet-guard.sh gates any command behind a biometric approval, sending the exact argv to a human and running it only once approved — no inbound ports needed.

COSIGNET_API_KEY=… ./cosignet-guard.sh \
  --user alex --action "terraform destroy — prod" \
  -- terraform destroy -auto-approve

Find it in recipes/cli-approval/. Works for any high-stakes command: DROP DATABASE, deleting backup buckets, force pushes.

Recipe: approval gate in CI/CD (no agent)

Block a pipeline at a prod deploy or migration and require a passkey approval bound to the exact deploy facts (commit, ref, environment, actor). The runner long-polls outbound, so it works on hosted and locked-down self-hosted runners — no inbound webhook needed.

COSIGNET_API_KEY=… COSIGNET_USER=alex ./recipes/ci-cd/gate.sh \
  --action "Deploy acme/api → production" \
  --field environment=production --field commit="$GITHUB_SHA"

Find it in recipes/ci-cd/ with ready-to-copy github-actions.yml and gitlab-ci.yml jobs.

Recipe: approval gate inside a backend service

Pause an irreversible operation in an automated service — a refund, payout, or bulk delete — until a human signs the exact parameters. Ships a dependency-free Python client (stdlib only) plus a runnable example.

decision = cosignet.request_approval(
    username="finance-ops", action="Refund customer",
    payload={"customer_id": "cus_8821", "amount_usd": 4200})
if decision["status"] != "approved": raise ApprovalError(decision["status"])

Find it in recipes/backend-service/. The same two-call REST pattern ports to Go, Ruby, Java, etc.

Webhooks

Register a URL to receive a POST callback whenever a confirmation reaches a terminal state. All webhook routes require X-Api-Key.

Events

Register a webhook

curl -s -X POST https://cosignet.com/api/webhooks \
  -H 'content-type: application/json' \
  -H "X-Api-Key: $COSIGNET_API_KEY" \
  --data '{"url":"https://your-server.example/hook","events":["approved","rejected","expired"]}'
# => { "id": "…", "url": "…", "events": […] }

List, revoke, and inspect deliveries

Request headers on each delivery

Verifying the signature

Compute HMAC-SHA-256 over the exact raw request body bytes using your webhook secret and compare it to Cosignet-Signature. Reject requests where Cosignet-Timestamp is more than a few minutes old to limit replay exposure. Use Cosignet-Delivery-Id to deduplicate retried deliveries.

import { createHmac } from 'node:crypto';

function verifyWebhook(rawBody, secret, headers) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  if (expected !== headers['cosignet-signature']) throw new Error('bad signature');
  const ts = new Date(headers['cosignet-timestamp']);
  if (Math.abs(Date.now() - ts.getTime()) > 5 * 60 * 1000) throw new Error('timestamp out of window');
}

Retry schedule

A 2xx response marks the delivery delivered. Any other status or network error triggers a retry on the following schedule. After all retries are exhausted the delivery moves to dead_letter.

AttemptDelay from previous
1 (initial)immediate
2+30 s
3+2 min
4+10 min
5+1 h
6+6 h
dead_letter

Security model

The binding

The WebAuthn authentication challenge is 16 random bytes ‖ SHA-256(payload JSON). The nonce gives replay protection; the hash binds the assertion to the exact action. What the human saw is what got signed.

Guarantees

Non-guarantees

Self-check — verify challenge binding locally

This script recomputes the payload-binding half of the challenge from a confirmation's payload and compares it to the SHA-256 reported by the API. It proves the action you fetched is the action whose hash was advertised. Full WebAuthn verification additionally requires parsing the clientDataJSON (whose challenge field must equal base64url(nonce ‖ SHA-256(payload))), checking authenticatorData RP ID hash and the UV flag, and verifying the signature against the stored credential public key — done server-side by @simplewebauthn/server.

// node selfcheck.mjs <id>   (needs COSIGNET_API_KEY)
import { createHash } from 'node:crypto';

const base = process.env.COSIGNET_BASE_URL ?? 'https://cosignet.com';
const id = process.argv[2];
const key = process.env.COSIGNET_API_KEY;

const data = await fetch(`${base}/confirm/${id}/data`).then((r) => r.json());
const status = await fetch(`${base}/api/confirmations/${id}`, {
  headers: { 'X-Api-Key': key },
}).then((r) => r.json());

// Recompute SHA-256 over the exact payload JSON returned by the server.
const localHash = createHash('sha256').update(JSON.stringify(data.payload)).digest('hex');

console.log('action       :', data.action);
console.log('payload      :', JSON.stringify(data.payload));
console.log('api hash     :', status.payloadHash);
console.log('recomputed   :', localHash);
console.log('binding match:', localHash === status.payloadHash);

// Honest limit: this checks payload→hash binding. It does not by itself verify
// the WebAuthn signature; that requires the stored credential public key and the
// full assertion (clientDataJSON, authenticatorData, signature).
if (status.rawAssertion) {
  const cdj = JSON.parse(
    Buffer.from(status.rawAssertion.response.clientDataJSON, 'base64url').toString('utf8'),
  );
  console.log('cdj type     :', cdj.type, '(expect webauthn.get)');
  console.log('cdj origin   :', cdj.origin);
}

The server stores the raw assertion exactly as received so this proof is reproducible.