> ## Documentation Index
> Fetch the complete documentation index at: https://v2.docs.conduit.financial/llms.txt
> Use this file to discover all available pages before exploring further.

# Programmatic payout signing (machine signers)

> Let your backend approve non-custodial payouts with a server-held P-256 key - discover the requests over the API, stamp them, and submit the stamp to Conduit. No browser, no human, no gas to manage.

A **machine signer** lets your backend join a non-custodial wallet's signing quorum the same way a human signer does - by producing a cryptographic approval - but with a server-held P-256 key instead of a passkey. Your backend discovers the payouts awaiting its signature, stamps each one, and submits the stamp to Conduit. Conduit verifies the stamp, records your vote, and moves the payout forward.

This guide covers the signing loop only — wallet provisioning and the valid roster shapes per signing mode are in [Add a Crypto Wallet](/guides/add-crypto-wallet); the concepts behind rosters and quorums live in [Multi-signer wallets](/concepts/multi-signer-wallets) and [Non-Custodial Wallets](/concepts/non-custodial-wallets).

<Note>
  **Test this flow in sandbox first.** Drive it end-to-end with simulated money
  and deterministic controls - start with the [sandbox
  quickstart](/sandbox/quickstart), then the [multi-signer
  recipes](/sandbox/multi-signer-wallets), and the [cheat
  sheet](/sandbox/cheat-sheet) for every magic value.
</Note>

## Two keys, two jobs

Programmatic signing involves two separate credentials. Keeping them distinct is the whole mental model:

| Credential              | What it is                                                                              | What it authorizes                                  | Where it goes                                                                                                                                                                                                |
| ----------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Conduit API key**     | The bearer key on your HTTP requests (`x-api-key`).                                     | Your organization - that these API calls are yours. | Sent on every request, as usual.                                                                                                                                                                             |
| **Machine signing key** | A P-256 (secp256r1) keypair **you generate and hold** in your own tooling, KMS, or HSM. | A single signer's vote on a payout.                 | **The private key never leaves your infrastructure.** You register only the public half, and that public half also rides inside every stamp. The private half is never sent, stored, or returned by Conduit. |

Your API key gets you in the door; your signing key casts the vote. A stamp submitted with a valid API key but an unregistered signing key is still rejected, and vice versa.

<Note>
  **Self-custody of the signing key.** You generate the machine signing keypair
  yourself and keep the private half. **Conduit never generates, stores,
  transmits, or returns your private key** - there is no "get your signing key
  from Conduit" step, and no field in any request or response carries a private
  key. Conduit only ever learns the public half, which you hand it once at
  enrollment. This is what makes a machine signer *your* signer: only you can
  produce a valid stamp.
</Note>

## Who can sign, and what Conduit always keeps

Whether a wallet accepts machine stamps is set by Conduit, out of band, as the customer's **signing mode**: an org-wide default with an optional per-customer override (the customer-level setting wins). There is nothing to toggle in the API - arrange it with your Conduit representative.

| Mode                        | Who signs, who governs                                                    | Machine signers                                                  |
| --------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| **Passkey required**        | Humans sign, humans govern.                                               | Not accepted. Payouts never appear in the signing-requests API.  |
| **Programmatic**            | Machines sign; a passkey signer may also approve. Humans govern.          | Signer keys allowed; wallet admins are still human passkeys.     |
| **Programmatic unattended** | Machines sign and govern; a passkey signer, if present, may also approve. | Machine keys allowed as admins too - a fully server-driven path. |

<Note>
  **A passkey signer on a programmatic wallet can also approve payouts.** A
  wallet in a programmatic mode is not machines-only by definition: if its
  roster has a human passkey signer, that person is a member of the signing
  quorum and may approve a payout on the Conduit-hosted verify page, exactly as
  in passkey-required mode. When such a wallet has a payout awaiting signature,
  `transaction.awaiting_signature` carries a `verificationUrl` (the human link)
  **alongside** the `signingRequestId` (the machine handle), and the same
  payout's quorum can be met by any mix of machine stamps and human approvals.
  This human vote is optional — your machine signers still have to be able to
  reach the threshold on their own — so use it for "the bot signs routine
  payouts, a person approves the large ones." A wallet whose programmatic roster
  has no passkey signer emits only the `signingRequestId`.
</Note>

**Unattended mode is not enabled on request alone.** Because it puts no human on your side of the quorum, Conduit enables it per customer only after an additional approval review. Plan for that review before you build against it; the other programmatic mode has no such gate.

