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.

Cosigner sends a signed request to the callback, which returns approve or reject
Click to open the full-size diagram

How it works

  1. Cosigner verifies the orchestrator's session_start.
  2. Cosigner POSTs an RS256 JWT to your URL (Content-Type: application/jwt).
  3. Your server verifies it, runs policy, returns 200 with an RS256 JWT.
  4. 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.

Setup

Two RSA-4096 key pairs. Only public keys are exchanged.

  • Cosigner key: Created by cosigner-cli, signs requests. Give cosigner-public.pem to your server.
  • Server key: Created on your server or in KMS, signs responses. Give server-public.pem to 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 true in production.

  • Name
    callback.url
    Type
    string
    Description

    HTTPS only. Plain HTTP allowed for host.docker.internal. /cosigner/approve is 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 default 1.

  • 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

    ECDSA or EdDSA.

  • Name
    operation
    Type
    string
    Description

    KEYGEN, SIGN, RESHARE or PING.

  • 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

    SIGN only. Exact bytes to be signed.

  • Name
    signing_input_sha256
    Type
    string (hex)
    Description

    SIGN only. SHA-256 of signing_input.

  • Name
    signing_context
    Type
    object
    Description

    SIGN only, optional. Transaction details from Fystack, max 64 KiB.

  • Name
    derivation
    Type
    object
    Description

    SIGN only, optional. ECDSA non-hardened path and delta.

  • Name
    new_threshold, new_participants
    Type
    number, array
    Description

    RESHARE only.

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.

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

    approve or reject. pong for PING.

  • Name
    reason
    Type
    string
    Description

    Required with reject, max 1024 bytes, omitted with approve. 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_unavailable and 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)
  }
})

Production checklist

  • Keep require_callback: true. cosigner-cli callback remove disables approval entirely.
  • Default deny.
  • Decode tx.raw and match it to signing_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_4096 with RSASSA_PKCS1_V1_5_SHA_256 works for RS256.
  • Respond well within the timeout.
  • Alert on callback_unavailable and on rejections.