---
title: "Approval Callback"
description: "Approve or reject every cosigner operation from your own server using RS256-signed JWTs."
url: https://docs.fystack.io/cosigner/approval-callback
last_updated: 2026-10-14
---

> ## Documentation index
> Fetch the complete documentation index at: https://docs.fystack.io/llms.txt
> Use it to discover all available pages before exploring further.

# 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.

_Diagram: Cosigner sends a signed request to the callback, which returns approve or reject_

## How it works

1. Cosigner verifies the orchestrator's `session_start`.
2. Cosigner `POST`s 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`.

> **Note:** 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. 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`.

```bash
cosigner-cli callback add ./node-callback-server/keys
```

**Remote server:**

```bash
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.

```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
```

- `approval.require_callback` (boolean): Refuse all sessions without a callback. Keep `true` in production.
- `callback.url` (string): HTTPS only. Plain HTTP allowed for `host.docker.internal`. `/cosigner/approve` is appended if no path.
- `callback.timeout` (duration): Total budget including retry. Also the request JWT expiry. Default `30s`.
- `callback.retry_count` (integer): Retries on network errors or `5xx`, same token. Max and default `1`.
- `callback.private_key` (secret provider): Cosigner RSA-4096 signing key, hex PKCS#8 DER.
- `callback.response_public_key_path` (string): Your server's RSA-4096 public key (PEM).
- `callback.ca_cert_path` (string): 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.

- `request_id` (string (UUID)): Unique per request, also `jti`. Echo it back. Use as idempotency key.
- `iat, exp` (number): `exp` = `iat` + timeout.
- `cosigner_id, workspace_id, participant_id` (string): Cosigner identity.
- `session_id, orchestrator_id` (string): Session and the orchestrator that started it.
- `coordinator_sequence, coordinator_signature` (number, string): The already-verified orchestrator signature. Log for traceability.
- `protocol` (string): `ECDSA` or `EdDSA`.
- `operation` (string): `KEYGEN`, `SIGN`, `RESHARE` or `PING`.
- `threshold, participants` (number, array): Each participant's `participant_id`, `party_key`, `identity_public_key`.
- `key_id` (string): Wallet key.
- `signing_input` (string (base64)): `SIGN` only. Exact bytes to be signed.
- `signing_input_sha256` (string (hex)): `SIGN` only. SHA-256 of `signing_input`.
- `signing_context` (object): `SIGN` only, optional. Transaction details from Fystack, max 64 KiB.
- `derivation` (object): `SIGN` only, optional. ECDSA non-hardened `path` and `delta`.
- `new_threshold, new_participants` (number, array): `RESHARE` only.

```json
{
  "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`.

> **Note:** `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.

- `request_id` (string): Must match the request.
- `action` (string): `approve` or `reject`. `pong` for `PING`.
- `reason` (string): Required with `reject`, max 1024 bytes, omitted with `approve`. Written to the audit log.
- `exp` (number): Required. Keep short, e.g. 60s.

```json
{
  "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`).

```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)
  }
})
```

> **Note:** As shipped, the example approves everything. Replace the decision logic before using real funds.

## 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.