<Warning>
  **Conduit is always a required co-approver, in every mode.** Reaching your own
  signing threshold is necessary but never sufficient: Conduit's compliance
  approval is the final vote on every withdrawal, and it is cast last. Your
  machines can never move money on their own, and Conduit can never move it
  without you - neither side acts alone. "Programmatic unattended" removes the
  human from *your* side of the quorum; it does not remove Conduit's compliance
  check.
</Warning>

**No gas to manage.** Network fees on non-custodial wallets are sponsored for you - the wallet never needs to hold a native-gas balance, and you never fund one.

## 1. Generate a machine signing keypair

**You generate the keypair; Conduit only ever learns the public half.** Generate a P-256 (secp256r1) keypair in your own tooling, KMS, or HSM and keep the private half in your secret store. There is no step where Conduit issues you a signing key - the key originates on your side, and the private half never leaves your infrastructure (never sent, never stored by us, never returned by any endpoint).

A machine signer is exactly that - a *machine*. Your system holds the private key and stamps approvals programmatically; unlike a human passkey signer, nobody receives an email or opens a page to enroll or to approve.

The public key you register must be in **compressed SEC1 form**: 33 bytes, hex-encoded (66 hex characters), starting with `02` or `03`. An optional `0x` prefix is accepted.

```ts theme={null}
import crypto from "node:crypto";

export function generateSignerKeypair() {
  const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
    namedCurve: "prime256v1",
  });
  const jwk = publicKey.export({ format: "jwk" });
  const x = Buffer.from(jwk.x!, "base64url");
  const y = Buffer.from(jwk.y!, "base64url");
  const prefix = (y[y.length - 1] & 1) === 1 ? 0x03 : 0x02;

  return {
    // Register this with Conduit.
    compressedPublicKeyHex: Buffer.concat([Buffer.from([prefix]), x]).toString(
      "hex",
    ),
    // Store this in your secret manager. Never log it, never send it.
    privateKeyPem: privateKey.export({
      format: "pem",
      type: "pkcs8",
    }) as string,
  };
}
```

## 2. Register the public key

Include the machine signer in the roster when you claim the wallet, **or** add it to an existing roster. You provide **only the compressed public key** - `credentialType: "api_key"` plus `publicKey` (the 33-byte compressed form from step 1). There is no private-key field on this or any endpoint; the private half stays with you. Conduit registers that public key as the signer's machine credential. A machine signer never has an enrollment link of its own (that flow is only for human passkey signers) — but how it becomes active depends on *when* you add it:

* **At claim time** the machine signer seats **`active` inline**: it is live as soon as the claim completes, with no approval step.
* **Adding one to an already-active roster** is a roster change your existing admins must co-approve. The response always returns an **admin approval link** (`verificationUrl` with `urlAudience: "admin"`, also delivered on the `wallet_ceremony.awaiting_admin_approval` webhook), and the new machine signer stays pending until an admin co-approves it; it goes `active` only once that co-approval clears.
* **When the roster carries an active `api_key` admin**, the same response ADDS a **`machineApproval`** object alongside that link and webhook — a shortcut so you can approve without a human opening the link. `machineApproval` carries a `token` and the `approvalMaterial` to stamp. You stamp that material with one of your **active `api_key` admin** keys exactly as in step 4, then POST it to `/v2/verifications/{token}/complete` to activate the new signer — the same stamp-and-approve step every machine governance ceremony uses. (A roster with only passkey admins gets the link and webhook but no `machineApproval` — there is no machine key to stamp with.)

  ```json theme={null}
  // POST /v2/customers/:id/wallet-signers  →  201
  {
    "id": "wsg_...", "status": "pending_activation",
    "verificationUrl": "https://app.conduit.financial/verify/vtok_...",
    "urlAudience": "admin",
    "machineApproval": {
      "token": "vtok_...",
      "approvalMaterial": {
        "activityId": "...", "fingerprint": "...", "subOrganizationId": "..."
      }
    }
  }

  // Stamp approvalMaterial with an active api_key ADMIN key (step 4), then:
  // POST /v2/verifications/{token}/complete
  {
    "method": "api_key",
    // credential is a JSON string carrying your stamped body, the stamp, and
    // approvalMaterial.subOrganizationId (same envelope as the payout approve).
    "credential": "{\"signedBody\":\"<exact body>\",\"stamp\":\"<base64url stamp>\",\"organizationId\":\"<subOrganizationId>\"}"
  }
  //  →  the ROSTER_ADD ceremony reaches quorum and the new signer goes `active`.
  ```

  Two-party control still holds: your admin's stamp plus Conduit's own compliance stamp both apply the change. The stamp must come from an **`active` `api_key` `admin`** on this roster — a non-admin or wrong-scheme stamp is rejected by the same admissibility gate the payout approve path uses.

