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. Start free, register an approver passkey, and grab your API key
Start free with your email — no invite needed. You'll get an enrollment link by email; open it, choose a username, and create a resident passkey with biometrics (user verification is required). That's your first approver.
Then open your dashboard and copy an API key from Settings → API keys. Export it so the calls below can find it:
export COSIGNET_API_KEY="…" # from Dashboard → Settings → API keysJust exploring? The public demo at cosignet.com does not require an account.
-
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 confirmationurl, thepayloadHash, andexpiresAt. -
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?waitso 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 passkey signature binds to that operation.
action— human-readable label, e.g. "Deploy to production".payload— exact operation object, SHA-256 hashed and signed by the approver.approver_username— Cosignet username of the approver.wait_seconds— optional, 0–25. How long to poll before returningpending.return_url— optional https URL. Shown as a "Return to <host>" button on the approval page once the request resolves (approved/rejected/expired), so the human can get back to your app — e.g. to re-trigger an expired action. Rendered as a link only; never fetched server-side.public— optional boolean. Opt this approval into public reveal: its action and payload become readable in the transparency-log bundle and a PBKDF2 hash of the account's designated verified email (chosen in dashboard settings) is committed into the Merkle leaf as the accountable party. Permanent and irreversible; requires public reveal to be enabled for your account in dashboard settings.idempotency_key— optional dedup key. Retrying with the same key and the same action/payload returns the original approval instead of creating a duplicate; reusing a key with different parameters is an error.
Returns approved / rejected / expired / pending,
the payload hash, and the confirmation URL. See MCP approval gate for AI agents
for the recommended fail-closed integration pattern.
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" | "email" | "telegram_or_email"
"returnUrl": "https://app.example.com/approvals", // optional https; "Return to " button on the page
"public": false // optional; publish action+payload+email hash (off by default)
}
Response: { id, url, status: "pending", payloadHash, expiresAt, public }.
Send an optional Idempotency-Key header to make retries safe: a repeat
request with the same key + same action/payload returns the original confirmation
(with idempotent: true) instead of minting a duplicate or re-notifying the approver —
handy when a network blip interrupts a long-poll. Reusing a key with different parameters is a
422.
Notifications come in two kinds. Personal reach pings the
specific signer and is chosen per request via notify: telegram DMs the
approver's linked Telegram, email goes to the approver's own address — the direct email
you set on the approver in the dashboard, falling back to their verified member email, then the
account contact — and telegram_or_email tries Telegram first, then email. Team reach is a broadcast to
the whole account and is always-on when configured: set a Slack
Incoming Webhook
URL under dashboard settings and every approval request also pings that shared channel (e.g.
#approvals), independent of the per-request notify field and across both REST
and MCP. Every channel sends a link to the passkey page only — no action or payload text.
public: true opts the approval into the public transparency reveal —
its action and payload become readable in the verification bundle and a PBKDF2 hash of the
account's designated verified email (chosen in dashboard settings, as the accountable party — not
the individual signer) is committed into the log. It is permanent and irreversible, and must
first be enabled for your account in dashboard settings. See the
security page for the trust model.
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.
Inviting teammates (admin invite routes)
Once you're in, these routes let you enroll additional approvers on your account. They're protected
by X-Api-Key; hand the returned registerUrl to the teammate so they can
create their own passkey.
POST /api/invites— create an invite. Body:{ note?, maxUses?, pinnedUsername?, expiresAt? }. Returns{ code, registerUrl }.GET /api/invites— list all invite codes with usage counts.POST /api/invites/:code/revoke— immediately invalidate a code.
Public demo endpoint
POST /demo/confirmations is unauthenticated and exists only for the isolated landing demo on demo.cosignet.com. It
accepts a demo-* username, fixes the payload to
{"to":"acct_8821","amount_usd":4200,"memo":"INV-2025-118"}, 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.
Published on npm as @cosignet/sdk; source at gitlab.com/cosignet/sdk. Or skip the SDK entirely and call the REST API directly (two calls, shown above).
npm install @cosignet/sdk
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.
For outage behavior, retries, and break-glass guidance, read When Cosignet is unavailable.
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. For the full pattern, see the
agent deployment approval workflow.
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
approved— the approver signed the confirmation with a passkey.rejected— a human explicitly rejected the confirmation.expired— the TTL elapsed without a decision.
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
GET /api/webhooks— list all registered webhooks.POST /api/webhooks/:id/revoke— revoke a webhook.GET /api/webhook-deliveries?webhookId=:id— list delivery attempts. Optional filters:webhookId,confirmationId,status.
Request headers on each delivery
Cosignet-Event— the event name.Cosignet-Delivery-Id— stable ID for this webhook delivery; use for idempotency across retries.Cosignet-Timestamp— ISO 8601 UTC timestamp of the attempt.Cosignet-Signature— HMAC-SHA-256 hex over the raw request body, keyed by your webhook secret.
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.
| Attempt | Delay 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
- Required user verification (biometrics or device PIN) on every approval.
- WebAuthn phishing resistance where the RP ID and origin are enforced by the browser.
- Payload binding through the challenge construction above.
- An audit trail: the raw assertion proves that a specific enrolled credential approved the exact payload. Binding that credential to a real person and organizational role depends on your enrollment and identity-governance records.
- Replay protection: each approval consumes its one-time challenge; reuse returns
409.
Non-guarantees
- No hardware attestation for synced passkeys; a synced credential can exist on multiple devices.
- Cosignet binds approval to an action; it does not execute the action or enforce that your system runs only the approved payload.
- No absolute security guarantees are made. The signature cannot be forged without the user's device, and the action cannot be swapped after approval without breaking the binding.
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.
Ready to gate your first action?
Create a free account and we'll email your passkey enrollment link right away — no credit card.