> For the complete documentation index, see [llms.txt](https://docs.swapkit.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.swapkit.dev/spotlights/transaction-payload-signing.md).

# Transaction Payload Signing

SwapKit can cryptographically sign the transaction payloads returned by the Swap API so integrators can verify that responses originate from SwapKit and have not been tampered with in transit. This page describes the **generic ES256 signing flow** that works for any swap, on any chain, with any token.

> SLIP-0024 is a separate envelope format used specifically to render human-readable confirmation screens on hardware wallets. It is signed with a different scheme (ECDSA over **secp256k1** of a binary-encoded payment request) and is **not** the signature described here. See the separate [SLIP-0024 documentation](https://docs.swapkit.dev/spotlights/slip-0024-transaction-payload-signing).

***

#### Overview

* Each API key can be associated with a **secp256r1 (P-256 / ES256)** key pair.
* SwapKit holds the private key; integrators receive the **public key** when the key pair is created.
* When a swap response includes a transaction, SwapKit signs it and returns, on **each route's `meta` object**:
  * `meta.signedTx` — the JWS payload component (see below). It is **not** the raw transaction.
  * `meta.signature` — the ES256 signature, encoded as a Flattened JWS signature.
  * `meta.signedTxString` — the exact serialized transaction bytes SwapKit hashed and signed (the signature **pre-image**). It is the RFC 8785 canonical JSON of an object transaction, or the raw string of a string transaction. Use it to run the binding check without re-serializing the `tx` yourself.
* The signature is a **Flattened JWS (RFC 7515)**: SwapKit serializes the transaction to its **canonical form** (see [Transaction serialization](/spotlights/transaction-payload-signing.md#transaction-serialization) below), computes the SHA-256 digest of that canonical byte string as a lowercase hex string, base64url encodes that hex string as the JWS payload (`meta.signedTx`), and signs the JWS Signing Input. The integrator verifies `meta.signature` against the reconstructed JWS Signing Input using the stored public key.

If no key pair is configured for the API key — or the response does not include a transaction — the swap response is returned **unsigned** and `meta.signedTx` / `meta.signature` / `meta.signedTxString` are omitted. Note that sending `disableBuildTx: true` suppresses transaction building and therefore signing: if you build your own transaction from our response, there is nothing for SwapKit to sign.

> **The signature covers a digest of the transaction, not the transaction directly.** Verifying the signature is necessary but not sufficient — see Verify the signature for the two checks you must perform.

#### Activate signing for your API key

Signing is activated by SwapKit on your behalf — there is no self-serve endpoint. To enable it, **contact your SwapKit account manager** and request a signing key pair for your API key.

Once provisioned, you will receive a **public key** in PEM format, for example:

```
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
```

Notes:

* Store the public key securely on your side — this is the only value you need to verify signatures.
* SwapKit holds the corresponding private key. It is never exposed to integrators and is stored encrypted at rest using Google Cloud KMS under a per-tenant encryption key.
* A key pair cannot be overwritten in place. To rotate, request a rotation through your account manager.
* `secp256r1` (ES256) is the current default. Other key types may be added in the future.

#### Receive signed swap responses

Once signing is active, a swap response that includes a transaction carries the signature in each route's `meta`:

```json
{
  "routes": [
    {
      "tx": { "...": "..." },
      "meta": {
        "signedTx": "<base64url(SHA-256 hex digest of the canonical tx)>",
        "signedTxString": "<exact canonical tx bytes that were hashed & signed — RFC 8785 JSON for object txs, or the raw string for string txs>",
        "signature": "<base64url ES256 signature — see Signature specification below>"
      }
    }
  ]
}
```

None of these fields is the raw transaction. `meta.signedTx` is the **JWS payload** — `BASE64URL(SHA-256 hex digest of the canonical tx)`; `meta.signature` is the JWS signature over the **JWS Signing Input** `eyJhbGciOiJFUzI1NiJ9.<meta.signedTx>`; and `meta.signedTxString` is the exact byte string that was hashed and signed, provided so you can run the binding check by hashing it directly. The exact byte layout of each field is in the Signature specification below.

#### Transaction serialization

The signature is over a digest of the transaction's **canonical serialization**, which depends on the transaction shape:

* **Object transactions** (EVM, Cosmos, Tron, Starknet, TON, …) are serialized with **RFC 8785 — the JSON Canonicalization Scheme (JCS)**: object keys sorted by UTF-16 code unit, array order preserved, no insignificant whitespace. Unlike `JSON.stringify`, the output byte string is deterministic regardless of object property insertion order, so it can be reproduced byte-for-byte by any platform or language that implements JCS.
* **String transactions** (PSBT, base64, CBOR, and other pre-serialized formats) are the exact signable bytes already, so they are used **verbatim**, with no canonicalization.

> **Why this matters.** SwapKit previously serialized object transactions with `JSON.stringify`, which does not guarantee key ordering across languages/platforms and is therefore not safe to reproduce for the binding check. Signing now uses RFC 8785 so the hashed pre-image is deterministic and reproducible byte-for-byte. SwapKit also returns the exact pre-image in `meta.signedTxString`, so you never have to re-serialize the transaction yourself.

The exact serializer SwapKit uses is a standard RFC 8785 (JCS) implementation:

```typescript
/**
 * Serialize a JSON value to its RFC 8785 (JSON Canonicalization Scheme, JCS)
 * canonical form: object keys sorted by UTF-16 code unit, array order preserved,
 * and no insignificant whitespace. The output byte string is deterministic
 * regardless of object property insertion order, so it can be reproduced
 * byte-for-byte by any platform/language that implements JCS.
 *
 * BigInt and non-finite numbers are rejected rather than coerced (JCS/JSON have
 * no bigint type, and JSON.stringify would silently emit non-finite numbers as
 * null) — neither occurs in transaction data, where amounts are strings.
 * Properties whose value is `undefined` are omitted, matching JSON.stringify.
 */
export function canonicalizeJson(value: unknown): string {
  if (typeof value === "bigint") {
    throw new TypeError(
      `canonicalizeJson: cannot canonicalize a BigInt (${value}n) — tx amounts must be strings`,
    );
  }

  if (typeof value === "number" && !Number.isFinite(value)) {
    throw new TypeError(`canonicalizeJson: cannot canonicalize non-finite number (${value})`);
  }

  if (value === null || typeof value !== "object") {
    // Primitives: string/number/boolean. JSON.stringify produces the canonical
    // representation for strings (minimal escaping) and integers.
    return JSON.stringify(value) ?? "null";
  }

  if (Array.isArray(value)) {
    return `[${value.map((item) => canonicalizeJson(item)).join(",")}]`;
  }

  const entries = Object.entries(value as Record<string, unknown>)
    .filter(([, v]) => v !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));

  const members = entries.map(([key, v]) => `${JSON.stringify(key)}:${canonicalizeJson(v)}`);

  return `{${members.join(",")}}`;
}
```

In other languages, use a maintained JCS/RFC 8785 library (for example `canonicaljson` in Python, `gibson042/canonicaljson-go` in Go, `erdtman/java-json-canonicalization` in Java) rather than your language's default JSON encoder.

#### Verify the signature

Verification has **two independent checks** — both are required:

1. **Signature check** — the JWS signature is valid for the JWS Signing Input under your public key. This proves SwapKit produced the signature.
2. **Binding check** — the signed digest equals `SHA-256` of the canonical serialization of the `tx` you are about to broadcast. This proves the signature is bound to the transaction you will send, not some other transaction.

Skipping step 2 leaves you exposed: an attacker could leave a valid `signedTx`/`signature` pair untouched while swapping out `tx`, and a signature-only check would still pass.

> **Run the binding check against the `tx` you will broadcast.** `meta.signedTxString` is the exact pre-image SwapKit signed, so hashing it directly reproduces the signed digest with no re-serialization. That only proves those *bytes* are authentic, though — to bind the check to what you actually send, confirm the transaction you broadcast is the one `meta.signedTxString` represents. The trust-minimizing way to do that is to canonicalize your own `tx` and require it to equal `meta.signedTxString`; the example below does exactly that.

You do not need a SwapKit-specific SDK. The simplest correct approach is a **JWS/JOSE library**, because the signature is a Flattened JWS. A raw ES256 verifier also works if you reconstruct the JWS Signing Input and handle the signature encoding (see Recommended libraries).

**Signature specification**

| Field                            | Value                                                                                                                      |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Envelope                         | Flattened JWS JSON Serialization (RFC 7515 §7.2.2)                                                                         |
| Curve                            | `secp256r1` (also known as P-256 / prime256v1)                                                                             |
| Hash                             | `SHA-256`                                                                                                                  |
| Algorithm                        | ECDSA (ES256, RFC 7518)                                                                                                    |
| Public key format                | PEM, `SubjectPublicKeyInfo` (as delivered on activation)                                                                   |
| Protected header                 | `{"alg":"ES256"}` → base64url `eyJhbGciOiJFUzI1NiJ9`                                                                       |
| Signed bytes (JWS Signing Input) | `eyJhbGciOiJFUzI1NiJ9.<meta.signedTx>` (ASCII)                                                                             |
| Canonical tx string              | Object tx → **RFC 8785 (JCS)** JSON; string tx (PSBT, base64, CBOR, …) → used verbatim. Returned as `meta.signedTxString`. |
| `meta.signedTx` (JWS payload)    | `BASE64URL(SHA-256 hex digest of the canonical tx string)`                                                                 |
| `meta.signedTxString`            | The exact canonical tx string (signature pre-image). Hash it to reproduce the digest without re-serializing `tx`.          |
| Signature encoding               | **JOSE: raw `r \|\| s`, 64 bytes** (concatenated, fixed-width). **Not DER.**                                               |
| Signature transport encoding     | **base64url** (`meta.signature`) — not standard base64, not hex                                                            |

**TypeScript example using `jose` library**

```typescript
import * as jose from "jose";
import * as crypto from "crypto";
import { canonicalizeJson } from "./canonicalizeJson"; // the RFC 8785 serializer shown above

// Public key delivered when signing was activated for your API key.
const publicKeyPem = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----";

// A single route from a swap response
const route = {
  tx: {
    to: "0x569a904F8478c66fD495d2B4E8e272B6507feDB3",
    from: "0x569a904F8478c66fD495d2B4E8e272B6507feDB3",
    gas: "0x5208",
    gasPrice: "0x1805fa7",
    value: "2000000000000000",
    data: "0x",
  },
  meta: {
    signedTx: "NjFhNGM1ZTJmMWIzZC4uLg....",       // base64url(SHA-256 hex digest of the canonical tx)
    signedTxString: '{"data":"0x","from":"0x569a...","gas":"0x5208","gasPrice":"0x1805fa7","to":"0x569a...","value":"2000000000000000"}', // exact signed pre-image
    signature: "cceWPI6Ak-....",                  // base64url ES256 signature (raw r || s)
  },
};

async function verifyRoute(route: {
  tx: unknown;
  meta: { signedTx: string; signedTxString: string; signature: string };
}): Promise<void> {
  const { signedTx, signedTxString, signature } = route.meta;

  // Import the public key (PEM SubjectPublicKeyInfo).
  const ecPublicKey = await jose.importSPKI(publicKeyPem, "ES256");

  // 1) SIGNATURE CHECK — verify the Flattened JWS.
  //    jose reconstructs the signing input (eyJhbGciOiJFUzI1NiJ9.<signedTx>)
  //    and decodes the raw r || s signature internally.
  //    It throws on an invalid signature, wrong key, or unexpected algorithm.
  const { payload } = await jose.flattenedVerify(
    {
      payload: signedTx,
      signature,
      protected: Buffer.from(JSON.stringify({ alg: "ES256" })).toString("base64url"),
    },
    ecPublicKey,
  );

  // 2) BINDING CHECK — tie the tx you will broadcast to the signed pre-image.
  //    Canonicalize your OWN tx (RFC 8785 for objects, raw string for strings)
  //    and require it to equal the pre-image SwapKit signed. This is what makes
  //    the signature bind to the transaction you send — key order can never drift.
  const canonicalTx = typeof route.tx === "string" ? route.tx : canonicalizeJson(route.tx);
  if (canonicalTx !== signedTxString) {
    throw new Error("Transaction does not match the signed pre-image");
  }

  //    The verified JWS payload is the base64url-decoded signedTx, i.e. the hex
  //    digest string. Recompute it over the canonical tx and compare.
  const expectedDigest = crypto.createHash("sha256").update(Buffer.from(canonicalTx)).digest("hex");
  const signedDigest = Buffer.from(payload).toString("utf8");
  if (signedDigest !== expectedDigest) {
    throw new Error("Digest mismatch — transaction does not match the signed payload");
  }

  // Both checks passed — the tx is authentic and unmodified. Safe to broadcast.
}

await verifyRoute(route);
```

> If you cannot implement RFC 8785 canonicalization in your language, you may hash `meta.signedTxString` directly for the digest — but you must then broadcast the transaction that string represents (parse `meta.signedTxString`), not a separately-held `tx`, or the binding no longer covers what you send.

**Recommended libraries**

The signature is a **Flattened JWS** with a `r || s` (non-DER) signature, base64url-encoded. Pick one of two paths:

**Path A — JWS/JOSE library (recommended).** Hand it the protected header `{"alg":"ES256"}`, `meta.signedTx` as the payload, `meta.signature`, and your public key. It reconstructs the signing input and handles the `r || s` encoding for you.

* **Node.js / Browser** — [`jose`](https://github.com/panva/jose): `jose.flattenedVerify({ protected, payload, signature }, key)`. This is what SwapKit uses; see the example above.
* **Python** — [`jwcrypto`](https://jwcrypto.readthedocs.io/) or [`joserfc`](https://jose.authlib.org/en/) (both actively maintained). Avoid `python-jose` — it is effectively unmaintained and has had CVEs.
* **Go** — [`go-jose`](https://github.com/go-jose/go-jose) (`jose.ParseSigned` / `JSONWebSignature.Verify`)
* **Java** — [`nimbus-jose-jwt`](https://connect2id.com/products/nimbus-jose-jwt) (`JWSObject` / `ECDSAVerifier`)
* **Rust** — [`josekit`](https://docs.rs/josekit/). (Note: the `jsonwebtoken` crate only handles compact JWT, not arbitrary Flattened JWS payloads.)

**Path B — raw ES256 verifier.** If you use a generic ECDSA-P256 verifier instead, you must: (a) build the signing input string `eyJhbGciOiJFUzI1NiJ9.<meta.signedTx>` and pass its **UTF-8 bytes** as the data; (b) base64url-decode `meta.signature` to the raw 64-byte `r || s`; and (c) match the signature format your verifier expects. The verifier applies `SHA-256` to the data itself.

| Verifier                                                                                                                                       | Signature format expected                               | Conversion from base64url `r \|\| s`                                                          |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [Node `crypto.verify`](https://nodejs.org/api/crypto.html#cryptoverifyalgorithm-data-key-signature-callback)                                   | raw `r \|\| s` via `{ key, dsaEncoding: "ieee-p1363" }` | none (default `"der"` would reject it)                                                        |
| [WebCrypto `SubtleCrypto.verify`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify) (`{ name: "ECDSA", hash: "SHA-256" }`) | raw `r \|\| s`                                          | none                                                                                          |
| [Python `cryptography`](https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ec/)                                                    | DER                                                     | `encode_dss_signature(r, s)`, then `public_key.verify(der, input, ec.ECDSA(hashes.SHA256()))` |
| [Go `crypto/ecdsa`](https://pkg.go.dev/crypto/ecdsa)                                                                                           | two big integers                                        | split `r`/`s`, `ecdsa.Verify(pub, sha256.Sum256(input)[:], r, s)` (or DER + `VerifyASN1`)     |
| [Java `Signature` `SHA256withECDSA`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/Signature.html)                | DER                                                     | convert `r \|\| s` to DER first                                                               |

Whichever path you choose, the binding check (recomputing the digest from the canonical `tx` and comparing to `meta.signedTx`) is the same and still required.

#### What to do on verification failure

If either check fails:

* **Do not** broadcast the transaction.
* **Do not** retry against a different endpoint or relax the check.
* Treat the response as untrusted and surface the error to the caller or log it for investigation.

A failure means the response did not come from SwapKit, was modified in transit, or the integration is using a stale public key after a rotation.

***

#### FAQ

**Why do I have to recompute the digest if the signature already verifies?** Because the signature only proves the digest is authentic. Without comparing the signed digest to the digest of *your* `tx`, a tampered transaction with an intact (but unrelated) `signedTx`/`signature` pair would pass the signature check.

**Does signing work for tokens that aren't in SLIP-0044?** Yes. The ES256 signature is over a digest of the raw transaction payload SwapKit returns. It is independent of any token registry — there is no SLIP-0044 lookup involved, and token coverage is not limited by it.

**Can I have multiple key pairs per API key?** No. One key pair per API key — which also means an API key is provisioned for either ES256 transaction payload signing or SLIP-0024 hardware-wallet signing, never both. To rotate, request a rotation through your SwapKit point of contact.