```json theme={null}
POST /v2/customers/:id/wallets/claim-non-custodial
{
  "roster": [
    { "email": "alice@yourcompany.com", "role": "admin", "credentialType": "passkey" },
    { "email": "bob@yourcompany.com",   "role": "admin", "credentialType": "passkey" },
    { "email": "signer-1@yourcompany.com", "role": "signer", "credentialType": "api_key", "publicKey": "02a1b2c3..." },
    { "email": "signer-2@yourcompany.com", "role": "signer", "credentialType": "api_key", "publicKey": "03d4e5f6..." }
  ],
  "signingThreshold": 2,
  "chains": ["ethereum"]
}
```

A machine signer has no passkey enrollment step: it becomes `active` as soon as the claim completes, and `wallet_signer.added` fires. The roster still has to satisfy your customer's signing mode - in **programmatic** mode a machine member must be `role: "signer"` and every admin must be a passkey; a mismatch is rejected with `SIGNING_MODE_ROSTER_INVALID`. Your active machine signers also have to be able to reach the signing threshold on their own, or the claim is rejected with `PROGRAMMATIC_QUORUM_UNREACHABLE`.

Errors specific to registering the machine public key:

| Code                           | When                                                                           | What to do                                                                              |
| ------------------------------ | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| `API_KEY_PUBLIC_KEY_REQUIRED`  | `credentialType: "api_key"` submitted without `publicKey`                      | Include `publicKey` in the member.                                                      |
| `API_KEY_PUBLIC_KEY_INVALID`   | `publicKey` is not a valid P-256 compressed hex key                            | Provide a 66-char hex string starting with `02` or `03`.                                |
| `API_KEY_PUBLIC_KEY_DUPLICATE` | Two `api_key` members in the same request carry the same `publicKey`           | Give each machine signer its own unique public key, or drop the duplicate roster entry. |
| `API_KEY_PUBLIC_KEY_IN_USE`    | The `publicKey` is already held by another active machine signer on the roster | Add the signer with a public key that is not already registered on the roster.          |
| `CEREMONY_IN_FLIGHT`           | Another roster change is pending for this customer                             | Retry after the pending roster change clears.                                           |

## 3. Discover payouts awaiting your signature

