Approval Callback
Before joining any session, the cosigner sends a signed request to your server and waits for a signed approve. Your server gets the raw bytes to be signed, so it can verify the transaction independently of Fystack.

How it works
- Cosigner verifies the orchestrator's
session_start. - Cosigner
POSTs an RS256 JWT to your URL (Content-Type: application/jwt). - Your server verifies it, runs policy, returns
200with an RS256 JWT. - Cosigner verifies the response and the
request_id, then joins the session.
Requests are sent for KEYGEN, SIGN and RESHARE. Apply transaction policy to SIGN.
The cosigner fails closed. Reject, non-200, redirect, timeout, oversized body, bad signature or wrong request_id all mean no signature. The session fails with CALLBACK_REJECTED.
Setup
Two RSA-4096 key pairs. Only public keys are exchanged.
- Cosigner key: Created by
cosigner-cli, signs requests. Givecosigner-public.pemto your server. - Server key: Created on your server or in KMS, signs responses. Give
server-public.pemto the cosigner.
Same host: point at the server's key folder. The CLI reads server-public.pem and writes cosigner-public.pem. Default URL is http://host.docker.internal:8089/cosigner/approve.
cosigner-cli callback add ./node-callback-server/keys
Remote server:
cosigner-cli callback add https://risk.example.com/cosigner/approve \
--server-public-key ./server-public.pem
The CLI prints the path and fingerprint of cosigner-public.pem; install it on your server. Add --ca ./ca.pem for a private CA.
callback add updates config.yaml, sets require_callback: true and restarts the cosigner (skip with --no-restart). cosigner-cli info shows the current URL and fingerprint.
config.yaml
approval:
require_callback: true
callback:
url: https://risk.example.com/cosigner/approve
timeout: 30s
retry_count: 1
private_key:
type: file
config:
path: /run/secrets/cosigner-private.der.hex
response_public_key_path: /run/secrets/server-public.pem
# ca_cert_path: /run/secrets/callback-ca.pem
- Name
- approval.require_callback
- Type
- boolean
- Description
Refuse all sessions without a callback. Keep
truein production.
- Name
- callback.url
- Type
- string
- Description
HTTPS only. Plain HTTP allowed for
host.docker.internal./cosigner/approveis appended if no path.
- Name
- callback.timeout
- Type
- duration
- Description
Total budget including retry. Also the request JWT expiry. Default
30s.
- Name
- callback.retry_count
- Type
- integer
- Description
Retries on network errors or
5xx, same token. Max and default1.
- Name
- callback.private_key
- Type
- secret provider
- Description
Cosigner RSA-4096 signing key, hex PKCS#8 DER.
- Name
- callback.response_public_key_path
- Type
- string
- Description
Your server's RSA-4096 public key (PEM).
- Name
- callback.ca_cert_path
- Type
- string
- Description
CA certificate for a private or self-signed server certificate.
Request
JWT header {"alg": "RS256", "typ": "JWT"}. Accept only RS256 and verify against cosigner-public.pem before reading claims.
- Name
- request_id
- Type
- string (UUID)
- Description
Unique per request, also
jti. Echo it back. Use as idempotency key.
- Name
- iat, exp
- Type
- number
- Description
exp=iat+ timeout.
- Name
- cosigner_id, workspace_id, participant_id
- Type
- string
- Description
Cosigner identity.
- Name
- session_id, orchestrator_id
- Type
- string
- Description
Session and the orchestrator that started it.
- Name
- coordinator_sequence, coordinator_signature
- Type
- number, string
- Description
The already-verified orchestrator signature. Log for traceability.
- Name
- protocol
- Type
- string
- Description
ECDSAorEdDSA.
- Name
- operation
- Type
- string
- Description
KEYGEN,SIGN,RESHAREorPING.
- Name
- threshold, participants
- Type
- number, array
- Description
Each participant's
participant_id,party_key,identity_public_key.
- Name
- key_id
- Type
- string
- Description
Wallet key.
- Name
- signing_input
- Type
- string (base64)
- Description
SIGNonly. Exact bytes to be signed.
- Name
- signing_input_sha256
- Type
- string (hex)
- Description
SIGNonly. SHA-256 ofsigning_input.
- Name
- signing_context
- Type
- object
- Description
SIGNonly, optional. Transaction details from Fystack, max 64 KiB.
- Name
- derivation
- Type
- object
- Description
SIGNonly, optional. ECDSA non-hardenedpathanddelta.
- Name
- new_threshold, new_participants
- Type
- number, array
- Description
RESHAREonly.
Decoded SIGN request
{
"request_id": "3f1c2a8e-7b0d-4d0c-9a51-2f6c4f0e9b12",
"operation": "SIGN",
"protocol": "ECDSA",
"key_id": "8b0f3c4e-...",
"signing_input": "q83vEjRWeJA...",
"signing_input_sha256": "5d41402abc4b2a76...",
"signing_context": {
"version": 1,
"apex_request_id": "wd_01J9...",
"reference": { "type": "onchain_tx", "id": "..." },
"requested_at": "2026-10-12T08:30:00Z",
"chain": { "network": "ethereum", "chain_id": "1", "protocol": "ECDSA" },
"wallet": { "wallet_id": "8b0f3c4e-...", "derivation_path": [44, 60, 0, 0, 0] },
"tx": {
"raw": "0x02f8...",
"payload_hash": "0x5d41402a...",
"verified": true,
"verification_mode": "evm_transaction",
"decoded": { "to_address": "0xAbC...", "value": "1000000000000000000" }
}
},
"threshold": 1,
"participants": [ ... ]
}
verification_mode: evm_transaction, solana_message, tron_transaction, bitcoin_transaction or digest_only.
signing_context is a hint, not proof. The cosigner does not check it against signing_input. Decode tx.raw yourself, recompute the hash, compare with signing_input, then check destination, amount and risk. Reject what you can't verify.
Response
HTTP 200, Content-Type: application/jwt, RS256 with your server key. Max 64 KiB. Redirects are not followed.
- Name
- request_id
- Type
- string
- Description
Must match the request.
- Name
- action
- Type
- string
- Description
approveorreject.pongforPING.
- Name
- reason
- Type
- string
- Description
Required with
reject, max 1024 bytes, omitted withapprove. Written to the audit log.
- Name
- exp
- Type
- number
- Description
Required. Keep short, e.g. 60s.
Response payload
{
"request_id": "3f1c2a8e-7b0d-4d0c-9a51-2f6c4f0e9b12",
"action": "reject",
"reason": "destination_not_allowlisted",
"iat": 1791794400,
"exp": 1791794460,
"jti": "b7e5..."
}
Liveness
The cosigner sends "operation": "PING" at startup and every 30 seconds. Reply "action": "pong" without running policy.
- Startup ping fails: the cosigner stops.
- Later ping fails: health shows
callback_unavailableand signing is rejected until it recovers.
Example server
cosigner-cli ships a Node.js example in examples/node-callback-server (express + jose).
server.js
app.post('/cosigner/approve', express.text({ type: 'application/jwt' }), async (req, res) => {
try {
// RS256 only, signed by the pinned cosigner key, with exp and request_id.
const request = await verifier.verify(req.body.trim())
if (request.operation === 'PING') {
return res.type('application/jwt').send(await signer.sign(request, { action: 'pong' }))
}
let decision = { action: 'approve' }
if (request.operation === 'SIGN' && !(await policyAllows(request))) {
decision = { action: 'reject', reason: 'destination_not_allowlisted' }
}
res.type('application/jwt').send(await signer.sign(request, decision))
} catch (err) {
res.sendStatus(401)
}
})
As shipped, the example approves everything. Replace the decision logic before using real funds.
Production checklist
- Keep
require_callback: true.cosigner-cli callback removedisables approval entirely. - Default deny.
- Decode
tx.rawand match it tosigning_input. - Enforce allowlists, limits and AML checks.
- Be idempotent by
request_id; retries resend the same token. - Keep the server key in KMS or an HSM. AWS KMS
RSA_4096withRSASSA_PKCS1_V1_5_SHA_256works for RS256. - Respond well within the timeout.
- Alert on
callback_unavailableand on rejections.