**Event-driven (recommended): react to the `transaction.awaiting_signature` webhook.** When a programmatic-mode payout reaches the signing step, Conduit fires `transaction.awaiting_signature` carrying a **`signingRequestId`** (plus `signingMode`; a `verificationUrl` rides along only when the wallet's roster has an active passkey signer, and is omitted for a machine-only roster). That event is your trigger — on receipt, fetch that one request with `GET /v2/signing-requests/{signingRequestId}` (see the note below), build the stamp (step 4), and submit. You do not need to poll for discovery. Subscribe to the exact event `transaction.awaiting_signature` (selected-mode subscriptions match exact event names; a wildcard like `transaction.*` is rejected), or subscribe in `all` mode, on a [webhook endpoint](/webhooks).

`GET /v2/signing-requests` (the list) is the **reconciliation** view — use it for catch-up on startup, or if you don't consume webhooks. It returns the open payouts for your organization that are still collecting customer approvals - pending, non-expired, and short of their required approvals. A payout that has already collected quorum, expired, or belongs to a passkey-required wallet never appears here. It is an organization-wide feed keyed to the payout's state, not to your key: a request you have already stamped stays listed until the full quorum is met, so treat the list as "still collecting approvals," not "not yet stamped by you." Re-submitting a stamp you already sent is a safe no-op that returns the request's current state, so acting on a still-listed request you already approved does no harm.

```bash theme={null}
GET /v2/signing-requests
x-api-key: {{apiKey}}
```

```json theme={null}
{
  "data": [
    {
      "id": "vrf_9c8b...",
      "transactionId": "txn_5f2a...",
      "customerId": "cus_1a2b...",
      "status": "awaiting_signature",
      "requiredApprovals": 2,
      "approvedCount": 1,
      "expiresAt": "2026-07-01T12:00:00.000Z",
      "createdAt": "2026-07-01T11:30:00.000Z",
      "approvalMaterial": {
        "version": "1",
        "activityId": "act_...",
        "fingerprint": "fp_...",
        "subOrganizationId": "sub-org-...",
        "outbound": {
          "toAddress": "0xrecipient...",
          "assetAmount": {
            "code": "USDC",
            "chain": "ethereum",
            "amount": "100.000000"
          }
        }
      }
    }
  ],
  "meta": {
    "mode": "cursor",
    "nextCursor": null,
    "previousCursor": null,
    "total": 1
  }
}
```

The `id` addresses the request in the next step, and everything you need to build the stamp is in `approvalMaterial`. Responses are never cached (`Cache-Control: no-store`), so each fetch returns current state. Use `outbound.toAddress` and `outbound.assetAmount` to confirm you are approving the payout you expect before you stamp it.

<Note>
  `GET /v2/signing-requests/{id}` returns a single request by its `id` and the
  same shape. Use it to confirm one payout's `status` after you submit, when you
  need a synchronous read; routine progress arrives on the webhook. An `id` that
  isn't one of your requests returns `404 SIGNING_REQUEST_NOT_FOUND` - the same
  response a request in another account would give, so a 404 never reveals
  whether an id exists elsewhere.
</Note>

## 4. Build the stamp and submit it

For each request, build the exact activity body from its `approvalMaterial`, sign that body with your machine key, and `POST` the body plus the stamp back to Conduit.

The body and the stamp use a few fixed protocol constants (`ACTIVITY_TYPE_APPROVE_ACTIVITY`, the signature scheme). **Copy them verbatim** - Conduit re-derives your signature over the exact bytes you send and checks that the body authorizes this specific request (its `fingerprint`, `subOrganizationId`, and activity type). Change a byte and the stamp is rejected.

The signed body:

```json theme={null}
{
  "type": "ACTIVITY_TYPE_APPROVE_ACTIVITY",
  "timestampMs": "1750000000000",
  "organizationId": "<approvalMaterial.subOrganizationId>",
  "parameters": { "fingerprint": "<approvalMaterial.fingerprint>" }
}
```

`timestampMs` is the current time in milliseconds; it must be fresh (a stamp minted long in the past or future is refused). Use `ACTIVITY_TYPE_REJECT_ACTIVITY` to reject instead of approve. A complete, dependency-free reference implementation in TypeScript:

```ts theme={null}
import crypto from "node:crypto";

const CONDUIT_API = "https://api.conduit.financial";
const STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; // fixed constant - send verbatim

// Sign `signedBody` with the machine key and return the stamp value Conduit expects.
function buildStamp(
  signedBody: string,
  compressedPublicKeyHex: string,
  privateKeyPem: string,
): string {
  const signature = crypto
    .createSign("SHA256")
    .update(signedBody)
    .sign({ key: privateKeyPem, dsaEncoding: "der" }, "hex");
  const envelope = JSON.stringify({
    publicKey: compressedPublicKeyHex,
    scheme: STAMP_SCHEME,
    signature,
  });
  return Buffer.from(envelope).toString("base64url");
}

// Approve (or reject) one signing request discovered from GET /v2/signing-requests.
export async function submitApproval(
  request: {
    id: string;
    approvalMaterial: { fingerprint: string; subOrganizationId: string };
  },
  keys: {
    apiKey: string;
    compressedPublicKeyHex: string;
    privateKeyPem: string;
  },
  decision: "approve" | "reject" = "approve",
) {
  const activityType =
    decision === "approve"
      ? "ACTIVITY_TYPE_APPROVE_ACTIVITY"
      : "ACTIVITY_TYPE_REJECT_ACTIVITY";
  const signedBody = JSON.stringify({
    type: activityType,
    timestampMs: Date.now().toString(),
    organizationId: request.approvalMaterial.subOrganizationId,
    parameters: { fingerprint: request.approvalMaterial.fingerprint },
  });
  const stamp = buildStamp(
    signedBody,
    keys.compressedPublicKeyHex,
    keys.privateKeyPem,
  );

  const res = await fetch(
    `${CONDUIT_API}/v2/signing-requests/${request.id}/${decision}`,
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": keys.apiKey,
        "idempotency-key": crypto.randomUUID(),
      },
      body: JSON.stringify({ signedBody, stamp }),
    },
  );
  if (!res.ok)
    throw new Error(
      `stamp submission failed (${res.status}): ${await res.text()}`,
    );
  return res.json(); // the signing request in its new state
}
```

`POST /v2/signing-requests/{id}/approve` returns `200` with the request in its updated state - `approvedCount` incremented, and `status: "quorum_met"` once your side of the quorum is complete (the payout then moves to Conduit's compliance approval). Submitting the same stamp again is safe and idempotent: whether the request is still open or has already resolved (quorum met, rejected, or expired), a resubmit returns the request's **current state** at `200`, never an error - so a machine that timed out mid-submit can simply retry, and you re-read the request to see the final outcome rather than treating a resubmit as a failure.

## Rejecting a payout

`POST /v2/signing-requests/{id}/reject` submits a rejecting stamp (build the body with `ACTIVITY_TYPE_REJECT_ACTIVITY`).

<Warning>
  **A rejection is terminal and immediate.** A single reject ends the payout -
  it cannot be revived, and no further approvals will complete it. There is no
  "undo": to send that payout you originate a new one. Gate which of your
  machine signers may reject.
</Warning>

## Outcomes

Fetch `GET /v2/signing-requests/{id}` to follow a request to its terminal state:

| `status`             | Meaning                                                                           |
| -------------------- | --------------------------------------------------------------------------------- |
| `awaiting_signature` | Still collecting your quorum - more approvals needed, or yours not yet counted.   |
| `quorum_met`         | Your side is complete; the payout has moved to Conduit's compliance approval.     |
| `declined`           | A rejection ended it, or compliance declined it. Terminal.                        |
| `expired`            | The approval window closed, or the underlying payout is no longer live. Terminal. |

## Error reference

| Code                                 | HTTP | When                                                                                                                                                                                                                                                        | What to do                                                                                                                                                          |
| ------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SIGNING_REQUEST_NOT_FOUND`          | 404  | No signing request with this id in your account (also the response for another account's id).                                                                                                                                                               | List with `GET /v2/signing-requests` and use an id from there.                                                                                                      |
| `SIGNING_MODE_NOT_PROGRAMMATIC`      | 409  | The wallet requires human passkey approval - machine stamps aren't accepted.                                                                                                                                                                                | Have Conduit enable a programmatic signing mode, or approve with a passkey.                                                                                         |
| `SIGNING_STAMP_INVALID`              | 422  | The stamp isn't a valid P-256 stamp, its signature doesn't match the body, or the body doesn't authorize this request (wrong fingerprint, sub-organization, or activity type).                                                                              | Re-fetch `approvalMaterial`, rebuild the body exactly, and re-stamp.                                                                                                |
| `SIGNING_STAMP_SIGNER_UNKNOWN`       | 422  | The stamp verified, but its public key isn't an active machine signer on this wallet.                                                                                                                                                                       | Stamp with a key you registered as an active signer, or add the signer first.                                                                                       |
| `SIGNING_STAMP_ATTRIBUTION_MISMATCH` | 422  | The stamp verified and resolved to your signer, but the provider recorded the vote under a different signer. Conduit fails closed rather than count a vote it can't attribute to the key you submitted. A rare integrity guard, not hit on the normal path. | Re-fetch the request and resubmit with the machine signer whose key is on the roster; if it recurs, contact Conduit (the signer's registration may be out of sync). |

## What Conduit records

Your approval is recorded as a vote attributed to the signer whose key produced the stamp - that vote is the authoritative record of your approval. Conduit records an intent before it relays to the provider, then finalizes it with the provider's outcome after; the provider's decision is authoritative, so once it accepts your vote that vote stands even if a later local step retries. Your **raw stamp is never stored**: it is a bearer signature, so Conduit relays it and discards it. Conduit also keeps a supplementary audit trail (a hash of the body you signed, a fingerprint of the public key that signed it, and which of your API keys submitted it) for operational traceability.

## Related

<CardGroup cols={2}>
  <Card title="Add a Crypto Wallet" href="/guides/add-crypto-wallet">
    Provision the wallet: feature request, claim, roster shapes per signing
    mode, activation.
  </Card>

  <Card title="Multi-signer wallets" href="/concepts/multi-signer-wallets">
    Roster rules, thresholds, root quorum, and lifecycle endpoints.
  </Card>

  <Card title="Non-Custodial Wallets" href="/concepts/non-custodial-wallets">
    The custody model and the hosted verify page for passkey signers.
  </Card>

  <Card title="Sandbox multi-signer recipes" href="/sandbox/multi-signer-wallets">
    Drive rosters and signing flows deterministically in sandbox.
  </Card>
</CardGroup>
