Repository: hyperledger/firefly
Pull request #1096 (2022-11-08 04:40:23)

Idempotent apis
This feature allows API requests to FireFly Core to be made idempotent.

That is that the caller of the API can specify its own unique identifier (an arbitrary string up to 256 characters) that uniquely identifies the request. So if there is a network connectivity failure, or an abrupt termination of either runtime, the application can safely attempt to resubmit the REST API call and be returned a `409 Conflict` HTTP code.

Examples of how an app might construct such an idempotencyKey include:
- Unique business identifiers from the request that comes into its API up-stream - passing idempotency along the chain
- A hash of the business unique data that relates to the request - maybe all the input data of a blockchain transaction for example, if that payload is guaranteed to be unique.
    > Be careful of cases where the business data might _not_ be unique - like a transfer of 10 coins from A to B... that
    > could happen multiple times, and each would be a separate business transaction. Where as transfer with invoice
    > number abcd1234 of 10 coins from A to B might be perfectly unique.
- A unique identifier of a business transaction generated within the application and stored in its database before submission
    > Here be careful you haven't just moved the problem up one layer. How does that unique ID get generated? Is that
    > itself idempotent?

FireFly already uses an idempotent interface downstream to key plugins:
   - EVMConnect blockchain operations - fully idempotent, using the operation ID
   - Token operations - inherit idempotence from the blockchain connector they communicate with

> Note that the interface between FireFly and EthConnect for blockchain operations is not currently fully idempotent.
> This PR does not make the changes needed for that, and I note that EVMConnect is the recommended choice for
> new FireFly projects.

### Tasks

- [x] Add `idempotencyKey` field to messages
   - Persisted directly on `Message` object
   - Local only - not propagated in `persistBatch` when received remotely
   - Note complexity was in the batching logic, as it's important if the message writer batching has one out of a set of messages that fails the idempotency check, it doesn't fail the whole batch of message/data inserts. To do this without reducing performance for the good case took a bit of extra work
- [x] Add `idempotencyKey` to `invoke` of blockchain smart contracts
    - Persisted on `Transaction` object
- [x] Add `idempotencyKey` to `deploy` of blockchain smart contracts
    - Persisted on `Transaction` object
- [x] Add `idempotencyKey` to `transfer` (/`mint`/`burn`/`approval`) of tokens
    - Persisted on `Transaction` object
- [x] Add `idempotencyKey` to `publish` APIs
- [x] Update e2e tests for Message to test idempotent submission
- [x] Update e2e tests for Tokens to test idempotent submission
- [x] Update e2e tests for Custom contracts to test idempotent submission

> I did look at in this PR, whether I could pass the `idempotencyKey` back to applications on events if they submitted
> them on a request. However, this proved to need more investigation because the `.transaction` field of events is
> only enriched on `transaction_submitted` events. To switch it to be cache-back-queried on every event, and to bloat
> the JSON with the full transaction for all events, felt like a change that needed discussion independent from adding
> the `idempotencyKey` capability to the submission APIs.


Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 01df452d2..728aed7e8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,6 +9,7 @@
     "autometa",
     "bifactory",
     "blobstore",
+    "blockchainids",
     "breq",
     "ccache",
     "contractlisteners",
@@ -41,6 +42,8 @@
     "fftypes",
     "finalizers",
     "Hyperledger",
+    "Idempotency",
+    "idempotencykey",
     "IDUUID",
     "Infof",
     "IPFS",
diff --git a/db/migrations/postgres/000101_add_idempotency_keys.down.sql b/db/migrations/postgres/000101_add_idempotency_keys.down.sql
new file mode 100644
index 000000000..1bf2f2566
--- /dev/null
+++ b/db/migrations/postgres/000101_add_idempotency_keys.down.sql
@@ -0,0 +1,9 @@
+BEGIN;
+
+DROP INDEX transactions_idempotency_keys;
+DROP INDEX messages_idempotency_keys;
+
+ALTER TABLE transactions DROP COLUMN idempotency_key;
+ALTER TABLE messages DROP COLUMN idempotency_key;
+
+COMMIT;
diff --git a/db/migrations/postgres/000101_add_idempotency_keys.up.sql b/db/migrations/postgres/000101_add_idempotency_keys.up.sql
new file mode 100644
index 000000000..8369a799d
--- /dev/null
+++ b/db/migrations/postgres/000101_add_idempotency_keys.up.sql
@@ -0,0 +1,9 @@
+BEGIN;
+
+ALTER TABLE transactions ADD COLUMN idempotency_key VARCHAR(256);
+ALTER TABLE messages ADD COLUMN idempotency_key VARCHAR(256);
+
+CREATE UNIQUE INDEX transactions_idempotency_keys ON transactions (namespace, idempotency_key);
+CREATE UNIQUE INDEX messages_idempotency_keys ON messages (namespace, idempotency_key);
+
+COMMIT;
\ No newline at end of file
diff --git a/db/migrations/sqlite/000101_add_idempotency_keys.down.sql b/db/migrations/sqlite/000101_add_idempotency_keys.down.sql
new file mode 100644
index 000000000..315c9f268
--- /dev/null
+++ b/db/migrations/sqlite/000101_add_idempotency_keys.down.sql
@@ -0,0 +1,5 @@
+DROP INDEX transactions_idempotency_keys;
+DROP INDEX messages_idempotency_keys;
+
+ALTER TABLE transactions DROP COLUMN idempotency_key;
+ALTER TABLE messages DROP COLUMN idempotency_key;
diff --git a/db/migrations/sqlite/000101_add_idempotency_keys.up.sql b/db/migrations/sqlite/000101_add_idempotency_keys.up.sql
new file mode 100644
index 000000000..df1859d92
--- /dev/null
+++ b/db/migrations/sqlite/000101_add_idempotency_keys.up.sql
@@ -0,0 +1,5 @@
+ALTER TABLE transactions ADD COLUMN idempotency_key VARCHAR(256);
+ALTER TABLE messages ADD COLUMN idempotency_key VARCHAR(256);
+
+CREATE UNIQUE INDEX transactions_idempotency_keys ON transactions (namespace, idempotency_key);
+CREATE UNIQUE INDEX messages_idempotency_keys ON messages (namespace, idempotency_key);
diff --git a/docs/reference/types/message.md b/docs/reference/types/message.md
index 3e1defe59..791507a1b 100644
--- a/docs/reference/types/message.md
+++ b/docs/reference/types/message.md
@@ -64,6 +64,7 @@ nav_order: 15
 | `confirmed` | The timestamp of when the message was confirmed/rejected | [`FFTime`](simpletypes#fftime) |
 | `data` | The list of data elements attached to the message | [`DataRef[]`](#dataref) |
 | `pins` | For private messages, a unique pin hash:nonce is assigned for each topic | `string[]` |
+| `idempotencyKey` | An optional unique identifier for a message. Cannot be duplicated within a namespace, thus allowing idempotent submission of messages to the API. Local only - not transferred when the message is sent to other members of the network | `IdempotencyKey` |
 
 ## MessageHeader
 
diff --git a/docs/reference/types/transaction.md b/docs/reference/types/transaction.md
index 87b931491..781b5323d 100644
--- a/docs/reference/types/transaction.md
+++ b/docs/reference/types/transaction.md
@@ -42,5 +42,6 @@ nav_order: 6
 | `namespace` | The namespace of the FireFly transaction | `string` |
 | `type` | The type of the FireFly transaction | `FFEnum`:<br/>`"none"`<br/>`"unpinned"`<br/>`"batch_pin"`<br/>`"network_action"`<br/>`"token_pool"`<br/>`"token_transfer"`<br/>`"contract_deploy"`<br/>`"contract_invoke"`<br/>`"token_approval"`<br/>`"data_publish"` |
 | `created` | The time the transaction was created on this node. Note the transaction is individually created with the same UUID on each participant in the FireFly transaction | [`FFTime`](simpletypes#fftime) |
+| `idempotencyKey` | An optional unique identifier for a transaction. Cannot be duplicated within a namespace, thus allowing idempotent submission of transactions to the API | `IdempotencyKey` |
 | `blockchainIds` | The blockchain transaction ID, in the format specific to the blockchain involved in the transaction. Not all FireFly transactions include a blockchain. FireFly transactions are extensible to support multiple blockchain transactions | `string[]` |
 
diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml
index d8ca72764..5330cb02f 100644
--- a/docs/swagger/swagger.yaml
+++ b/docs/swagger/swagger.yaml
@@ -591,6 +591,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -1182,6 +1186,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -2008,6 +2016,10 @@ paths:
                     if required by the blockchain connector
                 definition:
                   description: The definition of the smart contract
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   description: An optional array of inputs passed to the smart contract's
                     constructor, if applicable
@@ -3359,6 +3371,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -4196,6 +4212,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -4754,6 +4774,11 @@ paths:
         name: created
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: group
@@ -4769,6 +4794,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: key
@@ -4878,7 +4908,11 @@ paths:
         content:
           application/json:
             schema:
-              additionalProperties: {}
+              properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
               type: object
       responses:
         "200":
@@ -4999,290 +5033,316 @@ paths:
           type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
-        name: group
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: hash
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: id
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: key
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: pins
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: sequence
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: state
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: tag
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: topics
+        name: datahash
         schema:
           type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
-        name: txtype
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: type
-        schema:
-          type: string
-      - description: Sort field. For multi-field sort use comma separated values (or
-          multiple query values) with '-' prefix for descending
-        in: query
-        name: sort
-        schema:
-          type: string
-      - description: Ascending sort order (overrides all fields in a multi-field sort)
-        in: query
-        name: ascending
-        schema:
-          type: string
-      - description: Descending sort order (overrides all fields in a multi-field
-          sort)
-        in: query
-        name: descending
-        schema:
-          type: string
-      - description: 'The number of records to skip (max: 1,000). Unsuitable for bulk
-          operations'
-        in: query
-        name: skip
-        schema:
-          type: string
-      - description: 'The maximum number of records to return (max: 1,000)'
-        in: query
-        name: limit
-        schema:
-          example: "25"
-          type: string
-      - description: Return a total count as well as items (adds extra database processing)
-        in: query
-        name: count
-        schema:
-          type: string
-      responses:
-        "200":
-          content:
-            application/json:
-              schema:
-                properties:
-                  batch:
-                    description: The UUID of the batch in which the message was pinned/transferred
-                    format: uuid
-                    type: string
-                  confirmed:
-                    description: The timestamp of when the message was confirmed/rejected
-                    format: date-time
-                    type: string
-                  data:
-                    description: The list of data elements attached to the message
-                    items:
-                      description: The list of data elements attached to the message
-                      properties:
-                        hash:
-                          description: The hash of the referenced data
-                          format: byte
-                          type: string
-                        id:
-                          description: The UUID of the referenced data resource
-                          format: uuid
-                          type: string
-                      type: object
-                    type: array
-                  hash:
-                    description: The hash of the message. Derived from the header,
-                      which includes the data hash
-                    format: byte
-                    type: string
-                  header:
-                    description: The message header contains all fields that are used
-                      to build the message hash
-                    properties:
-                      author:
-                        description: The DID of identity of the submitter
-                        type: string
-                      cid:
-                        description: The correlation ID of the message. Set this when
-                          a message is a response to another message
-                        format: uuid
-                        type: string
-                      created:
-                        description: The creation time of the message
-                        format: date-time
-                        type: string
-                      datahash:
-                        description: A single hash representing all data in the message.
-                          Derived from the array of data ids+hashes attached to this
-                          message
-                        format: byte
-                        type: string
-                      group:
-                        description: Private messages only - the identifier hash of
-                          the privacy group. Derived from the name and member list
-                          of the group
-                        format: byte
-                        type: string
-                      id:
-                        description: The UUID of the message. Unique to each message
-                        format: uuid
-                        type: string
-                      key:
-                        description: The on-chain signing key used to sign the transaction
-                        type: string
-                      namespace:
-                        description: The namespace of the message within the multiparty
-                          network
-                        type: string
-                      tag:
-                        description: The message tag indicates the purpose of the
-                          message to the applications that process it
-                        type: string
-                      topics:
-                        description: A message topic associates this message with
-                          an ordered stream of data. A custom topic should be assigned
-                          - using the default topic is discouraged
-                        items:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          type: string
-                        type: array
-                      txtype:
-                        description: The type of transaction used to order/deliver
-                          this message
-                        enum:
-                        - none
-                        - unpinned
-                        - batch_pin
-                        - network_action
-                        - token_pool
-                        - token_transfer
-                        - contract_deploy
-                        - contract_invoke
-                        - token_approval
-                        - data_publish
-                        type: string
-                      type:
-                        description: The type of the message
-                        enum:
-                        - definition
-                        - broadcast
-                        - private
-                        - groupinit
-                        - transfer_broadcast
-                        - transfer_private
-                        type: string
-                    type: object
-                  localNamespace:
-                    description: The local namespace of the message
-                    type: string
-                  pins:
-                    description: For private messages, a unique pin hash:nonce is
-                      assigned for each topic
-                    items:
-                      description: For private messages, a unique pin hash:nonce is
-                        assigned for each topic
-                      type: string
-                    type: array
-                  state:
-                    description: The current state of the message
-                    enum:
-                    - staged
-                    - ready
-                    - sent
-                    - pending
-                    - confirmed
-                    - rejected
-                    type: string
-                type: object
-          description: Success
-        default:
-          description: ""
-      tags:
-      - Default Namespace
-  /data/{dataid}/value:
-    get:
-      description: Downloads the JSON value of the data resource, without the associated
-        metadata
-      operationId: getDataValue
-      parameters:
-      - description: The blob ID
-        in: path
-        name: dataid
-        required: true
-        schema:
-          type: string
-      - description: Server-side request timeout (milliseconds, or set a custom suffix
-          like 10s)
-        in: header
-        name: Request-Timeout
-        schema:
-          default: 2m0s
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: author
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: batch
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: cid
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: confirmed
-        schema:
-          type: string
-      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
-        in: query
-        name: created
+        name: group
         schema:
           type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
-        name: group
+        name: hash
         schema:
           type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
-        name: hash
+        name: id
         schema:
           type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
-        name: id
+        name: idempotencykey
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: key
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: pins
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: sequence
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: state
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: tag
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: topics
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: txtype
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: type
+        schema:
+          type: string
+      - description: Sort field. For multi-field sort use comma separated values (or
+          multiple query values) with '-' prefix for descending
+        in: query
+        name: sort
+        schema:
+          type: string
+      - description: Ascending sort order (overrides all fields in a multi-field sort)
+        in: query
+        name: ascending
+        schema:
+          type: string
+      - description: Descending sort order (overrides all fields in a multi-field
+          sort)
+        in: query
+        name: descending
+        schema:
+          type: string
+      - description: 'The number of records to skip (max: 1,000). Unsuitable for bulk
+          operations'
+        in: query
+        name: skip
+        schema:
+          type: string
+      - description: 'The maximum number of records to return (max: 1,000)'
+        in: query
+        name: limit
+        schema:
+          example: "25"
+          type: string
+      - description: Return a total count as well as items (adds extra database processing)
+        in: query
+        name: count
+        schema:
+          type: string
+      responses:
+        "200":
+          content:
+            application/json:
+              schema:
+                properties:
+                  batch:
+                    description: The UUID of the batch in which the message was pinned/transferred
+                    format: uuid
+                    type: string
+                  confirmed:
+                    description: The timestamp of when the message was confirmed/rejected
+                    format: date-time
+                    type: string
+                  data:
+                    description: The list of data elements attached to the message
+                    items:
+                      description: The list of data elements attached to the message
+                      properties:
+                        hash:
+                          description: The hash of the referenced data
+                          format: byte
+                          type: string
+                        id:
+                          description: The UUID of the referenced data resource
+                          format: uuid
+                          type: string
+                      type: object
+                    type: array
+                  hash:
+                    description: The hash of the message. Derived from the header,
+                      which includes the data hash
+                    format: byte
+                    type: string
+                  header:
+                    description: The message header contains all fields that are used
+                      to build the message hash
+                    properties:
+                      author:
+                        description: The DID of identity of the submitter
+                        type: string
+                      cid:
+                        description: The correlation ID of the message. Set this when
+                          a message is a response to another message
+                        format: uuid
+                        type: string
+                      created:
+                        description: The creation time of the message
+                        format: date-time
+                        type: string
+                      datahash:
+                        description: A single hash representing all data in the message.
+                          Derived from the array of data ids+hashes attached to this
+                          message
+                        format: byte
+                        type: string
+                      group:
+                        description: Private messages only - the identifier hash of
+                          the privacy group. Derived from the name and member list
+                          of the group
+                        format: byte
+                        type: string
+                      id:
+                        description: The UUID of the message. Unique to each message
+                        format: uuid
+                        type: string
+                      key:
+                        description: The on-chain signing key used to sign the transaction
+                        type: string
+                      namespace:
+                        description: The namespace of the message within the multiparty
+                          network
+                        type: string
+                      tag:
+                        description: The message tag indicates the purpose of the
+                          message to the applications that process it
+                        type: string
+                      topics:
+                        description: A message topic associates this message with
+                          an ordered stream of data. A custom topic should be assigned
+                          - using the default topic is discouraged
+                        items:
+                          description: A message topic associates this message with
+                            an ordered stream of data. A custom topic should be assigned
+                            - using the default topic is discouraged
+                          type: string
+                        type: array
+                      txtype:
+                        description: The type of transaction used to order/deliver
+                          this message
+                        enum:
+                        - none
+                        - unpinned
+                        - batch_pin
+                        - network_action
+                        - token_pool
+                        - token_transfer
+                        - contract_deploy
+                        - contract_invoke
+                        - token_approval
+                        - data_publish
+                        type: string
+                      type:
+                        description: The type of the message
+                        enum:
+                        - definition
+                        - broadcast
+                        - private
+                        - groupinit
+                        - transfer_broadcast
+                        - transfer_private
+                        type: string
+                    type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
+                  localNamespace:
+                    description: The local namespace of the message
+                    type: string
+                  pins:
+                    description: For private messages, a unique pin hash:nonce is
+                      assigned for each topic
+                    items:
+                      description: For private messages, a unique pin hash:nonce is
+                        assigned for each topic
+                      type: string
+                    type: array
+                  state:
+                    description: The current state of the message
+                    enum:
+                    - staged
+                    - ready
+                    - sent
+                    - pending
+                    - confirmed
+                    - rejected
+                    type: string
+                type: object
+          description: Success
+        default:
+          description: ""
+      tags:
+      - Default Namespace
+  /data/{dataid}/value:
+    get:
+      description: Downloads the JSON value of the data resource, without the associated
+        metadata
+      operationId: getDataValue
+      parameters:
+      - description: The blob ID
+        in: path
+        name: dataid
+        required: true
+        schema:
+          type: string
+      - description: Server-side request timeout (milliseconds, or set a custom suffix
+          like 10s)
+        in: header
+        name: Request-Timeout
+        schema:
+          default: 2m0s
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: author
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: batch
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: cid
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: confirmed
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: created
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: group
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: hash
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: id
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
         schema:
           type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
@@ -5394,7 +5454,11 @@ paths:
         content:
           application/json:
             schema:
-              additionalProperties: {}
+              properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
               type: object
       responses:
         "200":
@@ -7402,6 +7466,11 @@ paths:
         name: created
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: group
@@ -7417,6 +7486,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: key
@@ -7606,6 +7680,12 @@ paths:
                           - transfer_private
                           type: string
                       type: object
+                    idempotencyKey:
+                      description: An optional unique identifier for a message. Cannot
+                        be duplicated within a namespace, thus allowing idempotent
+                        submission of messages to the API. Local only - not transferred
+                        when the message is sent to other members of the network
+                      type: string
                     localNamespace:
                       description: The local namespace of the message
                       type: string
@@ -7850,6 +7930,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -8197,6 +8283,11 @@ paths:
                     description: The UUID of the FireFly transaction
                     format: uuid
                     type: string
+                  idempotencyKey:
+                    description: An optional unique identifier for a transaction.
+                      Cannot be duplicated within a namespace, thus allowing idempotent
+                      submission of transactions to the API
+                    type: string
                   namespace:
                     description: The namespace of the FireFly transaction
                     type: string
@@ -8332,6 +8423,12 @@ paths:
                       - transfer_private
                       type: string
                   type: object
+                idempotencyKey:
+                  description: An optional unique identifier for a message. Cannot
+                    be duplicated within a namespace, thus allowing idempotent submission
+                    of messages to the API. Local only - not transferred when the
+                    message is sent to other members of the network
+                  type: string
               type: object
       responses:
         "200":
@@ -8440,6 +8537,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -8569,6 +8672,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -8746,6 +8855,12 @@ paths:
                       - transfer_private
                       type: string
                   type: object
+                idempotencyKey:
+                  description: An optional unique identifier for a message. Cannot
+                    be duplicated within a namespace, thus allowing idempotent submission
+                    of messages to the API. Local only - not transferred when the
+                    message is sent to other members of the network
+                  type: string
               type: object
       responses:
         "200":
@@ -8860,6 +8975,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -8995,6 +9116,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -9168,6 +9295,12 @@ paths:
                       - transfer_private
                       type: string
                   type: object
+                idempotencyKey:
+                  description: An optional unique identifier for a message. Cannot
+                    be duplicated within a namespace, thus allowing idempotent submission
+                    of messages to the API. Local only - not transferred when the
+                    message is sent to other members of the network
+                  type: string
               type: object
       responses:
         "200":
@@ -9362,6 +9495,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -10095,6 +10234,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -10827,6 +10970,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -11769,6 +11916,10 @@ paths:
                     if required by the blockchain connector
                 definition:
                   description: The definition of the smart contract
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   description: An optional array of inputs passed to the smart contract's
                     constructor, if applicable
@@ -13162,6 +13313,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -14034,6 +14189,10 @@ paths:
           application/json:
             schema:
               properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 input:
                   additionalProperties:
                     description: A map of named inputs. The name and type of each
@@ -14620,6 +14779,11 @@ paths:
         name: created
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: group
@@ -14635,6 +14799,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: key
@@ -14751,7 +14920,11 @@ paths:
         content:
           application/json:
             schema:
-              additionalProperties: {}
+              properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
               type: object
       responses:
         "200":
@@ -14877,6 +15050,11 @@ paths:
         name: created
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: group
@@ -14892,6 +15070,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: key
@@ -15079,6 +15262,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -15157,6 +15346,11 @@ paths:
         name: created
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: group
@@ -15172,6 +15366,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: key
@@ -15288,7 +15487,11 @@ paths:
         content:
           application/json:
             schema:
-              additionalProperties: {}
+              properties:
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
               type: object
       responses:
         "200":
@@ -17401,6 +17604,11 @@ paths:
         name: created
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: datahash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: group
@@ -17416,6 +17624,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: key
@@ -17605,6 +17818,12 @@ paths:
                           - transfer_private
                           type: string
                       type: object
+                    idempotencyKey:
+                      description: An optional unique identifier for a message. Cannot
+                        be duplicated within a namespace, thus allowing idempotent
+                        submission of messages to the API. Local only - not transferred
+                        when the message is sent to other members of the network
+                      type: string
                     localNamespace:
                       description: The local namespace of the message
                       type: string
@@ -17856,6 +18075,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -18224,6 +18449,11 @@ paths:
                     description: The UUID of the FireFly transaction
                     format: uuid
                     type: string
+                  idempotencyKey:
+                    description: An optional unique identifier for a transaction.
+                      Cannot be duplicated within a namespace, thus allowing idempotent
+                      submission of transactions to the API
+                    type: string
                   namespace:
                     description: The namespace of the FireFly transaction
                     type: string
@@ -18404,6 +18634,12 @@ paths:
                       - transfer_private
                       type: string
                   type: object
+                idempotencyKey:
+                  description: An optional unique identifier for a message. Cannot
+                    be duplicated within a namespace, thus allowing idempotent submission
+                    of messages to the API. Local only - not transferred when the
+                    message is sent to other members of the network
+                  type: string
               type: object
       responses:
         "200":
@@ -18518,6 +18754,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -18653,6 +18895,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -18837,6 +19085,12 @@ paths:
                       - transfer_private
                       type: string
                   type: object
+                idempotencyKey:
+                  description: An optional unique identifier for a message. Cannot
+                    be duplicated within a namespace, thus allowing idempotent submission
+                    of messages to the API. Local only - not transferred when the
+                    message is sent to other members of the network
+                  type: string
               type: object
       responses:
         "200":
@@ -18951,6 +19205,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -19086,6 +19346,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -19266,6 +19532,12 @@ paths:
                       - transfer_private
                       type: string
                   type: object
+                idempotencyKey:
+                  description: An optional unique identifier for a message. Cannot
+                    be duplicated within a namespace, thus allowing idempotent submission
+                    of messages to the API. Local only - not transferred when the
+                    message is sent to other members of the network
+                  type: string
               type: object
       responses:
         "200":
@@ -19460,6 +19732,12 @@ paths:
                         - transfer_private
                         type: string
                     type: object
+                  idempotencyKey:
+                    description: An optional unique identifier for a message. Cannot
+                      be duplicated within a namespace, thus allowing idempotent submission
+                      of messages to the API. Local only - not transferred when the
+                      message is sent to other members of the network
+                    type: string
                   localNamespace:
                     description: The local namespace of the message
                     type: string
@@ -24006,6 +24284,10 @@ paths:
                     of the approval.  See your chosen token connector documentation
                     for details
                   type: object
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the approval request.
                     On input defaults to the first signing key of the organization
@@ -24015,8 +24297,8 @@ paths:
                   description: The blockchain identity that is granted the approval
                   type: string
                 pool:
-                  description: The UUID the token pool this approval applies to
-                  format: uuid
+                  description: The name or UUID of a token pool. Required if more
+                    than one pool exists.
                   type: string
               type: object
       responses:
@@ -24376,150 +24658,24 @@ paths:
                   description: The source account for the transfer. On input defaults
                     to the value of 'key'
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the transfer. On input
                     defaults to the first signing key of the organization that operates
                     the node
                   type: string
                 message:
-                  description: You can specify a message to correlate with the transfer,
-                    which can be of type broadcast or private. Your chosen token connector
-                    and on-chain smart contract must support on-chain/off-chain correlation
-                    by taking a `data` input on the transfer
-                  properties:
-                    data:
-                      description: For input allows you to specify data in-line in
-                        the message, that will be turned into data attachments. For
-                        output when fetchdata is used on API calls, includes the in-line
-                        data payloads of all data attachments
-                      items:
-                        description: For input allows you to specify data in-line
-                          in the message, that will be turned into data attachments.
-                          For output when fetchdata is used on API calls, includes
-                          the in-line data payloads of all data attachments
-                        properties:
-                          datatype:
-                            description: The optional datatype to use for validation
-                              of the in-line data
-                            properties:
-                              name:
-                                description: The name of the datatype
-                                type: string
-                              version:
-                                description: The version of the datatype. Semantic
-                                  versioning is encouraged, such as v1.0.1
-                                type: string
-                            type: object
-                          id:
-                            description: The UUID of the referenced data resource
-                            format: uuid
-                            type: string
-                          validator:
-                            description: The data validator type to use for in-line
-                              data
-                            type: string
-                          value:
-                            description: The in-line value for the data. Can be any
-                              JSON type - object, array, string, number or boolean
-                        type: object
-                      type: array
-                    group:
-                      description: Allows you to specify details of the private group
-                        of recipients in-line in the message. Alternative to using
-                        the header.group to specify the hash of a group that has been
-                        previously resolved
-                      properties:
-                        members:
-                          description: An array of members of the group. If no identities
-                            local to the sending node are included, then the organization
-                            owner of the local node is added automatically
-                          items:
-                            description: An array of members of the group. If no identities
-                              local to the sending node are included, then the organization
-                              owner of the local node is added automatically
-                            properties:
-                              identity:
-                                description: The DID of the group member. On input
-                                  can be a UUID or org name, and will be resolved
-                                  to a DID
-                                type: string
-                              node:
-                                description: The UUID of the node that will receive
-                                  a copy of the off-chain message for the identity.
-                                  The first applicable node for the identity will
-                                  be picked automatically on input if not specified
-                                type: string
-                            type: object
-                          type: array
-                        name:
-                          description: Optional name for the group. Allows you to
-                            have multiple separate groups with the same list of participants
-                          type: string
-                      type: object
-                    header:
-                      description: The message header contains all fields that are
-                        used to build the message hash
-                      properties:
-                        author:
-                          description: The DID of identity of the submitter
-                          type: string
-                        cid:
-                          description: The correlation ID of the message. Set this
-                            when a message is a response to another message
-                          format: uuid
-                          type: string
-                        group:
-                          description: Private messages only - the identifier hash
-                            of the privacy group. Derived from the name and member
-                            list of the group
-                          format: byte
-                          type: string
-                        key:
-                          description: The on-chain signing key used to sign the transaction
-                          type: string
-                        tag:
-                          description: The message tag indicates the purpose of the
-                            message to the applications that process it
-                          type: string
-                        topics:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          items:
-                            description: A message topic associates this message with
-                              an ordered stream of data. A custom topic should be
-                              assigned - using the default topic is discouraged
-                            type: string
-                          type: array
-                        txtype:
-                          description: The type of transaction used to order/deliver
-                            this message
-                          enum:
-                          - none
-                          - unpinned
-                          - batch_pin
-                          - network_action
-                          - token_pool
-                          - token_transfer
-                          - contract_deploy
-                          - contract_invoke
-                          - token_approval
-                          - data_publish
-                          type: string
-                        type:
-                          description: The type of the message
-                          enum:
-                          - definition
-                          - broadcast
-                          - private
-                          - groupinit
-                          - transfer_broadcast
-                          - transfer_private
-                          type: string
-                      type: object
-                  type: object
+                  description: The UUID of a message that has been correlated with
+                    this transfer using the data field of the transfer in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 pool:
-                  description: The name or UUID of a token pool
+                  description: The UUID the token pool this transfer applies to
+                  format: uuid
                   type: string
                 to:
                   description: The target account for the transfer. On input defaults
@@ -24816,150 +24972,24 @@ paths:
                   description: The source account for the transfer. On input defaults
                     to the value of 'key'
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the transfer. On input
                     defaults to the first signing key of the organization that operates
                     the node
                   type: string
                 message:
-                  description: You can specify a message to correlate with the transfer,
-                    which can be of type broadcast or private. Your chosen token connector
-                    and on-chain smart contract must support on-chain/off-chain correlation
-                    by taking a `data` input on the transfer
-                  properties:
-                    data:
-                      description: For input allows you to specify data in-line in
-                        the message, that will be turned into data attachments. For
-                        output when fetchdata is used on API calls, includes the in-line
-                        data payloads of all data attachments
-                      items:
-                        description: For input allows you to specify data in-line
-                          in the message, that will be turned into data attachments.
-                          For output when fetchdata is used on API calls, includes
-                          the in-line data payloads of all data attachments
-                        properties:
-                          datatype:
-                            description: The optional datatype to use for validation
-                              of the in-line data
-                            properties:
-                              name:
-                                description: The name of the datatype
-                                type: string
-                              version:
-                                description: The version of the datatype. Semantic
-                                  versioning is encouraged, such as v1.0.1
-                                type: string
-                            type: object
-                          id:
-                            description: The UUID of the referenced data resource
-                            format: uuid
-                            type: string
-                          validator:
-                            description: The data validator type to use for in-line
-                              data
-                            type: string
-                          value:
-                            description: The in-line value for the data. Can be any
-                              JSON type - object, array, string, number or boolean
-                        type: object
-                      type: array
-                    group:
-                      description: Allows you to specify details of the private group
-                        of recipients in-line in the message. Alternative to using
-                        the header.group to specify the hash of a group that has been
-                        previously resolved
-                      properties:
-                        members:
-                          description: An array of members of the group. If no identities
-                            local to the sending node are included, then the organization
-                            owner of the local node is added automatically
-                          items:
-                            description: An array of members of the group. If no identities
-                              local to the sending node are included, then the organization
-                              owner of the local node is added automatically
-                            properties:
-                              identity:
-                                description: The DID of the group member. On input
-                                  can be a UUID or org name, and will be resolved
-                                  to a DID
-                                type: string
-                              node:
-                                description: The UUID of the node that will receive
-                                  a copy of the off-chain message for the identity.
-                                  The first applicable node for the identity will
-                                  be picked automatically on input if not specified
-                                type: string
-                            type: object
-                          type: array
-                        name:
-                          description: Optional name for the group. Allows you to
-                            have multiple separate groups with the same list of participants
-                          type: string
-                      type: object
-                    header:
-                      description: The message header contains all fields that are
-                        used to build the message hash
-                      properties:
-                        author:
-                          description: The DID of identity of the submitter
-                          type: string
-                        cid:
-                          description: The correlation ID of the message. Set this
-                            when a message is a response to another message
-                          format: uuid
-                          type: string
-                        group:
-                          description: Private messages only - the identifier hash
-                            of the privacy group. Derived from the name and member
-                            list of the group
-                          format: byte
-                          type: string
-                        key:
-                          description: The on-chain signing key used to sign the transaction
-                          type: string
-                        tag:
-                          description: The message tag indicates the purpose of the
-                            message to the applications that process it
-                          type: string
-                        topics:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          items:
-                            description: A message topic associates this message with
-                              an ordered stream of data. A custom topic should be
-                              assigned - using the default topic is discouraged
-                            type: string
-                          type: array
-                        txtype:
-                          description: The type of transaction used to order/deliver
-                            this message
-                          enum:
-                          - none
-                          - unpinned
-                          - batch_pin
-                          - network_action
-                          - token_pool
-                          - token_transfer
-                          - contract_deploy
-                          - contract_invoke
-                          - token_approval
-                          - data_publish
-                          type: string
-                        type:
-                          description: The type of the message
-                          enum:
-                          - definition
-                          - broadcast
-                          - private
-                          - groupinit
-                          - transfer_broadcast
-                          - transfer_private
-                          type: string
-                      type: object
-                  type: object
+                  description: The UUID of a message that has been correlated with
+                    this transfer using the data field of the transfer in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 pool:
-                  description: The name or UUID of a token pool
+                  description: The UUID the token pool this transfer applies to
+                  format: uuid
                   type: string
                 to:
                   description: The target account for the transfer. On input defaults
@@ -25426,6 +25456,10 @@ paths:
                     FireFly core configuration file that is responsible for the token
                     pool. Required on input when multiple token connectors are configured
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The signing key used to create the token pool. On input
                     for token connectors that support on-chain deployment of new tokens
@@ -26034,150 +26068,24 @@ paths:
                   description: The source account for the transfer. On input defaults
                     to the value of 'key'
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the transfer. On input
                     defaults to the first signing key of the organization that operates
                     the node
                   type: string
                 message:
-                  description: You can specify a message to correlate with the transfer,
-                    which can be of type broadcast or private. Your chosen token connector
-                    and on-chain smart contract must support on-chain/off-chain correlation
-                    by taking a `data` input on the transfer
-                  properties:
-                    data:
-                      description: For input allows you to specify data in-line in
-                        the message, that will be turned into data attachments. For
-                        output when fetchdata is used on API calls, includes the in-line
-                        data payloads of all data attachments
-                      items:
-                        description: For input allows you to specify data in-line
-                          in the message, that will be turned into data attachments.
-                          For output when fetchdata is used on API calls, includes
-                          the in-line data payloads of all data attachments
-                        properties:
-                          datatype:
-                            description: The optional datatype to use for validation
-                              of the in-line data
-                            properties:
-                              name:
-                                description: The name of the datatype
-                                type: string
-                              version:
-                                description: The version of the datatype. Semantic
-                                  versioning is encouraged, such as v1.0.1
-                                type: string
-                            type: object
-                          id:
-                            description: The UUID of the referenced data resource
-                            format: uuid
-                            type: string
-                          validator:
-                            description: The data validator type to use for in-line
-                              data
-                            type: string
-                          value:
-                            description: The in-line value for the data. Can be any
-                              JSON type - object, array, string, number or boolean
-                        type: object
-                      type: array
-                    group:
-                      description: Allows you to specify details of the private group
-                        of recipients in-line in the message. Alternative to using
-                        the header.group to specify the hash of a group that has been
-                        previously resolved
-                      properties:
-                        members:
-                          description: An array of members of the group. If no identities
-                            local to the sending node are included, then the organization
-                            owner of the local node is added automatically
-                          items:
-                            description: An array of members of the group. If no identities
-                              local to the sending node are included, then the organization
-                              owner of the local node is added automatically
-                            properties:
-                              identity:
-                                description: The DID of the group member. On input
-                                  can be a UUID or org name, and will be resolved
-                                  to a DID
-                                type: string
-                              node:
-                                description: The UUID of the node that will receive
-                                  a copy of the off-chain message for the identity.
-                                  The first applicable node for the identity will
-                                  be picked automatically on input if not specified
-                                type: string
-                            type: object
-                          type: array
-                        name:
-                          description: Optional name for the group. Allows you to
-                            have multiple separate groups with the same list of participants
-                          type: string
-                      type: object
-                    header:
-                      description: The message header contains all fields that are
-                        used to build the message hash
-                      properties:
-                        author:
-                          description: The DID of identity of the submitter
-                          type: string
-                        cid:
-                          description: The correlation ID of the message. Set this
-                            when a message is a response to another message
-                          format: uuid
-                          type: string
-                        group:
-                          description: Private messages only - the identifier hash
-                            of the privacy group. Derived from the name and member
-                            list of the group
-                          format: byte
-                          type: string
-                        key:
-                          description: The on-chain signing key used to sign the transaction
-                          type: string
-                        tag:
-                          description: The message tag indicates the purpose of the
-                            message to the applications that process it
-                          type: string
-                        topics:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          items:
-                            description: A message topic associates this message with
-                              an ordered stream of data. A custom topic should be
-                              assigned - using the default topic is discouraged
-                            type: string
-                          type: array
-                        txtype:
-                          description: The type of transaction used to order/deliver
-                            this message
-                          enum:
-                          - none
-                          - unpinned
-                          - batch_pin
-                          - network_action
-                          - token_pool
-                          - token_transfer
-                          - contract_deploy
-                          - contract_invoke
-                          - token_approval
-                          - data_publish
-                          type: string
-                        type:
-                          description: The type of the message
-                          enum:
-                          - definition
-                          - broadcast
-                          - private
-                          - groupinit
-                          - transfer_broadcast
-                          - transfer_private
-                          type: string
-                      type: object
-                  type: object
+                  description: The UUID of a message that has been correlated with
+                    this transfer using the data field of the transfer in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 pool:
-                  description: The name or UUID of a token pool
+                  description: The UUID the token pool this transfer applies to
+                  format: uuid
                   type: string
                 to:
                   description: The target account for the transfer. On input defaults
@@ -26548,6 +26456,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: type
@@ -26617,6 +26530,11 @@ paths:
                       description: The UUID of the FireFly transaction
                       format: uuid
                       type: string
+                    idempotencyKey:
+                      description: An optional unique identifier for a transaction.
+                        Cannot be duplicated within a namespace, thus allowing idempotent
+                        submission of transactions to the API
+                      type: string
                     namespace:
                       description: The namespace of the FireFly transaction
                       type: string
@@ -26681,6 +26599,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: type
@@ -26748,6 +26671,11 @@ paths:
                     description: The UUID of the FireFly transaction
                     format: uuid
                     type: string
+                  idempotencyKey:
+                    description: An optional unique identifier for a transaction.
+                      Cannot be duplicated within a namespace, thus allowing idempotent
+                      submission of transactions to the API
+                    type: string
                   namespace:
                     description: The namespace of the FireFly transaction
                     type: string
@@ -31638,6 +31566,10 @@ paths:
                     of the approval.  See your chosen token connector documentation
                     for details
                   type: object
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the approval request.
                     On input defaults to the first signing key of the organization
@@ -31647,8 +31579,8 @@ paths:
                   description: The blockchain identity that is granted the approval
                   type: string
                 pool:
-                  description: The UUID the token pool this approval applies to
-                  format: uuid
+                  description: The name or UUID of a token pool. Required if more
+                    than one pool exists.
                   type: string
               type: object
       responses:
@@ -31994,150 +31926,24 @@ paths:
                   description: The source account for the transfer. On input defaults
                     to the value of 'key'
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the transfer. On input
                     defaults to the first signing key of the organization that operates
                     the node
                   type: string
                 message:
-                  description: You can specify a message to correlate with the transfer,
-                    which can be of type broadcast or private. Your chosen token connector
-                    and on-chain smart contract must support on-chain/off-chain correlation
-                    by taking a `data` input on the transfer
-                  properties:
-                    data:
-                      description: For input allows you to specify data in-line in
-                        the message, that will be turned into data attachments. For
-                        output when fetchdata is used on API calls, includes the in-line
-                        data payloads of all data attachments
-                      items:
-                        description: For input allows you to specify data in-line
-                          in the message, that will be turned into data attachments.
-                          For output when fetchdata is used on API calls, includes
-                          the in-line data payloads of all data attachments
-                        properties:
-                          datatype:
-                            description: The optional datatype to use for validation
-                              of the in-line data
-                            properties:
-                              name:
-                                description: The name of the datatype
-                                type: string
-                              version:
-                                description: The version of the datatype. Semantic
-                                  versioning is encouraged, such as v1.0.1
-                                type: string
-                            type: object
-                          id:
-                            description: The UUID of the referenced data resource
-                            format: uuid
-                            type: string
-                          validator:
-                            description: The data validator type to use for in-line
-                              data
-                            type: string
-                          value:
-                            description: The in-line value for the data. Can be any
-                              JSON type - object, array, string, number or boolean
-                        type: object
-                      type: array
-                    group:
-                      description: Allows you to specify details of the private group
-                        of recipients in-line in the message. Alternative to using
-                        the header.group to specify the hash of a group that has been
-                        previously resolved
-                      properties:
-                        members:
-                          description: An array of members of the group. If no identities
-                            local to the sending node are included, then the organization
-                            owner of the local node is added automatically
-                          items:
-                            description: An array of members of the group. If no identities
-                              local to the sending node are included, then the organization
-                              owner of the local node is added automatically
-                            properties:
-                              identity:
-                                description: The DID of the group member. On input
-                                  can be a UUID or org name, and will be resolved
-                                  to a DID
-                                type: string
-                              node:
-                                description: The UUID of the node that will receive
-                                  a copy of the off-chain message for the identity.
-                                  The first applicable node for the identity will
-                                  be picked automatically on input if not specified
-                                type: string
-                            type: object
-                          type: array
-                        name:
-                          description: Optional name for the group. Allows you to
-                            have multiple separate groups with the same list of participants
-                          type: string
-                      type: object
-                    header:
-                      description: The message header contains all fields that are
-                        used to build the message hash
-                      properties:
-                        author:
-                          description: The DID of identity of the submitter
-                          type: string
-                        cid:
-                          description: The correlation ID of the message. Set this
-                            when a message is a response to another message
-                          format: uuid
-                          type: string
-                        group:
-                          description: Private messages only - the identifier hash
-                            of the privacy group. Derived from the name and member
-                            list of the group
-                          format: byte
-                          type: string
-                        key:
-                          description: The on-chain signing key used to sign the transaction
-                          type: string
-                        tag:
-                          description: The message tag indicates the purpose of the
-                            message to the applications that process it
-                          type: string
-                        topics:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          items:
-                            description: A message topic associates this message with
-                              an ordered stream of data. A custom topic should be
-                              assigned - using the default topic is discouraged
-                            type: string
-                          type: array
-                        txtype:
-                          description: The type of transaction used to order/deliver
-                            this message
-                          enum:
-                          - none
-                          - unpinned
-                          - batch_pin
-                          - network_action
-                          - token_pool
-                          - token_transfer
-                          - contract_deploy
-                          - contract_invoke
-                          - token_approval
-                          - data_publish
-                          type: string
-                        type:
-                          description: The type of the message
-                          enum:
-                          - definition
-                          - broadcast
-                          - private
-                          - groupinit
-                          - transfer_broadcast
-                          - transfer_private
-                          type: string
-                      type: object
-                  type: object
+                  description: The UUID of a message that has been correlated with
+                    this transfer using the data field of the transfer in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 pool:
-                  description: The name or UUID of a token pool
+                  description: The UUID the token pool this transfer applies to
+                  format: uuid
                   type: string
                 tokenIndex:
                   description: The index of the token within the pool that this transfer
@@ -32412,150 +32218,24 @@ paths:
                     of the transfer. See your chosen token connector documentation
                     for details
                   type: object
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the transfer. On input
                     defaults to the first signing key of the organization that operates
                     the node
                   type: string
                 message:
-                  description: You can specify a message to correlate with the transfer,
-                    which can be of type broadcast or private. Your chosen token connector
-                    and on-chain smart contract must support on-chain/off-chain correlation
-                    by taking a `data` input on the transfer
-                  properties:
-                    data:
-                      description: For input allows you to specify data in-line in
-                        the message, that will be turned into data attachments. For
-                        output when fetchdata is used on API calls, includes the in-line
-                        data payloads of all data attachments
-                      items:
-                        description: For input allows you to specify data in-line
-                          in the message, that will be turned into data attachments.
-                          For output when fetchdata is used on API calls, includes
-                          the in-line data payloads of all data attachments
-                        properties:
-                          datatype:
-                            description: The optional datatype to use for validation
-                              of the in-line data
-                            properties:
-                              name:
-                                description: The name of the datatype
-                                type: string
-                              version:
-                                description: The version of the datatype. Semantic
-                                  versioning is encouraged, such as v1.0.1
-                                type: string
-                            type: object
-                          id:
-                            description: The UUID of the referenced data resource
-                            format: uuid
-                            type: string
-                          validator:
-                            description: The data validator type to use for in-line
-                              data
-                            type: string
-                          value:
-                            description: The in-line value for the data. Can be any
-                              JSON type - object, array, string, number or boolean
-                        type: object
-                      type: array
-                    group:
-                      description: Allows you to specify details of the private group
-                        of recipients in-line in the message. Alternative to using
-                        the header.group to specify the hash of a group that has been
-                        previously resolved
-                      properties:
-                        members:
-                          description: An array of members of the group. If no identities
-                            local to the sending node are included, then the organization
-                            owner of the local node is added automatically
-                          items:
-                            description: An array of members of the group. If no identities
-                              local to the sending node are included, then the organization
-                              owner of the local node is added automatically
-                            properties:
-                              identity:
-                                description: The DID of the group member. On input
-                                  can be a UUID or org name, and will be resolved
-                                  to a DID
-                                type: string
-                              node:
-                                description: The UUID of the node that will receive
-                                  a copy of the off-chain message for the identity.
-                                  The first applicable node for the identity will
-                                  be picked automatically on input if not specified
-                                type: string
-                            type: object
-                          type: array
-                        name:
-                          description: Optional name for the group. Allows you to
-                            have multiple separate groups with the same list of participants
-                          type: string
-                      type: object
-                    header:
-                      description: The message header contains all fields that are
-                        used to build the message hash
-                      properties:
-                        author:
-                          description: The DID of identity of the submitter
-                          type: string
-                        cid:
-                          description: The correlation ID of the message. Set this
-                            when a message is a response to another message
-                          format: uuid
-                          type: string
-                        group:
-                          description: Private messages only - the identifier hash
-                            of the privacy group. Derived from the name and member
-                            list of the group
-                          format: byte
-                          type: string
-                        key:
-                          description: The on-chain signing key used to sign the transaction
-                          type: string
-                        tag:
-                          description: The message tag indicates the purpose of the
-                            message to the applications that process it
-                          type: string
-                        topics:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          items:
-                            description: A message topic associates this message with
-                              an ordered stream of data. A custom topic should be
-                              assigned - using the default topic is discouraged
-                            type: string
-                          type: array
-                        txtype:
-                          description: The type of transaction used to order/deliver
-                            this message
-                          enum:
-                          - none
-                          - unpinned
-                          - batch_pin
-                          - network_action
-                          - token_pool
-                          - token_transfer
-                          - contract_deploy
-                          - contract_invoke
-                          - token_approval
-                          - data_publish
-                          type: string
-                        type:
-                          description: The type of the message
-                          enum:
-                          - definition
-                          - broadcast
-                          - private
-                          - groupinit
-                          - transfer_broadcast
-                          - transfer_private
-                          type: string
-                      type: object
-                  type: object
+                  description: The UUID of a message that has been correlated with
+                    this transfer using the data field of the transfer in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 pool:
-                  description: The name or UUID of a token pool
+                  description: The UUID the token pool this transfer applies to
+                  format: uuid
                   type: string
                 to:
                   description: The target account for the transfer. On input defaults
@@ -33008,6 +32688,10 @@ paths:
                     FireFly core configuration file that is responsible for the token
                     pool. Required on input when multiple token connectors are configured
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The signing key used to create the token pool. On input
                     for token connectors that support on-chain deployment of new tokens
@@ -33595,150 +33279,24 @@ paths:
                   description: The source account for the transfer. On input defaults
                     to the value of 'key'
                   type: string
+                idempotencyKey:
+                  description: An optional identifier to allow idempotent submission
+                    of requests. Stored on the transaction uniquely within a namespace
+                  type: string
                 key:
                   description: The blockchain signing key for the transfer. On input
                     defaults to the first signing key of the organization that operates
                     the node
                   type: string
                 message:
-                  description: You can specify a message to correlate with the transfer,
-                    which can be of type broadcast or private. Your chosen token connector
-                    and on-chain smart contract must support on-chain/off-chain correlation
-                    by taking a `data` input on the transfer
-                  properties:
-                    data:
-                      description: For input allows you to specify data in-line in
-                        the message, that will be turned into data attachments. For
-                        output when fetchdata is used on API calls, includes the in-line
-                        data payloads of all data attachments
-                      items:
-                        description: For input allows you to specify data in-line
-                          in the message, that will be turned into data attachments.
-                          For output when fetchdata is used on API calls, includes
-                          the in-line data payloads of all data attachments
-                        properties:
-                          datatype:
-                            description: The optional datatype to use for validation
-                              of the in-line data
-                            properties:
-                              name:
-                                description: The name of the datatype
-                                type: string
-                              version:
-                                description: The version of the datatype. Semantic
-                                  versioning is encouraged, such as v1.0.1
-                                type: string
-                            type: object
-                          id:
-                            description: The UUID of the referenced data resource
-                            format: uuid
-                            type: string
-                          validator:
-                            description: The data validator type to use for in-line
-                              data
-                            type: string
-                          value:
-                            description: The in-line value for the data. Can be any
-                              JSON type - object, array, string, number or boolean
-                        type: object
-                      type: array
-                    group:
-                      description: Allows you to specify details of the private group
-                        of recipients in-line in the message. Alternative to using
-                        the header.group to specify the hash of a group that has been
-                        previously resolved
-                      properties:
-                        members:
-                          description: An array of members of the group. If no identities
-                            local to the sending node are included, then the organization
-                            owner of the local node is added automatically
-                          items:
-                            description: An array of members of the group. If no identities
-                              local to the sending node are included, then the organization
-                              owner of the local node is added automatically
-                            properties:
-                              identity:
-                                description: The DID of the group member. On input
-                                  can be a UUID or org name, and will be resolved
-                                  to a DID
-                                type: string
-                              node:
-                                description: The UUID of the node that will receive
-                                  a copy of the off-chain message for the identity.
-                                  The first applicable node for the identity will
-                                  be picked automatically on input if not specified
-                                type: string
-                            type: object
-                          type: array
-                        name:
-                          description: Optional name for the group. Allows you to
-                            have multiple separate groups with the same list of participants
-                          type: string
-                      type: object
-                    header:
-                      description: The message header contains all fields that are
-                        used to build the message hash
-                      properties:
-                        author:
-                          description: The DID of identity of the submitter
-                          type: string
-                        cid:
-                          description: The correlation ID of the message. Set this
-                            when a message is a response to another message
-                          format: uuid
-                          type: string
-                        group:
-                          description: Private messages only - the identifier hash
-                            of the privacy group. Derived from the name and member
-                            list of the group
-                          format: byte
-                          type: string
-                        key:
-                          description: The on-chain signing key used to sign the transaction
-                          type: string
-                        tag:
-                          description: The message tag indicates the purpose of the
-                            message to the applications that process it
-                          type: string
-                        topics:
-                          description: A message topic associates this message with
-                            an ordered stream of data. A custom topic should be assigned
-                            - using the default topic is discouraged
-                          items:
-                            description: A message topic associates this message with
-                              an ordered stream of data. A custom topic should be
-                              assigned - using the default topic is discouraged
-                            type: string
-                          type: array
-                        txtype:
-                          description: The type of transaction used to order/deliver
-                            this message
-                          enum:
-                          - none
-                          - unpinned
-                          - batch_pin
-                          - network_action
-                          - token_pool
-                          - token_transfer
-                          - contract_deploy
-                          - contract_invoke
-                          - token_approval
-                          - data_publish
-                          type: string
-                        type:
-                          description: The type of the message
-                          enum:
-                          - definition
-                          - broadcast
-                          - private
-                          - groupinit
-                          - transfer_broadcast
-                          - transfer_private
-                          type: string
-                      type: object
-                  type: object
+                  description: The UUID of a message that has been correlated with
+                    this transfer using the data field of the transfer in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 pool:
-                  description: The name or UUID of a token pool
+                  description: The UUID the token pool this transfer applies to
+                  format: uuid
                   type: string
                 to:
                   description: The target account for the transfer. On input defaults
@@ -34095,6 +33653,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: type
@@ -34164,6 +33727,11 @@ paths:
                       description: The UUID of the FireFly transaction
                       format: uuid
                       type: string
+                    idempotencyKey:
+                      description: An optional unique identifier for a transaction.
+                        Cannot be duplicated within a namespace, thus allowing idempotent
+                        submission of transactions to the API
+                      type: string
                     namespace:
                       description: The namespace of the FireFly transaction
                       type: string
@@ -34221,6 +33789,11 @@ paths:
         name: id
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: idempotencykey
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: type
@@ -34288,6 +33861,11 @@ paths:
                     description: The UUID of the FireFly transaction
                     format: uuid
                     type: string
+                  idempotencyKey:
+                    description: An optional unique identifier for a transaction.
+                      Cannot be duplicated within a namespace, thus allowing idempotent
+                      submission of transactions to the API
+                    type: string
                   namespace:
                     description: The namespace of the FireFly transaction
                     type: string
diff --git a/internal/apiserver/route_post_data_blob_publish.go b/internal/apiserver/route_post_data_blob_publish.go
index 45174987d..cd13e7cfe 100644
--- a/internal/apiserver/route_post_data_blob_publish.go
+++ b/internal/apiserver/route_post_data_blob_publish.go
@@ -34,7 +34,7 @@ var postDataBlobPublish = &ffapi.Route{
 	},
 	QueryParams:     nil,
 	Description:     coremsgs.APIEndpointsPostDataBlobPublish,
-	JSONInputValue:  func() interface{} { return &core.EmptyInput{} },
+	JSONInputValue:  func() interface{} { return &core.PublishInput{} },
 	JSONOutputValue: func() interface{} { return &core.Data{} },
 	JSONOutputCodes: []int{http.StatusOK},
 	Extensions: &coreExtensions{
@@ -42,7 +42,7 @@ var postDataBlobPublish = &ffapi.Route{
 			return or.Broadcast() != nil
 		},
 		CoreJSONHandler: func(r *ffapi.APIRequest, cr *coreRequest) (output interface{}, err error) {
-			return cr.or.Broadcast().PublishDataBlob(cr.ctx, r.PP["dataid"])
+			return cr.or.Broadcast().PublishDataBlob(cr.ctx, r.PP["dataid"], r.Input.(*core.PublishInput).IdempotencyKey)
 		},
 	},
 }
diff --git a/internal/apiserver/route_post_data_value_publish.go b/internal/apiserver/route_post_data_value_publish.go
index c9aa763cb..d6ba9d9d8 100644
--- a/internal/apiserver/route_post_data_value_publish.go
+++ b/internal/apiserver/route_post_data_value_publish.go
@@ -34,7 +34,7 @@ var postDataValuePublish = &ffapi.Route{
 	},
 	QueryParams:     nil,
 	Description:     coremsgs.APIEndpointsPostDataValuePublish,
-	JSONInputValue:  func() interface{} { return &core.EmptyInput{} },
+	JSONInputValue:  func() interface{} { return &core.PublishInput{} },
 	JSONOutputValue: func() interface{} { return &core.Data{} },
 	JSONOutputCodes: []int{http.StatusOK},
 	Extensions: &coreExtensions{
@@ -42,7 +42,7 @@ var postDataValuePublish = &ffapi.Route{
 			return or.Broadcast() != nil
 		},
 		CoreJSONHandler: func(r *ffapi.APIRequest, cr *coreRequest) (output interface{}, err error) {
-			return cr.or.Broadcast().PublishDataValue(cr.ctx, r.PP["dataid"])
+			return cr.or.Broadcast().PublishDataValue(cr.ctx, r.PP["dataid"], r.Input.(*core.PublishInput).IdempotencyKey)
 		},
 	},
 }
diff --git a/internal/apiserver/route_post_token_pool.go b/internal/apiserver/route_post_token_pool.go
index 2914b08b3..42b4b40b3 100644
--- a/internal/apiserver/route_post_token_pool.go
+++ b/internal/apiserver/route_post_token_pool.go
@@ -34,14 +34,14 @@ var postTokenPool = &ffapi.Route{
 		{Name: "confirm", Description: coremsgs.APIConfirmQueryParam, IsBool: true},
 	},
 	Description:     coremsgs.APIEndpointsPostTokenPool,
-	JSONInputValue:  func() interface{} { return &core.TokenPool{} },
+	JSONInputValue:  func() interface{} { return &core.TokenPoolInput{} },
 	JSONOutputValue: func() interface{} { return &core.TokenPool{} },
 	JSONOutputCodes: []int{http.StatusAccepted, http.StatusOK},
 	Extensions: &coreExtensions{
 		CoreJSONHandler: func(r *ffapi.APIRequest, cr *coreRequest) (output interface{}, err error) {
 			waitConfirm := strings.EqualFold(r.QP["confirm"], "true")
 			r.SuccessStatus = syncRetcode(waitConfirm)
-			return cr.or.Assets().CreateTokenPool(cr.ctx, r.Input.(*core.TokenPool), waitConfirm)
+			return cr.or.Assets().CreateTokenPool(cr.ctx, r.Input.(*core.TokenPoolInput), waitConfirm)
 		},
 	},
 }
diff --git a/internal/assets/manager.go b/internal/assets/manager.go
index 60a3a09fc..df5d1b27c 100644
--- a/internal/assets/manager.go
+++ b/internal/assets/manager.go
@@ -37,7 +37,7 @@ import (
 type Manager interface {
 	core.Named
 
-	CreateTokenPool(ctx context.Context, pool *core.TokenPool, waitConfirm bool) (*core.TokenPool, error)
+	CreateTokenPool(ctx context.Context, pool *core.TokenPoolInput, waitConfirm bool) (*core.TokenPool, error)
 	ActivateTokenPool(ctx context.Context, pool *core.TokenPool) error
 	GetTokenPools(ctx context.Context, filter database.AndFilter) ([]*core.TokenPool, *database.FilterResult, error)
 	GetTokenPool(ctx context.Context, connector, poolName string) (*core.TokenPool, error)
diff --git a/internal/assets/token_approval.go b/internal/assets/token_approval.go
index 866c4a052..a19ee2e15 100644
--- a/internal/assets/token_approval.go
+++ b/internal/assets/token_approval.go
@@ -98,7 +98,7 @@ func (s *approveSender) sendInternal(ctx context.Context, method sendMethod) (er
 			return nil
 		}
 
-		txid, err := s.mgr.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeTokenApproval)
+		txid, err := s.mgr.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeTokenApproval, s.approval.IdempotencyKey)
 		if err != nil {
 			return err
 		}
diff --git a/internal/assets/token_pool.go b/internal/assets/token_pool.go
index 359be37cd..84e8f3dc5 100644
--- a/internal/assets/token_pool.go
+++ b/internal/assets/token_pool.go
@@ -28,7 +28,7 @@ import (
 	"github.com/hyperledger/firefly/pkg/database"
 )
 
-func (am *assetManager) CreateTokenPool(ctx context.Context, pool *core.TokenPool, waitConfirm bool) (*core.TokenPool, error) {
+func (am *assetManager) CreateTokenPool(ctx context.Context, pool *core.TokenPoolInput, waitConfirm bool) (*core.TokenPool, error) {
 	if err := fftypes.ValidateFFNameFieldNoUUID(ctx, pool.Name, "name"); err != nil {
 		return nil, err
 	}
@@ -56,7 +56,7 @@ func (am *assetManager) CreateTokenPool(ctx context.Context, pool *core.TokenPoo
 	return am.createTokenPoolInternal(ctx, pool, waitConfirm)
 }
 
-func (am *assetManager) createTokenPoolInternal(ctx context.Context, pool *core.TokenPool, waitConfirm bool) (*core.TokenPool, error) {
+func (am *assetManager) createTokenPoolInternal(ctx context.Context, pool *core.TokenPoolInput, waitConfirm bool) (*core.TokenPool, error) {
 	plugin, err := am.selectTokenPlugin(ctx, pool.Connector)
 	if err != nil {
 		return nil, err
@@ -71,7 +71,7 @@ func (am *assetManager) createTokenPoolInternal(ctx context.Context, pool *core.
 
 	var op *core.Operation
 	err = am.database.RunAsGroup(ctx, func(ctx context.Context) (err error) {
-		txid, err := am.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeTokenPool)
+		txid, err := am.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeTokenPool, pool.IdempotencyKey)
 		if err != nil {
 			return err
 		}
@@ -84,7 +84,7 @@ func (am *assetManager) createTokenPoolInternal(ctx context.Context, pool *core.
 			am.namespace,
 			txid,
 			core.OpTypeTokenCreatePool)
-		if err = txcommon.AddTokenPoolCreateInputs(op, pool); err == nil {
+		if err = txcommon.AddTokenPoolCreateInputs(op, &pool.TokenPool); err == nil {
 			err = am.operations.AddOrReuseOperation(ctx, op)
 		}
 		return err
@@ -93,8 +93,8 @@ func (am *assetManager) createTokenPoolInternal(ctx context.Context, pool *core.
 		return nil, err
 	}
 
-	_, err = am.operations.RunOperation(ctx, opCreatePool(op, pool))
-	return pool, err
+	_, err = am.operations.RunOperation(ctx, opCreatePool(op, &pool.TokenPool))
+	return &pool.TokenPool, err
 }
 
 func (am *assetManager) ActivateTokenPool(ctx context.Context, pool *core.TokenPool) error {
diff --git a/internal/assets/token_transfer.go b/internal/assets/token_transfer.go
index 49e4bf197..06651c7ef 100644
--- a/internal/assets/token_transfer.go
+++ b/internal/assets/token_transfer.go
@@ -225,7 +225,7 @@ func (s *transferSender) sendInternal(ctx context.Context, method sendMethod) (e
 			return nil
 		}
 
-		txid, err := s.mgr.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeTokenTransfer)
+		txid, err := s.mgr.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeTokenTransfer, s.transfer.IdempotencyKey)
 		if err != nil {
 			return err
 		}
diff --git a/internal/batch/batch_processor.go b/internal/batch/batch_processor.go
index 658dfa24e..279e9073d 100644
--- a/internal/batch/batch_processor.go
+++ b/internal/batch/batch_processor.go
@@ -562,7 +562,7 @@ func (bp *batchProcessor) sealBatch(state *DispatchState) (err error) {
 			}
 
 			state.Persisted.TX.Type = bp.conf.txType
-			if state.Persisted.TX.ID, err = bp.txHelper.SubmitNewTransaction(ctx, bp.conf.txType); err != nil {
+			if state.Persisted.TX.ID, err = bp.txHelper.SubmitNewTransaction(ctx, bp.conf.txType, "" /* no idempotency key for batch TX */); err != nil {
 				return err
 			}
 			manifest := state.Persisted.GenManifest(state.Messages, state.Data)
diff --git a/internal/broadcast/manager.go b/internal/broadcast/manager.go
index 3a3499a2c..6d68cd492 100644
--- a/internal/broadcast/manager.go
+++ b/internal/broadcast/manager.go
@@ -47,8 +47,8 @@ type Manager interface {
 
 	NewBroadcast(in *core.MessageInOut) syncasync.Sender
 	BroadcastMessage(ctx context.Context, in *core.MessageInOut, waitConfirm bool) (out *core.Message, err error)
-	PublishDataValue(ctx context.Context, id string) (*core.Data, error)
-	PublishDataBlob(ctx context.Context, id string) (*core.Data, error)
+	PublishDataValue(ctx context.Context, id string, idempotencyKey core.IdempotencyKey) (*core.Data, error)
+	PublishDataBlob(ctx context.Context, id string, idempotencyKey core.IdempotencyKey) (*core.Data, error)
 	Start() error
 	WaitStop()
 
@@ -213,14 +213,14 @@ func (bm *broadcastManager) uploadDataBlob(ctx context.Context, tx *fftypes.UUID
 	return err
 }
 
-func (bm *broadcastManager) PublishDataValue(ctx context.Context, id string) (*core.Data, error) {
+func (bm *broadcastManager) PublishDataValue(ctx context.Context, id string, idempotencyKey core.IdempotencyKey) (*core.Data, error) {
 
 	d, err := bm.resolveData(ctx, id)
 	if err != nil {
 		return nil, err
 	}
 
-	txid, err := bm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeDataPublish)
+	txid, err := bm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeDataPublish, idempotencyKey)
 	if err != nil {
 		return nil, err
 	}
@@ -242,14 +242,14 @@ func (bm *broadcastManager) PublishDataValue(ctx context.Context, id string) (*c
 	return d, nil
 }
 
-func (bm *broadcastManager) PublishDataBlob(ctx context.Context, id string) (*core.Data, error) {
+func (bm *broadcastManager) PublishDataBlob(ctx context.Context, id string, idempotencyKey core.IdempotencyKey) (*core.Data, error) {
 
 	d, err := bm.resolveData(ctx, id)
 	if err != nil {
 		return nil, err
 	}
 
-	txid, err := bm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeDataPublish)
+	txid, err := bm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeDataPublish, idempotencyKey)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/contracts/manager.go b/internal/contracts/manager.go
index 9207fefe4..8e64e5d47 100644
--- a/internal/contracts/manager.go
+++ b/internal/contracts/manager.go
@@ -168,7 +168,7 @@ func (cm *contractManager) GetFFIs(ctx context.Context, filter database.AndFilte
 }
 
 func (cm *contractManager) writeInvokeTransaction(ctx context.Context, req *core.ContractCallRequest) (*core.Operation, error) {
-	txid, err := cm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeContractInvoke)
+	txid, err := cm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeContractInvoke, req.IdempotencyKey)
 	if err != nil {
 		return nil, err
 	}
@@ -185,7 +185,7 @@ func (cm *contractManager) writeInvokeTransaction(ctx context.Context, req *core
 }
 
 func (cm *contractManager) writeDeployTransaction(ctx context.Context, req *core.ContractDeployRequest) (*core.Operation, error) {
-	txid, err := cm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeContractDeploy)
+	txid, err := cm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeContractDeploy, req.IdempotencyKey)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/coremsgs/en_error_messages.go b/internal/coremsgs/en_error_messages.go
index 4597b015f..065d1cac0 100644
--- a/internal/coremsgs/en_error_messages.go
+++ b/internal/coremsgs/en_error_messages.go
@@ -268,4 +268,7 @@ var (
 	MsgCacheUnexpectedSizeKeyNameInternal = ffe("FF10427", "could not initialize cache - '%s' is not an expected size configuration key suffix. Expected values are: 'size', 'limit'")
 	MsgUnknownVerifierType                = ffe("FF10428", "Unknown verifier type", 400)
 	MsgNotSupportedByBlockchainPlugin     = ffe("FF10429", "Not supported by blockchain plugin", 400)
+	MsgIdempotencyKeyDuplicateMessage     = ffe("FF10430", "Idempotency key '%s' already used for message '%s'", 409)
+	MsgIdempotencyKeyDuplicateTransaction = ffe("FF10431", "Idempotency key '%s' already used for transaction '%s'", 409)
+	MsgNonIdempotencyKeyConflictTxInsert  = ffe("FF10432", "Conflict on insert of transaction '%s'. No existing transaction matching idempotency key '%s' found", 409)
 )
diff --git a/internal/coremsgs/en_struct_descriptions.go b/internal/coremsgs/en_struct_descriptions.go
index 6895f2182..f9d3369e4 100644
--- a/internal/coremsgs/en_struct_descriptions.go
+++ b/internal/coremsgs/en_struct_descriptions.go
@@ -68,6 +68,7 @@ var (
 	MessageConfirmed      = ffm("Message.confirmed", "The timestamp of when the message was confirmed/rejected")
 	MessageData           = ffm("Message.data", "The list of data elements attached to the message")
 	MessagePins           = ffm("Message.pins", "For private messages, a unique pin hash:nonce is assigned for each topic")
+	MessageIdempotencyKey = ffm("Message.idempotencyKey", "An optional unique identifier for a message. Cannot be duplicated within a namespace, thus allowing idempotent submission of messages to the API. Local only - not transferred when the message is sent to other members of the network")
 
 	// MessageInOut field descriptions
 	MessageInOutData  = ffm("MessageInOut.data", "For input allows you to specify data in-line in the message, that will be turned into data attachments. For output when fetchdata is used on API calls, includes the in-line data payloads of all data attachments")
@@ -170,12 +171,13 @@ var (
 	BatchPersistedConfirmed  = ffm("Batch.confirmed", "The time when the batch was confirmed")
 
 	// Transaction field descriptions
-	TransactionID            = ffm("Transaction.id", "The UUID of the FireFly transaction")
-	TransactionType          = ffm("Transaction.type", "The type of the FireFly transaction")
-	TransactionNamespace     = ffm("Transaction.namespace", "The namespace of the FireFly transaction")
-	TransactionCreated       = ffm("Transaction.created", "The time the transaction was created on this node. Note the transaction is individually created with the same UUID on each participant in the FireFly transaction")
-	TransactionBlockchainID  = ffm("Transaction.blockchainId", "The blockchain transaction ID, in the format specific to the blockchain involved in the transaction. Not all FireFly transactions include a blockchain")
-	TransactionBlockchainIDs = ffm("Transaction.blockchainIds", "The blockchain transaction ID, in the format specific to the blockchain involved in the transaction. Not all FireFly transactions include a blockchain. FireFly transactions are extensible to support multiple blockchain transactions")
+	TransactionID             = ffm("Transaction.id", "The UUID of the FireFly transaction")
+	TransactionType           = ffm("Transaction.type", "The type of the FireFly transaction")
+	TransactionNamespace      = ffm("Transaction.namespace", "The namespace of the FireFly transaction")
+	TransactionCreated        = ffm("Transaction.created", "The time the transaction was created on this node. Note the transaction is individually created with the same UUID on each participant in the FireFly transaction")
+	TransactionIdempotencyKey = ffm("Transaction.idempotencyKey", "An optional unique identifier for a transaction. Cannot be duplicated within a namespace, thus allowing idempotent submission of transactions to the API")
+	TransactionBlockchainID   = ffm("Transaction.blockchainId", "The blockchain transaction ID, in the format specific to the blockchain involved in the transaction. Not all FireFly transactions include a blockchain")
+	TransactionBlockchainIDs  = ffm("Transaction.blockchainIds", "The blockchain transaction ID, in the format specific to the blockchain involved in the transaction. Not all FireFly transactions include a blockchain. FireFly transactions are extensible to support multiple blockchain transactions")
 
 	// Operation field description
 	OperationID          = ffm("Operation.id", "The UUID of the operation")
@@ -522,8 +524,9 @@ var (
 	TokenApprovalBlockchainEvent = ffm("TokenApproval.blockchainEvent", "The UUID of the blockchain event")
 	TokenApprovalConfig          = ffm("TokenApproval.config", "Input only field, with token connector specific configuration of the approval.  See your chosen token connector documentation for details")
 
-	// TokenApprovalInputInput field descriptions
-	TokenApprovalInputInputPool = ffm("TokenApprovalInput.pool", "The name or UUID of a token pool. Required if more than one pool exists.")
+	// TokenApprovalInput field descriptions
+	TokenApprovalInputPool           = ffm("TokenApprovalInput.pool", "The name or UUID of a token pool. Required if more than one pool exists.")
+	TokenApprovalInputIdempotencyKey = ffm("TokenApprovalInput.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
 
 	// TokenBalance field descriptions
 	TokenBalancePool       = ffm("TokenBalance.pool", "The UUID the token pool this balance entry applies to")
@@ -556,6 +559,9 @@ var (
 	TokenPoolInfo      = ffm("TokenPool.info", "Token connector specific information about the pool. See your chosen token connector documentation for details")
 	TokenPoolTX        = ffm("TokenPool.tx", "Reference to the FireFly transaction used to create and broadcast this pool to the network")
 
+	// TokenPoolInput field descriptions
+	TokenPoolInputIdempotencyKey = ffm("TokenPoolInput.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
+
 	// TokenTransfer field descriptions
 	TokenTransferType            = ffm("TokenTransfer.type", "The type of transfer such as mint/burn/transfer")
 	TokenTransferLocalID         = ffm("TokenTransfer.localId", "The UUID of this token transfer, in the local FireFly node")
@@ -577,8 +583,9 @@ var (
 	TokenTransferConfig          = ffm("TokenTransfer.config", "Input only field, with token connector specific configuration of the transfer. See your chosen token connector documentation for details")
 
 	// TokenTransferInput field descriptions
-	TokenTransferInputMessage = ffm("TokenTransferInput.message", "You can specify a message to correlate with the transfer, which can be of type broadcast or private. Your chosen token connector and on-chain smart contract must support on-chain/off-chain correlation by taking a `data` input on the transfer")
-	TokenTransferInputPool    = ffm("TokenTransferInput.pool", "The name or UUID of a token pool")
+	TokenTransferInputMessage        = ffm("TokenTransferInput.message", "You can specify a message to correlate with the transfer, which can be of type broadcast or private. Your chosen token connector and on-chain smart contract must support on-chain/off-chain correlation by taking a `data` input on the transfer")
+	TokenTransferInputPool           = ffm("TokenTransferInput.pool", "The name or UUID of a token pool")
+	TokenTransferInputIdempotencyKey = ffm("TokenTransferInput.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
 
 	// TransactionStatus field descriptions
 	TransactionStatusStatus  = ffm("TransactionStatus.status", "The overall computed status of the transaction, after analyzing the details during the API call")
@@ -594,11 +601,12 @@ var (
 	TransactionStatusDetailsInfo      = ffm("TransactionStatusDetails.info", "Output details for this entry")
 
 	// ContractDeployRequest field descriptions
-	ContractDeployRequestKey        = ffm("ContractDeployRequest.key", "The blockchain signing key that will be used to deploy the contract. Defaults to the first signing key of the organization that operates the node")
-	ContractDeployRequestInput      = ffm("ContractDeployRequest.input", "An optional array of inputs passed to the smart contract's constructor, if applicable")
-	ContractDeployRequestDefinition = ffm("ContractDeployRequest.definition", "The definition of the smart contract")
-	ContractDeployRequestContract   = ffm("ContractDeployRequest.contract", "The smart contract to deploy. This should be pre-compiled if required by the blockchain connector")
-	ContractDeployRequestOptions    = ffm("ContractDeployRequest.options", "A map of named inputs that will be passed through to the blockchain connector")
+	ContractDeployRequestKey            = ffm("ContractDeployRequest.key", "The blockchain signing key that will be used to deploy the contract. Defaults to the first signing key of the organization that operates the node")
+	ContractDeployRequestInput          = ffm("ContractDeployRequest.input", "An optional array of inputs passed to the smart contract's constructor, if applicable")
+	ContractDeployRequestDefinition     = ffm("ContractDeployRequest.definition", "The definition of the smart contract")
+	ContractDeployRequestContract       = ffm("ContractDeployRequest.contract", "The smart contract to deploy. This should be pre-compiled if required by the blockchain connector")
+	ContractDeployRequestOptions        = ffm("ContractDeployRequest.options", "A map of named inputs that will be passed through to the blockchain connector")
+	ContractDeployRequestIdempotencyKey = ffm("ContractDeployRequest.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
 
 	// ContractCallRequest field descriptions
 	ContractCallRequestType       = ffm("ContractCallRequest.type", "Invocations cause transactions on the blockchain. Whereas queries simply execute logic in your local node to query data at a given current/historical block")
@@ -609,6 +617,7 @@ var (
 	ContractCallRequestMethodPath = ffm("ContractCallRequest.methodPath", "The pathname of the method on the specified FFI")
 	ContractCallRequestInput      = ffm("ContractCallRequest.input", "A map of named inputs. The name and type of each input must be compatible with the FFI description of the method, so that FireFly knows how to serialize it to the blockchain via the connector")
 	ContractCallRequestOptions    = ffm("ContractCallRequest.options", "A map of named inputs that will be passed through to the blockchain connector")
+	ContractCallIdempotencyKey    = ffm("ContractCallRequest.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
 
 	// WebSocketStatus field descriptions
 	WebSocketStatusEnabled     = ffm("WebSocketStatus.enabled", "Indicates whether the websockets plugin is enabled")
@@ -640,4 +649,7 @@ var (
 	WebhooksOptInputBody    = ffm("WebhookInputOptions.body", "A top-level property of the first data input, to use for the request body. Default is the whole first body")
 	WebhooksOptInputPath    = ffm("WebhookInputOptions.path", "A top-level property of the first data input, to use for a path to append with escaping to the webhook path")
 	WebhooksOptInputReplyTx = ffm("WebhookInputOptions.replytx", "A top-level property of the first data input, to use to dynamically set whether to pin the response (so the requester can choose)")
+
+	// PublishInput field descriptions
+	PublishInputIdempotencyKey = ffm("PublishInput.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
 )
diff --git a/internal/data/message_writer.go b/internal/data/message_writer.go
index ea6f96128..62d183910 100644
--- a/internal/data/message_writer.go
+++ b/internal/data/message_writer.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"time"
 
+	"github.com/hyperledger/firefly-common/pkg/fftypes"
 	"github.com/hyperledger/firefly-common/pkg/i18n"
 	"github.com/hyperledger/firefly-common/pkg/log"
 	"github.com/hyperledger/firefly/internal/coremsgs"
@@ -36,6 +37,7 @@ type NewMessage struct {
 // writeRequest is a combination of a message and a list of data that is new and needs to be
 // inserted into the database.
 type writeRequest struct {
+	id         *fftypes.UUID
 	newMessage *core.Message
 	newData    core.DataArray
 	result     chan error
@@ -44,7 +46,7 @@ type writeRequest struct {
 type messageWriterBatch struct {
 	messages       []*core.Message
 	data           core.DataArray
-	listeners      []chan error
+	listeners      map[fftypes.UUID]chan error
 	timeoutContext context.Context
 	timeoutCancel  func()
 }
@@ -89,9 +91,11 @@ func newMessageWriter(ctx context.Context, di database.Plugin, conf *messageWrit
 // worker count will dispatch the work to the pool and wait for it to complete on a background
 // transaction, or just run it in-line on the context passed ini.
 func (mw *messageWriter) WriteNewMessage(ctx context.Context, newMsg *NewMessage) error {
+	log.L(ctx).Debugf("Writing message type=%s id=%s hash=%s idempotencyKey=%s concurrency=%d", newMsg.Message.Header.Type, newMsg.Message.Header.ID, newMsg.Message.Hash, newMsg.Message.IdempotencyKey, mw.conf.workerCount)
 	if mw.conf.workerCount > 0 {
 		// Dispatch to background worker
 		nmi := &writeRequest{
+			id:         newMsg.Message.Message.Header.ID,
 			newMessage: &newMsg.Message.Message,
 			newData:    newMsg.NewData,
 			result:     make(chan error),
@@ -104,9 +108,15 @@ func (mw *messageWriter) WriteNewMessage(ctx context.Context, newMsg *NewMessage
 		return <-nmi.result
 	}
 	// Otherwise do it in-line on this context
-	return mw.database.RunAsGroup(ctx, func(ctx context.Context) error {
+	err := mw.database.RunAsGroup(ctx, func(ctx context.Context) error {
 		return mw.writeMessages(ctx, []*core.Message{&newMsg.Message.Message}, newMsg.NewData)
 	})
+	if err != nil {
+		if idempotencyErr := mw.checkIdempotencyDuplicate(ctx, &newMsg.Message.Message); idempotencyErr != nil {
+			return idempotencyErr
+		}
+	}
+	return err
 }
 
 // WriteData writes a piece of data independently of a message
@@ -114,6 +124,7 @@ func (mw *messageWriter) WriteData(ctx context.Context, data *core.Data) error {
 	if mw.conf.workerCount > 0 {
 		// Dispatch to background worker
 		nmi := &writeRequest{
+			id:      data.ID,
 			newData: core.DataArray{data},
 			result:  make(chan error),
 		}
@@ -154,29 +165,104 @@ func (mw *messageWriter) writerLoop(index int) {
 		select {
 		case work := <-mw.workQueue:
 			if batch == nil {
-				batch = &messageWriterBatch{}
+				batch = &messageWriterBatch{
+					listeners: make(map[fftypes.UUID]chan error),
+				}
 				batch.timeoutContext, batch.timeoutCancel = context.WithTimeout(mw.ctx, mw.conf.batchTimeout)
 			}
 			if work.newMessage != nil {
 				batch.messages = append(batch.messages, work.newMessage)
 			}
 			batch.data = append(batch.data, work.newData...)
-			batch.listeners = append(batch.listeners, work.result)
+			batch.listeners[*work.id] = work.result
 		case <-timeoutContext.Done():
 			timedOut = true
 		}
 
 		if batch != nil && (timedOut || (len(batch.messages)+len(batch.data) >= mw.conf.maxInserts)) {
 			batch.timeoutCancel()
-			err := mw.database.RunAsGroup(mw.ctx, func(ctx context.Context) error {
+			mw.persistMWBatch(batch)
+			batch = nil
+		}
+	}
+}
+
+func (mw *messageWriter) persistMWBatch(batch *messageWriterBatch) {
+	err := mw.database.RunAsGroup(mw.ctx, func(ctx context.Context) error {
+		return mw.writeMessages(ctx, batch.messages, batch.data)
+	})
+	if err != nil {
+		log.L(mw.ctx).Errorf("Failed batch message insert (pre-idempotency check): %s", err)
+		duplicatesRemoved := mw.removeIdempotencyDuplicates(mw.ctx, batch)
+		if duplicatesRemoved > 0 && (len(batch.messages) > 0 || len(batch.data) > 0) {
+			// We have a reduced scope batch to retry, with some duplicates removed
+			log.L(mw.ctx).Infof("Retrying batch insert after removing %d idempotency duplicates", duplicatesRemoved)
+			err = mw.database.RunAsGroup(mw.ctx, func(ctx context.Context) error {
 				return mw.writeMessages(ctx, batch.messages, batch.data)
 			})
-			for _, l := range batch.listeners {
-				l <- err
+		}
+	}
+	for _, l := range batch.listeners {
+		l <- err
+	}
+}
+
+func (mw *messageWriter) checkIdempotencyDuplicate(ctx context.Context, m *core.Message) error {
+	if m.IdempotencyKey != "" {
+		fb := database.MessageQueryFactory.NewFilter(ctx)
+		existing, _, err := mw.database.GetMessages(ctx, m.Header.Namespace, fb.Eq("idempotencykey", (string)(m.IdempotencyKey)))
+		if err != nil {
+			// Don't overwrite the original error for this - return -1 to the caller, who will return the previous error
+			log.L(mw.ctx).Errorf("Failed checking for idempotency errors: %s", err)
+			return nil
+		}
+		if len(existing) > 0 {
+			return i18n.NewError(ctx, coremsgs.MsgIdempotencyKeyDuplicateMessage, m.IdempotencyKey, existing[0].Header.ID)
+		}
+	}
+	return nil
+}
+
+func (mw *messageWriter) removeIdempotencyDuplicates(ctx context.Context, batch *messageWriterBatch) int {
+	duplicatesRemoved := 0
+	newMessageList := make([]*core.Message, 0, len(batch.messages))
+
+	// Spin through the messages in the batch, looking for existing messages that
+	for _, m := range batch.messages {
+		dupRemoved := false
+		idempotencyErr := mw.checkIdempotencyDuplicate(ctx, m)
+		if idempotencyErr != nil {
+			// We have an idempotency duplicate - we need to remove it from the batch so we can retry the rest
+			dupRemoved = true
+			if listener := batch.listeners[*m.Header.ID]; listener != nil {
+				// Notify the listener for this message,
+				listener <- idempotencyErr
 			}
-			batch = nil
+			delete(batch.listeners, *m.Header.ID)
+			// Remove all the data associated with this message from the batch
+			newData := make([]*core.Data, 0, len(batch.data))
+			for _, d := range batch.data {
+				isRef := false
+				for _, dr := range m.Data {
+					if dr.ID.Equals(d.ID) {
+						isRef = true
+						break
+					}
+				}
+				if !isRef {
+					newData = append(newData, d)
+				}
+			}
+			batch.data = newData
+		}
+		if dupRemoved {
+			duplicatesRemoved++
+		} else {
+			newMessageList = append(newMessageList, m)
 		}
 	}
+	batch.messages = newMessageList
+	return duplicatesRemoved
 }
 
 func (mw *messageWriter) writeMessages(ctx context.Context, msgs []*core.Message, data core.DataArray) error {
diff --git a/internal/database/sqlcommon/message_sql.go b/internal/database/sqlcommon/message_sql.go
index 0e78ef148..bb5d7448e 100644
--- a/internal/database/sqlcommon/message_sql.go
+++ b/internal/database/sqlcommon/message_sql.go
@@ -50,12 +50,14 @@ var (
 		"confirmed",
 		"tx_type",
 		"batch_id",
+		"idempotency_key",
 	}
 	msgFilterFieldMap = map[string]string{
-		"type":   "mtype",
-		"txtype": "tx_type",
-		"batch":  "batch_id",
-		"group":  "group_hash",
+		"type":           "mtype",
+		"txtype":         "tx_type",
+		"batch":          "batch_id",
+		"group":          "group_hash",
+		"idempotencykey": "idempotency_key",
 	}
 )
 
@@ -80,6 +82,7 @@ func (s *SQLCommon) attemptMessageUpdate(ctx context.Context, tx *txWrapper, mes
 			Set("confirmed", message.Confirmed).
 			Set("tx_type", message.Header.TxType).
 			Set("batch_id", message.BatchID).
+			Set("idempotency_key", message.IdempotencyKey).
 			Where(sq.Eq{
 				"id":              message.Header.ID,
 				"hash":            message.Hash,
@@ -111,6 +114,7 @@ func (s *SQLCommon) setMessageInsertValues(query sq.InsertBuilder, message *core
 		message.Confirmed,
 		message.Header.TxType,
 		message.BatchID,
+		message.IdempotencyKey,
 	)
 }
 
@@ -420,6 +424,7 @@ func (s *SQLCommon) msgResult(ctx context.Context, row *sql.Rows) (*core.Message
 		&msg.Confirmed,
 		&msg.Header.TxType,
 		&msg.BatchID,
+		&msg.IdempotencyKey,
 		// Must be added to the list of columns in all selects
 		&msg.Sequence,
 	)
diff --git a/internal/database/sqlcommon/transaction_sql.go b/internal/database/sqlcommon/transaction_sql.go
index f9f9fbe74..6038acd41 100644
--- a/internal/database/sqlcommon/transaction_sql.go
+++ b/internal/database/sqlcommon/transaction_sql.go
@@ -35,11 +35,13 @@ var (
 		"ttype",
 		"namespace",
 		"created",
+		"idempotency_key",
 		"blockchain_ids",
 	}
 	transactionFilterFieldMap = map[string]string{
-		"type":          "ttype",
-		"blockchainids": "blockchain_ids",
+		"type":           "ttype",
+		"idempotencykey": "idempotency_key",
+		"blockchainids":  "blockchain_ids",
 	}
 )
 
@@ -53,7 +55,8 @@ func (s *SQLCommon) InsertTransaction(ctx context.Context, transaction *core.Tra
 	defer s.rollbackTx(ctx, tx, autoCommit)
 
 	transaction.Created = fftypes.Now()
-	if _, err = s.insertTx(ctx, transactionsTable, tx,
+	var seq int64
+	if seq, err = s.insertTxExt(ctx, transactionsTable, tx,
 		sq.Insert(transactionsTable).
 			Columns(transactionColumns...).
 			Values(
@@ -61,12 +64,25 @@ func (s *SQLCommon) InsertTransaction(ctx context.Context, transaction *core.Tra
 				string(transaction.Type),
 				transaction.Namespace,
 				transaction.Created,
+				transaction.IdempotencyKey,
 				transaction.BlockchainIDs,
 			),
 		func() {
 			s.callbacks.UUIDCollectionNSEvent(database.CollectionTransactions, core.ChangeEventTypeCreated, transaction.Namespace, transaction.ID)
 		},
-	); err != nil {
+		transaction.IdempotencyKey != "", // on conflict we want to check for idempotency key mismatch to return a useful error
+	); err != nil || seq < 0 {
+		// Check for a duplicate idempotency key
+		if transaction.IdempotencyKey != "" {
+			fb := database.TransactionQueryFactory.NewFilter(ctx)
+			existing, _, _ := s.GetTransactions(ctx, transaction.Namespace, fb.Eq("idempotencykey", (string)(transaction.IdempotencyKey)))
+			if len(existing) > 0 {
+				return i18n.NewError(ctx, coremsgs.MsgIdempotencyKeyDuplicateTransaction, transaction.IdempotencyKey, existing[0].ID)
+			} else if err == nil {
+				// If we don't have an error, and we didn't find an existing idempotency key match, then we don't know the reason for the conflict
+				return i18n.NewError(ctx, coremsgs.MsgNonIdempotencyKeyConflictTxInsert, transaction.ID, transaction.IdempotencyKey)
+			}
+		}
 		return err
 	}
 
@@ -80,6 +96,7 @@ func (s *SQLCommon) transactionResult(ctx context.Context, row *sql.Rows) (*core
 		&transaction.Type,
 		&transaction.Namespace,
 		&transaction.Created,
+		&transaction.IdempotencyKey,
 		&transaction.BlockchainIDs,
 	)
 	if err != nil {
diff --git a/internal/events/persist_batch.go b/internal/events/persist_batch.go
index 4972cdf3d..2896256c4 100644
--- a/internal/events/persist_batch.go
+++ b/internal/events/persist_batch.go
@@ -208,6 +208,8 @@ func (em *eventManager) validateBatchMessage(ctx context.Context, batch *core.Ba
 	}
 	// Set the state to pending, for the insertion stage
 	msg.State = core.MessageStatePending
+	// Remove any idempotency key
+	msg.IdempotencyKey = ""
 
 	return true
 }
@@ -278,7 +280,7 @@ func (em *eventManager) persistBatchContent(ctx context.Context, batch *core.Bat
 		}
 	}
 
-	// Then the same one-shot insert of all the mesages, on the basis they are likely unique (even if
+	// Then the same one-shot insert of all the messages, on the basis they are likely unique (even if
 	// one of the data elements wasn't unique). Likely reasons for exceptions here are idempotent replay,
 	// or a root broadcast where "em.sentByUs" returned false, but we actually sent it.
 	err = em.database.InsertMessages(ctx, batch.Payload.Messages, func() {
diff --git a/internal/multiparty/manager.go b/internal/multiparty/manager.go
index b2868e537..83ed4dc30 100644
--- a/internal/multiparty/manager.go
+++ b/internal/multiparty/manager.go
@@ -214,7 +214,7 @@ func (mm *multipartyManager) SubmitNetworkAction(ctx context.Context, signingKey
 		return i18n.NewError(ctx, coremsgs.MsgUnrecognizedNetworkAction, action.Type)
 	}
 
-	txid, err := mm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeNetworkAction)
+	txid, err := mm.txHelper.SubmitNewTransaction(ctx, core.TransactionTypeNetworkAction, "")
 	if err != nil {
 		return err
 	}
diff --git a/internal/txcommon/txcommon.go b/internal/txcommon/txcommon.go
index b2e512c80..e04bbd297 100644
--- a/internal/txcommon/txcommon.go
+++ b/internal/txcommon/txcommon.go
@@ -30,7 +30,7 @@ import (
 )
 
 type Helper interface {
-	SubmitNewTransaction(ctx context.Context, txType core.TransactionType) (*fftypes.UUID, error)
+	SubmitNewTransaction(ctx context.Context, txType core.TransactionType, idempotencyKey core.IdempotencyKey) (*fftypes.UUID, error)
 	PersistTransaction(ctx context.Context, id *fftypes.UUID, txType core.TransactionType, blockchainTXID string) (valid bool, err error)
 	AddBlockchainTX(ctx context.Context, tx *core.Transaction, blockchainTXID string) error
 	InsertOrGetBlockchainEvent(ctx context.Context, event *core.BlockchainEvent) (existing *core.BlockchainEvent, err error)
@@ -102,14 +102,16 @@ func (t *transactionHelper) GetTransactionByIDCached(ctx context.Context, id *ff
 }
 
 // SubmitNewTransaction is called when there is a new transaction being submitted by the local node
-func (t *transactionHelper) SubmitNewTransaction(ctx context.Context, txType core.TransactionType) (*fftypes.UUID, error) {
+func (t *transactionHelper) SubmitNewTransaction(ctx context.Context, txType core.TransactionType, idempotencyKey core.IdempotencyKey) (*fftypes.UUID, error) {
 
 	tx := &core.Transaction{
-		ID:        fftypes.NewUUID(),
-		Namespace: t.namespace,
-		Type:      txType,
+		ID:             fftypes.NewUUID(),
+		Namespace:      t.namespace,
+		Type:           txType,
+		IdempotencyKey: idempotencyKey,
 	}
 
+	// Note that InsertTransaction is responsible for idempotency key duplicate detection and helpful error creation
 	if err := t.database.InsertTransaction(ctx, tx); err != nil {
 		return nil, err
 	}
diff --git a/mocks/assetmocks/manager.go b/mocks/assetmocks/manager.go
index f260b9223..e1a2d86bf 100644
--- a/mocks/assetmocks/manager.go
+++ b/mocks/assetmocks/manager.go
@@ -58,11 +58,11 @@ func (_m *Manager) BurnTokens(ctx context.Context, transfer *core.TokenTransferI
 }
 
 // CreateTokenPool provides a mock function with given fields: ctx, pool, waitConfirm
-func (_m *Manager) CreateTokenPool(ctx context.Context, pool *core.TokenPool, waitConfirm bool) (*core.TokenPool, error) {
+func (_m *Manager) CreateTokenPool(ctx context.Context, pool *core.TokenPoolInput, waitConfirm bool) (*core.TokenPool, error) {
 	ret := _m.Called(ctx, pool, waitConfirm)
 
 	var r0 *core.TokenPool
-	if rf, ok := ret.Get(0).(func(context.Context, *core.TokenPool, bool) *core.TokenPool); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *core.TokenPoolInput, bool) *core.TokenPool); ok {
 		r0 = rf(ctx, pool, waitConfirm)
 	} else {
 		if ret.Get(0) != nil {
@@ -71,7 +71,7 @@ func (_m *Manager) CreateTokenPool(ctx context.Context, pool *core.TokenPool, wa
 	}
 
 	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, *core.TokenPool, bool) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, *core.TokenPoolInput, bool) error); ok {
 		r1 = rf(ctx, pool, waitConfirm)
 	} else {
 		r1 = ret.Error(1)
diff --git a/mocks/broadcastmocks/manager.go b/mocks/broadcastmocks/manager.go
index 5f488a77c..770dc5361 100644
--- a/mocks/broadcastmocks/manager.go
+++ b/mocks/broadcastmocks/manager.go
@@ -94,13 +94,13 @@ func (_m *Manager) PrepareOperation(ctx context.Context, op *core.Operation) (*c
 	return r0, r1
 }
 
-// PublishDataBlob provides a mock function with given fields: ctx, id
-func (_m *Manager) PublishDataBlob(ctx context.Context, id string) (*core.Data, error) {
-	ret := _m.Called(ctx, id)
+// PublishDataBlob provides a mock function with given fields: ctx, id, idempotencyKey
+func (_m *Manager) PublishDataBlob(ctx context.Context, id string, idempotencyKey core.IdempotencyKey) (*core.Data, error) {
+	ret := _m.Called(ctx, id, idempotencyKey)
 
 	var r0 *core.Data
-	if rf, ok := ret.Get(0).(func(context.Context, string) *core.Data); ok {
-		r0 = rf(ctx, id)
+	if rf, ok := ret.Get(0).(func(context.Context, string, core.IdempotencyKey) *core.Data); ok {
+		r0 = rf(ctx, id, idempotencyKey)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*core.Data)
@@ -108,8 +108,8 @@ func (_m *Manager) PublishDataBlob(ctx context.Context, id string) (*core.Data,
 	}
 
 	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
-		r1 = rf(ctx, id)
+	if rf, ok := ret.Get(1).(func(context.Context, string, core.IdempotencyKey) error); ok {
+		r1 = rf(ctx, id, idempotencyKey)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -117,13 +117,13 @@ func (_m *Manager) PublishDataBlob(ctx context.Context, id string) (*core.Data,
 	return r0, r1
 }
 
-// PublishDataValue provides a mock function with given fields: ctx, id
-func (_m *Manager) PublishDataValue(ctx context.Context, id string) (*core.Data, error) {
-	ret := _m.Called(ctx, id)
+// PublishDataValue provides a mock function with given fields: ctx, id, idempotencyKey
+func (_m *Manager) PublishDataValue(ctx context.Context, id string, idempotencyKey core.IdempotencyKey) (*core.Data, error) {
+	ret := _m.Called(ctx, id, idempotencyKey)
 
 	var r0 *core.Data
-	if rf, ok := ret.Get(0).(func(context.Context, string) *core.Data); ok {
-		r0 = rf(ctx, id)
+	if rf, ok := ret.Get(0).(func(context.Context, string, core.IdempotencyKey) *core.Data); ok {
+		r0 = rf(ctx, id, idempotencyKey)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*core.Data)
@@ -131,8 +131,8 @@ func (_m *Manager) PublishDataValue(ctx context.Context, id string) (*core.Data,
 	}
 
 	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
-		r1 = rf(ctx, id)
+	if rf, ok := ret.Get(1).(func(context.Context, string, core.IdempotencyKey) error); ok {
+		r1 = rf(ctx, id, idempotencyKey)
 	} else {
 		r1 = ret.Error(1)
 	}
diff --git a/mocks/txcommonmocks/helper.go b/mocks/txcommonmocks/helper.go
index c6625229f..2d289053e 100644
--- a/mocks/txcommonmocks/helper.go
+++ b/mocks/txcommonmocks/helper.go
@@ -120,13 +120,13 @@ func (_m *Helper) PersistTransaction(ctx context.Context, id *fftypes.UUID, txTy
 	return r0, r1
 }
 
-// SubmitNewTransaction provides a mock function with given fields: ctx, txType
-func (_m *Helper) SubmitNewTransaction(ctx context.Context, txType fftypes.FFEnum) (*fftypes.UUID, error) {
-	ret := _m.Called(ctx, txType)
+// SubmitNewTransaction provides a mock function with given fields: ctx, txType, idempotencyKey
+func (_m *Helper) SubmitNewTransaction(ctx context.Context, txType fftypes.FFEnum, idempotencyKey core.IdempotencyKey) (*fftypes.UUID, error) {
+	ret := _m.Called(ctx, txType, idempotencyKey)
 
 	var r0 *fftypes.UUID
-	if rf, ok := ret.Get(0).(func(context.Context, fftypes.FFEnum) *fftypes.UUID); ok {
-		r0 = rf(ctx, txType)
+	if rf, ok := ret.Get(0).(func(context.Context, fftypes.FFEnum, core.IdempotencyKey) *fftypes.UUID); ok {
+		r0 = rf(ctx, txType, idempotencyKey)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*fftypes.UUID)
@@ -134,8 +134,8 @@ func (_m *Helper) SubmitNewTransaction(ctx context.Context, txType fftypes.FFEnu
 	}
 
 	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, fftypes.FFEnum) error); ok {
-		r1 = rf(ctx, txType)
+	if rf, ok := ret.Get(1).(func(context.Context, fftypes.FFEnum, core.IdempotencyKey) error); ok {
+		r1 = rf(ctx, txType, idempotencyKey)
 	} else {
 		r1 = ret.Error(1)
 	}
diff --git a/pkg/core/contracts.go b/pkg/core/contracts.go
index c3f2988b0..59975f2f2 100644
--- a/pkg/core/contracts.go
+++ b/pkg/core/contracts.go
@@ -32,22 +32,24 @@ var (
 )
 
 type ContractCallRequest struct {
-	Type       ContractCallType       `ffstruct:"ContractCallRequest" json:"type,omitempty" ffenum:"contractcalltype" ffexcludeinput:"true"`
-	Interface  *fftypes.UUID          `ffstruct:"ContractCallRequest" json:"interface,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
-	Location   *fftypes.JSONAny       `ffstruct:"ContractCallRequest" json:"location,omitempty"`
-	Key        string                 `ffstruct:"ContractCallRequest" json:"key,omitempty"`
-	Method     *fftypes.FFIMethod     `ffstruct:"ContractCallRequest" json:"method,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
-	MethodPath string                 `ffstruct:"ContractCallRequest" json:"methodPath,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
-	Input      map[string]interface{} `ffstruct:"ContractCallRequest" json:"input"`
-	Options    map[string]interface{} `ffstruct:"ContractCallRequest" json:"options"`
+	Type           ContractCallType       `ffstruct:"ContractCallRequest" json:"type,omitempty" ffenum:"contractcalltype" ffexcludeinput:"true"`
+	Interface      *fftypes.UUID          `ffstruct:"ContractCallRequest" json:"interface,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
+	Location       *fftypes.JSONAny       `ffstruct:"ContractCallRequest" json:"location,omitempty"`
+	Key            string                 `ffstruct:"ContractCallRequest" json:"key,omitempty"`
+	Method         *fftypes.FFIMethod     `ffstruct:"ContractCallRequest" json:"method,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
+	MethodPath     string                 `ffstruct:"ContractCallRequest" json:"methodPath,omitempty" ffexcludeinput:"postContractAPIInvoke,postContractAPIQuery"`
+	Input          map[string]interface{} `ffstruct:"ContractCallRequest" json:"input"`
+	Options        map[string]interface{} `ffstruct:"ContractCallRequest" json:"options"`
+	IdempotencyKey IdempotencyKey         `ffstruct:"ContractCallRequest" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
 }
 
 type ContractDeployRequest struct {
-	Key        string                 `ffstruct:"ContractDeployRequest" json:"key,omitempty"`
-	Input      []interface{}          `ffstruct:"ContractDeployRequest" json:"input"`
-	Definition *fftypes.JSONAny       `ffstruct:"ContractDeployRequest" json:"definition"`
-	Contract   *fftypes.JSONAny       `ffstruct:"ContractDeployRequest" json:"contract"`
-	Options    map[string]interface{} `ffstruct:"ContractDeployRequest" json:"options"`
+	Key            string                 `ffstruct:"ContractDeployRequest" json:"key,omitempty"`
+	Input          []interface{}          `ffstruct:"ContractDeployRequest" json:"input"`
+	Definition     *fftypes.JSONAny       `ffstruct:"ContractDeployRequest" json:"definition"`
+	Contract       *fftypes.JSONAny       `ffstruct:"ContractDeployRequest" json:"contract"`
+	Options        map[string]interface{} `ffstruct:"ContractDeployRequest" json:"options"`
+	IdempotencyKey IdempotencyKey         `ffstruct:"ContractDeployRequest" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
 }
 
 type ContractURLs struct {
diff --git a/pkg/core/idempotency_key.go b/pkg/core/idempotency_key.go
new file mode 100644
index 000000000..d49cfa319
--- /dev/null
+++ b/pkg/core/idempotency_key.go
@@ -0,0 +1,50 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package core
+
+import (
+	"context"
+	"database/sql/driver"
+
+	"github.com/hyperledger/firefly-common/pkg/i18n"
+)
+
+// IdempotencyKey is accessed in Go as a string, but when persisted to storage it will be stored as a null
+// to allow multiple entries in a unique index to exist with the same un-set idempotency key.
+type IdempotencyKey string
+
+func (ik IdempotencyKey) Value() (driver.Value, error) {
+	if ik == "" {
+		return nil, nil
+	}
+	return (string)(ik), nil
+}
+
+func (ik *IdempotencyKey) Scan(src interface{}) error {
+	switch src := src.(type) {
+	case nil:
+		return nil
+	case []byte:
+		*ik = IdempotencyKey(src)
+		return nil
+	case string:
+		*ik = IdempotencyKey(src)
+		return nil
+	default:
+		return i18n.NewError(context.Background(), i18n.MsgTypeRestoreFailed, src, ik)
+	}
+}
diff --git a/pkg/core/message.go b/pkg/core/message.go
index cbe4155fe..019207fff 100644
--- a/pkg/core/message.go
+++ b/pkg/core/message.go
@@ -94,11 +94,16 @@ type Message struct {
 	Confirmed      *fftypes.FFTime  `ffstruct:"Message" json:"confirmed,omitempty" ffexcludeinput:"true"`
 	Data           DataRefs         `ffstruct:"Message" json:"data" ffexcludeinput:"true"`
 	Pins           FFStringArray    `ffstruct:"Message" json:"pins,omitempty" ffexcludeinput:"true"`
+	IdempotencyKey IdempotencyKey   `ffstruct:"Message" json:"idempotencyKey,omitempty"`
 	Sequence       int64            `ffstruct:"Message" json:"-"` // Local database sequence used internally for batch assembly
 }
 
 // BatchMessage is the fields in a message record that are assured to be consistent on all parties.
 // This is what is transferred and hashed in a batch payload between nodes.
+//
+// Fields such as the idempotencyKey do NOT transfer, as these are meant for local processing of messages before being sent.
+//
+// Fields such as the state/confirmed do NOT transfer, as these are calculated individually by each member.
 func (m *Message) BatchMessage() *Message {
 	return &Message{
 		Header: m.Header,
diff --git a/pkg/core/publish.go b/pkg/core/publish.go
new file mode 100644
index 000000000..87353c5ce
--- /dev/null
+++ b/pkg/core/publish.go
@@ -0,0 +1,21 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package core
+
+type PublishInput struct {
+	IdempotencyKey IdempotencyKey `ffstruct:"PublishInput" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
+}
diff --git a/pkg/core/tokenapproval.go b/pkg/core/tokenapproval.go
index a3a6f2a7d..d9938efcc 100644
--- a/pkg/core/tokenapproval.go
+++ b/pkg/core/tokenapproval.go
@@ -20,7 +20,8 @@ import "github.com/hyperledger/firefly-common/pkg/fftypes"
 
 type TokenApprovalInput struct {
 	TokenApproval
-	Pool string `ffstruct:"TokenApprovalInput" json:"pool,omitempty"`
+	Pool           string         `ffstruct:"TokenApprovalInput" json:"pool,omitempty" ffexcludeoutput:"true"`
+	IdempotencyKey IdempotencyKey `ffstruct:"TokenApprovalInput" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
 }
 
 type TokenApproval struct {
diff --git a/pkg/core/tokenpool.go b/pkg/core/tokenpool.go
index c858bc5d2..6886105c0 100644
--- a/pkg/core/tokenpool.go
+++ b/pkg/core/tokenpool.go
@@ -42,6 +42,11 @@ var (
 	TokenPoolStateConfirmed = fftypes.FFEnumValue("tokenpoolstate", "confirmed")
 )
 
+type TokenPoolInput struct {
+	TokenPool
+	IdempotencyKey IdempotencyKey `ffstruct:"TokenPoolInput" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
+}
+
 type TokenPool struct {
 	ID        *fftypes.UUID      `ffstruct:"TokenPool" json:"id,omitempty" ffexcludeinput:"true"`
 	Type      TokenType          `ffstruct:"TokenPool" json:"type" ffenum:"tokentype"`
diff --git a/pkg/core/tokentransfer.go b/pkg/core/tokentransfer.go
index 0c3840dfc..ac59d32d6 100644
--- a/pkg/core/tokentransfer.go
+++ b/pkg/core/tokentransfer.go
@@ -49,6 +49,7 @@ type TokenTransfer struct {
 
 type TokenTransferInput struct {
 	TokenTransfer
-	Message *MessageInOut `ffstruct:"TokenTransferInput" json:"message,omitempty"`
-	Pool    string        `ffstruct:"TokenTransferInput" json:"pool,omitempty"`
+	Message        *MessageInOut  `ffstruct:"TokenTransferInput" json:"message,omitempty"`
+	Pool           string         `ffstruct:"TokenTransferInput" json:"pool,omitempty"`
+	IdempotencyKey IdempotencyKey `ffstruct:"TokenTransferInput" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
 }
diff --git a/pkg/core/transaction.go b/pkg/core/transaction.go
index d107b0a8a..3b5eb02eb 100644
--- a/pkg/core/transaction.go
+++ b/pkg/core/transaction.go
@@ -61,11 +61,12 @@ type BlockchainTransactionRef struct {
 // Transaction is a unit of work sent or received by this node
 // It serves as a container for one or more Operations, BlockchainEvents, and other related objects
 type Transaction struct {
-	ID            *fftypes.UUID   `ffstruct:"Transaction" json:"id,omitempty"`
-	Namespace     string          `ffstruct:"Transaction" json:"namespace,omitempty"`
-	Type          TransactionType `ffstruct:"Transaction" json:"type" ffenum:"txtype"`
-	Created       *fftypes.FFTime `ffstruct:"Transaction" json:"created"`
-	BlockchainIDs FFStringArray   `ffstruct:"Transaction" json:"blockchainIds,omitempty"`
+	ID             *fftypes.UUID   `ffstruct:"Transaction" json:"id,omitempty"`
+	Namespace      string          `ffstruct:"Transaction" json:"namespace,omitempty"`
+	Type           TransactionType `ffstruct:"Transaction" json:"type" ffenum:"txtype"`
+	Created        *fftypes.FFTime `ffstruct:"Transaction" json:"created"`
+	IdempotencyKey IdempotencyKey  `ffstruct:"Transaction" json:"idempotencyKey,omitempty"`
+	BlockchainIDs  FFStringArray   `ffstruct:"Transaction" json:"blockchainIds,omitempty"`
 }
 
 type TransactionStatusType string
diff --git a/pkg/database/plugin.go b/pkg/database/plugin.go
index 2c7d655d2..90f99e375 100644
--- a/pkg/database/plugin.go
+++ b/pkg/database/plugin.go
@@ -669,22 +669,24 @@ type Capabilities struct {
 
 // MessageQueryFactory filter fields for messages
 var MessageQueryFactory = &queryFields{
-	"id":        &UUIDField{},
-	"cid":       &UUIDField{},
-	"type":      &StringField{},
-	"author":    &StringField{},
-	"key":       &StringField{},
-	"topics":    &FFStringArrayField{},
-	"tag":       &StringField{},
-	"group":     &Bytes32Field{},
-	"created":   &TimeField{},
-	"hash":      &Bytes32Field{},
-	"pins":      &FFStringArrayField{},
-	"state":     &StringField{},
-	"confirmed": &TimeField{},
-	"sequence":  &Int64Field{},
-	"txtype":    &StringField{},
-	"batch":     &UUIDField{},
+	"id":             &UUIDField{},
+	"cid":            &UUIDField{},
+	"type":           &StringField{},
+	"author":         &StringField{},
+	"key":            &StringField{},
+	"topics":         &FFStringArrayField{},
+	"tag":            &StringField{},
+	"group":          &Bytes32Field{},
+	"created":        &TimeField{},
+	"datahash":       &Bytes32Field{},
+	"idempotencykey": &StringField{},
+	"hash":           &Bytes32Field{},
+	"pins":           &FFStringArrayField{},
+	"state":          &StringField{},
+	"confirmed":      &TimeField{},
+	"sequence":       &Int64Field{},
+	"txtype":         &StringField{},
+	"batch":          &UUIDField{},
 }
 
 // BatchQueryFactory filter fields for batches
@@ -705,10 +707,11 @@ var BatchQueryFactory = &queryFields{
 
 // TransactionQueryFactory filter fields for transactions
 var TransactionQueryFactory = &queryFields{
-	"id":            &UUIDField{},
-	"type":          &StringField{},
-	"created":       &TimeField{},
-	"blockchainids": &FFStringArrayField{},
+	"id":             &UUIDField{},
+	"type":           &StringField{},
+	"created":        &TimeField{},
+	"idempotencykey": &StringField{},
+	"blockchainids":  &FFStringArrayField{},
 }
 
 // DataQueryFactory filter fields for data


Test patch:
diff --git a/internal/apiserver/route_post_data_blob_publish_test.go b/internal/apiserver/route_post_data_blob_publish_test.go
index 8b9a0de73..494e194a1 100644
--- a/internal/apiserver/route_post_data_blob_publish_test.go
+++ b/internal/apiserver/route_post_data_blob_publish_test.go
@@ -45,7 +45,7 @@ func TestPostDataBlobPublish(t *testing.T) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	res := httptest.NewRecorder()
 
-	mbm.On("PublishDataBlob", mock.Anything, "id1").
+	mbm.On("PublishDataBlob", mock.Anything, "id1", core.IdempotencyKey("")).
 		Return(&core.Data{}, nil)
 	r.ServeHTTP(res, req)
 
diff --git a/internal/apiserver/route_post_data_value_publish_test.go b/internal/apiserver/route_post_data_value_publish_test.go
index 4dbc31dcf..32bf5066c 100644
--- a/internal/apiserver/route_post_data_value_publish_test.go
+++ b/internal/apiserver/route_post_data_value_publish_test.go
@@ -45,7 +45,7 @@ func TestPostDataValuePublish(t *testing.T) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	res := httptest.NewRecorder()
 
-	mbm.On("PublishDataValue", mock.Anything, "id1").
+	mbm.On("PublishDataValue", mock.Anything, "id1", core.IdempotencyKey("")).
 		Return(&core.Data{}, nil)
 	r.ServeHTTP(res, req)
 
diff --git a/internal/apiserver/route_post_token_pool_test.go b/internal/apiserver/route_post_token_pool_test.go
index f669b3aa5..32f74e720 100644
--- a/internal/apiserver/route_post_token_pool_test.go
+++ b/internal/apiserver/route_post_token_pool_test.go
@@ -40,7 +40,7 @@ func TestPostTokenPool(t *testing.T) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	res := httptest.NewRecorder()
 
-	mam.On("CreateTokenPool", mock.Anything, mock.AnythingOfType("*core.TokenPool"), false).
+	mam.On("CreateTokenPool", mock.Anything, mock.AnythingOfType("*core.TokenPoolInput"), false).
 		Return(&core.TokenPool{}, nil)
 	r.ServeHTTP(res, req)
 
diff --git a/internal/assets/token_approval_test.go b/internal/assets/token_approval_test.go
index 511fd4253..4d9a400eb 100644
--- a/internal/assets/token_approval_test.go
+++ b/internal/assets/token_approval_test.go
@@ -55,7 +55,8 @@ func TestTokenApprovalSuccess(t *testing.T) {
 			Operator: "operator",
 			Key:      "key",
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -69,7 +70,7 @@ func TestTokenApprovalSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "key", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(approvalData)
@@ -94,7 +95,8 @@ func TestTokenApprovalSuccessUnknownIdentity(t *testing.T) {
 			Approved: true,
 			Operator: "operator",
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -108,7 +110,7 @@ func TestTokenApprovalSuccessUnknownIdentity(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(approvalData)
@@ -164,6 +166,7 @@ func TestApprovalDefaultPoolSuccess(t *testing.T) {
 			Operator: "operator",
 			Key:      "key",
 		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -190,7 +193,7 @@ func TestApprovalDefaultPoolSuccess(t *testing.T) {
 		info, _ := f.Finalize()
 		return info.Count && info.Limit == 1
 	}))).Return(tokenPools, filterResult, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(approvalData)
@@ -323,7 +326,8 @@ func TestApprovalFail(t *testing.T) {
 			Operator: "operator",
 			Key:      "key",
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -337,7 +341,7 @@ func TestApprovalFail(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "key", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(approvalData)
@@ -362,7 +366,8 @@ func TestApprovalTransactionFail(t *testing.T) {
 			Approved: true,
 			Operator: "operator",
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -375,7 +380,7 @@ func TestApprovalTransactionFail(t *testing.T) {
 	mth := am.txHelper.(*txcommonmocks.Helper)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(nil, fmt.Errorf("pop"))
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(nil, fmt.Errorf("pop"))
 
 	_, err := am.TokenApproval(context.Background(), approval, false)
 	assert.EqualError(t, err, "pop")
@@ -394,7 +399,8 @@ func TestApprovalOperationsFail(t *testing.T) {
 			Operator: "operator",
 			Key:      "key",
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -409,7 +415,7 @@ func TestApprovalOperationsFail(t *testing.T) {
 
 	mim.On("NormalizeSigningKey", context.Background(), "key", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(fmt.Errorf("pop"))
 
 	_, err := am.TokenApproval(context.Background(), approval, false)
@@ -431,7 +437,8 @@ func TestTokenApprovalConfirm(t *testing.T) {
 			Operator: "operator",
 			Key:      "key",
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -446,7 +453,7 @@ func TestTokenApprovalConfirm(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "key", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(approvalData)
diff --git a/internal/assets/token_pool_test.go b/internal/assets/token_pool_test.go
index 644420ac8..18ff1c7a4 100644
--- a/internal/assets/token_pool_test.go
+++ b/internal/assets/token_pool_test.go
@@ -38,7 +38,7 @@ func TestCreateTokenPoolBadName(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{}
+	pool := &core.TokenPoolInput{}
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
 	assert.Regexp(t, "FF00140", err)
@@ -48,8 +48,10 @@ func TestCreateTokenPoolGetError(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -65,8 +67,10 @@ func TestCreateTokenPoolDuplicateName(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -82,8 +86,11 @@ func TestCreateTokenPoolDefaultConnectorSuccess(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -92,11 +99,11 @@ func TestCreateTokenPoolDefaultConnectorSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("resolved-key", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(createPoolData)
-		return op.Type == core.OpTypeTokenCreatePool && data.Pool == pool
+		return op.Type == core.OpTypeTokenCreatePool && data.Pool == &pool.TokenPool
 	})).Return(nil, nil)
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
@@ -112,8 +119,10 @@ func TestCreateTokenPoolDefaultConnectorNoConnectors(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
 	}
 
 	am.tokens = make(map[string]tokens.Plugin)
@@ -131,8 +140,10 @@ func TestCreateTokenPoolDefaultConnectorMultipleConnectors(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
 	}
 
 	am.tokens["magic-tokens"] = nil
@@ -152,8 +163,10 @@ func TestCreateTokenPoolNoConnectors(t *testing.T) {
 	defer cancel()
 	am.tokens = nil
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -169,8 +182,10 @@ func TestCreateTokenPoolIdentityFail(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Name: "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Name: "testpool",
+		},
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -189,9 +204,11 @@ func TestCreateTokenPoolWrongConnector(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "wrongun",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "wrongun",
+			Name:      "testpool",
+		},
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -210,9 +227,12 @@ func TestCreateTokenPoolFail(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "magic-tokens",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "magic-tokens",
+			Name:      "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -221,11 +241,11 @@ func TestCreateTokenPoolFail(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(createPoolData)
-		return op.Type == core.OpTypeTokenCreatePool && data.Pool == pool
+		return op.Type == core.OpTypeTokenCreatePool && data.Pool == &pool.TokenPool
 	})).Return(nil, fmt.Errorf("pop"))
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
@@ -241,9 +261,12 @@ func TestCreateTokenPoolTransactionFail(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "magic-tokens",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "magic-tokens",
+			Name:      "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -251,7 +274,7 @@ func TestCreateTokenPoolTransactionFail(t *testing.T) {
 	mth := am.txHelper.(*txcommonmocks.Helper)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(nil, fmt.Errorf("pop"))
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(nil, fmt.Errorf("pop"))
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
 	assert.Regexp(t, "pop", err)
@@ -265,9 +288,12 @@ func TestCreateTokenPoolOpInsertFail(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "magic-tokens",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "magic-tokens",
+			Name:      "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -276,7 +302,7 @@ func TestCreateTokenPoolOpInsertFail(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(fmt.Errorf("pop"))
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
@@ -292,9 +318,12 @@ func TestCreateTokenPoolSyncSuccess(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "magic-tokens",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "magic-tokens",
+			Name:      "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -303,11 +332,11 @@ func TestCreateTokenPoolSyncSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(createPoolData)
-		return op.Type == core.OpTypeTokenCreatePool && data.Pool == pool
+		return op.Type == core.OpTypeTokenCreatePool && data.Pool == &pool.TokenPool
 	})).Return(nil, nil)
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
@@ -323,9 +352,12 @@ func TestCreateTokenPoolAsyncSuccess(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "magic-tokens",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "magic-tokens",
+			Name:      "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -334,11 +366,11 @@ func TestCreateTokenPoolAsyncSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(createPoolData)
-		return op.Type == core.OpTypeTokenCreatePool && data.Pool == pool
+		return op.Type == core.OpTypeTokenCreatePool && data.Pool == &pool.TokenPool
 	})).Return(nil, nil)
 
 	_, err := am.CreateTokenPool(context.Background(), pool, false)
@@ -354,9 +386,12 @@ func TestCreateTokenPoolConfirm(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
 
-	pool := &core.TokenPool{
-		Connector: "magic-tokens",
-		Name:      "testpool",
+	pool := &core.TokenPoolInput{
+		TokenPool: core.TokenPool{
+			Connector: "magic-tokens",
+			Name:      "testpool",
+		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -366,7 +401,7 @@ func TestCreateTokenPoolConfirm(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "testpool").Return(nil, nil)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenPool, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	msa.On("WaitForTokenPool", context.Background(), mock.Anything, mock.Anything).
 		Run(func(args mock.Arguments) {
@@ -376,7 +411,7 @@ func TestCreateTokenPoolConfirm(t *testing.T) {
 		Return(nil, nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(createPoolData)
-		return op.Type == core.OpTypeTokenCreatePool && data.Pool == pool
+		return op.Type == core.OpTypeTokenCreatePool && data.Pool == &pool.TokenPool
 	})).Return(nil, nil)
 
 	_, err := am.CreateTokenPool(context.Background(), pool, true)
diff --git a/internal/assets/token_transfer_test.go b/internal/assets/token_transfer_test.go
index 1db96f54c..1290bb947 100644
--- a/internal/assets/token_transfer_test.go
+++ b/internal/assets/token_transfer_test.go
@@ -78,7 +78,8 @@ func TestMintTokensSuccess(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -91,7 +92,7 @@ func TestMintTokensSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(transferData)
@@ -142,6 +143,7 @@ func TestMintTokenDefaultPoolSuccess(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
+		IdempotencyKey: "idem1",
 	}
 
 	mdi := am.database.(*databasemocks.Plugin)
@@ -167,7 +169,7 @@ func TestMintTokenDefaultPoolSuccess(t *testing.T) {
 		info, _ := f.Finalize()
 		return info.Count && info.Limit == 1
 	}))).Return(tokenPools, filterResult, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(transferData)
@@ -324,7 +326,8 @@ func TestMintTokensFail(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -337,7 +340,7 @@ func TestMintTokensFail(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(transferData)
@@ -361,7 +364,8 @@ func TestMintTokensOperationFail(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -375,7 +379,7 @@ func TestMintTokensOperationFail(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(fmt.Errorf("pop"))
 
 	_, err := am.MintTokens(context.Background(), mint, false)
@@ -395,7 +399,8 @@ func TestMintTokensConfirm(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -409,7 +414,7 @@ func TestMintTokensConfirm(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	msa.On("WaitForTokenTransfer", context.Background(), mock.Anything, mock.Anything).
 		Run(func(args mock.Arguments) {
@@ -438,7 +443,8 @@ func TestBurnTokensSuccess(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -451,7 +457,7 @@ func TestBurnTokensSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(transferData)
@@ -502,7 +508,8 @@ func TestBurnTokensConfirm(t *testing.T) {
 		TokenTransfer: core.TokenTransfer{
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -516,7 +523,7 @@ func TestBurnTokensConfirm(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	msa.On("WaitForTokenTransfer", context.Background(), mock.Anything, mock.Anything).
 		Run(func(args mock.Arguments) {
@@ -548,7 +555,8 @@ func TestTransferTokensSuccess(t *testing.T) {
 			To:     "B",
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -561,7 +569,7 @@ func TestTransferTokensSuccess(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
 		data := op.Data.(transferData)
@@ -667,7 +675,8 @@ func TestTransferTokensTransactionFail(t *testing.T) {
 			To:     "B",
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Locator:   "F1",
@@ -680,7 +689,7 @@ func TestTransferTokensTransactionFail(t *testing.T) {
 	mth := am.txHelper.(*txcommonmocks.Helper)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(nil, fmt.Errorf("pop"))
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(nil, fmt.Errorf("pop"))
 
 	_, err := am.TransferTokens(context.Background(), transfer, false)
 	assert.EqualError(t, err, "pop")
@@ -716,6 +725,7 @@ func TestTransferTokensWithBroadcastMessage(t *testing.T) {
 				},
 			},
 		},
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -730,7 +740,7 @@ func TestTransferTokensWithBroadcastMessage(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mbm.On("NewBroadcast", transfer.Message).Return(mms)
 	mms.On("Prepare", context.Background()).Return(nil)
@@ -812,6 +822,7 @@ func TestTransferTokensWithBroadcastMessageSendFail(t *testing.T) {
 				},
 			},
 		},
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -826,7 +837,7 @@ func TestTransferTokensWithBroadcastMessageSendFail(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mbm.On("NewBroadcast", transfer.Message).Return(mms)
 	mms.On("Prepare", context.Background()).Return(nil)
@@ -904,6 +915,7 @@ func TestTransferTokensWithPrivateMessage(t *testing.T) {
 				},
 			},
 		},
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -918,7 +930,7 @@ func TestTransferTokensWithPrivateMessage(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	mpm.On("NewMessage", transfer.Message).Return(mms)
 	mms.On("Prepare", context.Background()).Return(nil)
@@ -1014,7 +1026,8 @@ func TestTransferTokensConfirm(t *testing.T) {
 			To:     "B",
 			Amount: *fftypes.NewFFBigInt(5),
 		},
-		Pool: "pool1",
+		Pool:           "pool1",
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -1028,7 +1041,7 @@ func TestTransferTokensConfirm(t *testing.T) {
 	mom := am.operations.(*operationmocks.Manager)
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
 	msa.On("WaitForTokenTransfer", context.Background(), mock.Anything, mock.Anything).
 		Run(func(args mock.Arguments) {
@@ -1077,6 +1090,7 @@ func TestTransferTokensWithBroadcastConfirm(t *testing.T) {
 				},
 			},
 		},
+		IdempotencyKey: "idem1",
 	}
 	pool := &core.TokenPool{
 		Connector: "magic-tokens",
@@ -1093,7 +1107,7 @@ func TestTransferTokensWithBroadcastConfirm(t *testing.T) {
 	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
 	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
 	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
-	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenTransfer, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mbm.On("NewBroadcast", transfer.Message).Return(mms)
 	mms.On("Prepare", context.Background()).Return(nil)
 	mms.On("Send", context.Background()).Return(nil)
diff --git a/internal/batch/batch_processor_test.go b/internal/batch/batch_processor_test.go
index c9303ba56..602cec9f7 100644
--- a/internal/batch/batch_processor_test.go
+++ b/internal/batch/batch_processor_test.go
@@ -88,7 +88,7 @@ func TestUnfilledBatch(t *testing.T) {
 	mdi.On("UpsertBatch", mock.Anything, mock.Anything, mock.Anything).Return(nil)
 
 	mth := bp.txHelper.(*txcommonmocks.Helper)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
 	mdm := bp.data.(*datamocks.Manager)
 	mdm.On("UpdateMessageIfCached", mock.Anything, mock.Anything).Return()
@@ -137,7 +137,7 @@ func TestBatchSizeOverflow(t *testing.T) {
 	mdi.On("UpsertBatch", mock.Anything, mock.Anything, mock.Anything).Return(nil)
 
 	mth := bp.txHelper.(*txcommonmocks.Helper)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
 	mdm := bp.data.(*datamocks.Manager)
 	mdm.On("UpdateMessageIfCached", mock.Anything, mock.Anything).Return()
@@ -196,7 +196,7 @@ func TestCloseToUnblockUpsertBatch(t *testing.T) {
 	mockRunAsGroupPassthrough(mdi)
 	waitForCall := make(chan bool)
 	mth := bp.txHelper.(*txcommonmocks.Helper)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin).
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin, core.IdempotencyKey("")).
 		Run(func(a mock.Arguments) {
 			waitForCall <- true
 			<-waitForCall
@@ -424,7 +424,7 @@ func TestMarkMessageDispatchedUnpinnedOK(t *testing.T) {
 	mdi.On("InsertEvent", mock.Anything, mock.Anything).Return(nil)
 
 	mth := bp.txHelper.(*txcommonmocks.Helper)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeUnpinned).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeUnpinned, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
 	mdm := bp.data.(*datamocks.Manager)
 	mdm.On("UpdateMessageIfCached", mock.Anything, mock.Anything).Return()
@@ -482,7 +482,7 @@ func TestMaskContextsRetryAfterPinsAssigned(t *testing.T) {
 	mdm.On("UpdateMessageIfCached", mock.Anything, mock.Anything).Return()
 
 	mth := bp.txHelper.(*txcommonmocks.Helper)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeBatchPin, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
 	mim := bp.bm.identity.(*identitymanagermocks.Manager)
 	mim.On("GetLocalNode", mock.Anything).Return(&core.Identity{}, nil)
diff --git a/internal/broadcast/manager_test.go b/internal/broadcast/manager_test.go
index 04c0a1e0e..c8d122cb5 100644
--- a/internal/broadcast/manager_test.go
+++ b/internal/broadcast/manager_test.go
@@ -324,7 +324,7 @@ func TestUploadBlobPublishFail(t *testing.T) {
 	}
 
 	ctx := context.Background()
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(fftypes.NewUUID(), nil)
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mdi.On("GetDataByID", ctx, "ns1", d.ID, true).Return(d, nil)
 	mdi.On("GetBlobMatchingHash", ctx, blob.Hash).Return(blob, nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.Anything).Return(nil)
@@ -333,7 +333,7 @@ func TestUploadBlobPublishFail(t *testing.T) {
 		return op.Type == core.OpTypeSharedStorageUploadBlob && data.Blob == blob
 	})).Return(nil, fmt.Errorf("pop"))
 
-	_, err := bm.PublishDataBlob(ctx, d.ID.String())
+	_, err := bm.PublishDataBlob(ctx, d.ID.String(), "idem1")
 	assert.EqualError(t, err, "pop")
 
 	mdi.AssertExpectations(t)
@@ -441,7 +441,7 @@ func TestUploadValueNotFound(t *testing.T) {
 	mdi := bm.database.(*databasemocks.Plugin)
 	mdi.On("GetDataByID", mock.Anything, "ns1", mock.Anything, true).Return(nil, nil)
 
-	_, err := bm.PublishDataValue(bm.ctx, fftypes.NewUUID().String())
+	_, err := bm.PublishDataValue(bm.ctx, fftypes.NewUUID().String(), "")
 	assert.Regexp(t, "FF10109", err)
 
 	mdi.AssertExpectations(t)
@@ -454,7 +454,7 @@ func TestUploadBlobLookupError(t *testing.T) {
 	mdi := bm.database.(*databasemocks.Plugin)
 	mdi.On("GetDataByID", mock.Anything, "ns1", mock.Anything, true).Return(nil, fmt.Errorf("pop"))
 
-	_, err := bm.PublishDataBlob(bm.ctx, fftypes.NewUUID().String())
+	_, err := bm.PublishDataBlob(bm.ctx, fftypes.NewUUID().String(), "")
 	assert.Regexp(t, "pop", err)
 
 	mdi.AssertExpectations(t)
@@ -464,7 +464,7 @@ func TestUploadValueBadID(t *testing.T) {
 	bm, cancel := newTestBroadcast(t)
 	defer cancel()
 
-	_, err := bm.PublishDataValue(bm.ctx, "badness")
+	_, err := bm.PublishDataValue(bm.ctx, "badness", "")
 	assert.Regexp(t, "FF00138", err)
 
 }
@@ -485,9 +485,9 @@ func TestUploadValueFailPrepare(t *testing.T) {
 	mom.On("AddOrReuseOperation", mock.Anything, mock.Anything).Return(fmt.Errorf("pop"))
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(fftypes.NewUUID(), nil)
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
-	_, err := bm.PublishDataValue(bm.ctx, d.ID.String())
+	_, err := bm.PublishDataValue(bm.ctx, d.ID.String(), "")
 	assert.EqualError(t, err, "pop")
 
 	mom.AssertExpectations(t)
@@ -514,9 +514,9 @@ func TestUploadValueFail(t *testing.T) {
 	})).Return(nil, fmt.Errorf("pop"))
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(fftypes.NewUUID(), nil)
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
-	_, err := bm.PublishDataValue(bm.ctx, d.ID.String())
+	_, err := bm.PublishDataValue(bm.ctx, d.ID.String(), "")
 	assert.EqualError(t, err, "pop")
 
 	mom.AssertExpectations(t)
@@ -543,9 +543,9 @@ func TestUploadValueOK(t *testing.T) {
 	})).Return(nil, nil)
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(fftypes.NewUUID(), nil)
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
-	d1, err := bm.PublishDataValue(bm.ctx, d.ID.String())
+	d1, err := bm.PublishDataValue(bm.ctx, d.ID.String(), "")
 	assert.NoError(t, err)
 	assert.Equal(t, d.ID, d1.ID)
 
@@ -566,9 +566,9 @@ func TestUploadBlobFailNoBlob(t *testing.T) {
 	mdi.On("GetDataByID", mock.Anything, "ns1", d.ID, true).Return(d, nil)
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(fftypes.NewUUID(), nil)
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
-	_, err := bm.PublishDataBlob(bm.ctx, d.ID.String())
+	_, err := bm.PublishDataBlob(bm.ctx, d.ID.String(), "")
 	assert.Regexp(t, "FF10241", err)
 
 	mdi.AssertExpectations(t)
@@ -603,9 +603,9 @@ func TestUploadBlobOK(t *testing.T) {
 	})).Return(nil, nil)
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(fftypes.NewUUID(), nil)
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(fftypes.NewUUID(), nil)
 
-	d1, err := bm.PublishDataBlob(ctx, d.ID.String())
+	d1, err := bm.PublishDataBlob(ctx, d.ID.String(), "")
 	assert.NoError(t, err)
 	assert.Equal(t, d.ID, d1.ID)
 
@@ -637,9 +637,9 @@ func TestUploadBlobTXFail(t *testing.T) {
 	mdi.On("GetDataByID", ctx, "ns1", d.ID, true).Return(d, nil)
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(nil, fmt.Errorf("pop"))
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(nil, fmt.Errorf("pop"))
 
-	_, err := bm.PublishDataBlob(ctx, d.ID.String())
+	_, err := bm.PublishDataBlob(ctx, d.ID.String(), "")
 	assert.Regexp(t, "pop", err)
 
 	mdi.AssertExpectations(t)
@@ -663,9 +663,9 @@ func TestUploadValueTXFail(t *testing.T) {
 	mdi.On("GetDataByID", ctx, "ns1", d.ID, true).Return(d, nil)
 
 	mtx := bm.txHelper.(*txcommonmocks.Helper)
-	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish).Return(nil, fmt.Errorf("pop"))
+	mtx.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeDataPublish, core.IdempotencyKey("")).Return(nil, fmt.Errorf("pop"))
 
-	_, err := bm.PublishDataValue(ctx, d.ID.String())
+	_, err := bm.PublishDataValue(ctx, d.ID.String(), "")
 	assert.Regexp(t, "pop", err)
 
 	mdi.AssertExpectations(t)
diff --git a/internal/contracts/manager_test.go b/internal/contracts/manager_test.go
index ec844ca17..e7684d286 100644
--- a/internal/contracts/manager_test.go
+++ b/internal/contracts/manager_test.go
@@ -1326,13 +1326,14 @@ func TestDeployContract(t *testing.T) {
 	mom := cm.operations.(*operationmocks.Manager)
 	signingKey := "0x2468"
 	req := &core.ContractDeployRequest{
-		Key:        signingKey,
-		Definition: fftypes.JSONAnyPtr("[]"),
-		Contract:   fftypes.JSONAnyPtr("\"0x123456\""),
-		Input:      []interface{}{"one", "two", "three"},
+		Key:            signingKey,
+		Definition:     fftypes.JSONAnyPtr("[]"),
+		Contract:       fftypes.JSONAnyPtr("\"0x123456\""),
+		Input:          []interface{}{"one", "two", "three"},
+		IdempotencyKey: "idem1",
 	}
 
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractDeploy).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractDeploy, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mim.On("NormalizeSigningKey", mock.Anything, signingKey, identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainContractDeploy && op.Plugin == "mockblockchain"
@@ -1361,13 +1362,14 @@ func TestDeployContractSync(t *testing.T) {
 	sam := cm.syncasync.(*syncasyncmocks.Bridge)
 	signingKey := "0x2468"
 	req := &core.ContractDeployRequest{
-		Key:        signingKey,
-		Definition: fftypes.JSONAnyPtr("[]"),
-		Contract:   fftypes.JSONAnyPtr("\"0x123456\""),
-		Input:      []interface{}{"one", "two", "three"},
+		Key:            signingKey,
+		Definition:     fftypes.JSONAnyPtr("[]"),
+		Contract:       fftypes.JSONAnyPtr("\"0x123456\""),
+		Input:          []interface{}{"one", "two", "three"},
+		IdempotencyKey: "idem1",
 	}
 
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractDeploy).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractDeploy, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mim.On("NormalizeSigningKey", mock.Anything, signingKey, identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainContractDeploy && op.Plugin == "mockblockchain"
@@ -1418,13 +1420,14 @@ func TestDeployContractSubmitNewTransactionFail(t *testing.T) {
 	mom := cm.operations.(*operationmocks.Manager)
 	signingKey := "0x2468"
 	req := &core.ContractDeployRequest{
-		Key:        signingKey,
-		Definition: fftypes.JSONAnyPtr("[]"),
-		Contract:   fftypes.JSONAnyPtr("\"0x123456\""),
-		Input:      []interface{}{"one", "two", "three"},
+		Key:            signingKey,
+		Definition:     fftypes.JSONAnyPtr("[]"),
+		Contract:       fftypes.JSONAnyPtr("\"0x123456\""),
+		Input:          []interface{}{"one", "two", "three"},
+		IdempotencyKey: "idem1",
 	}
 
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractDeploy).Return(nil, errors.New("pop"))
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractDeploy, core.IdempotencyKey("idem1")).Return(nil, errors.New("pop"))
 	mim.On("NormalizeSigningKey", mock.Anything, signingKey, identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 
 	_, err := cm.DeployContract(context.Background(), req, false)
@@ -1454,9 +1457,10 @@ func TestInvokeContract(t *testing.T) {
 			Params:  fftypes.FFIParams{},
 			Returns: fftypes.FFIParams{},
 		},
+		IdempotencyKey: "idem1",
 	}
 
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
@@ -1494,9 +1498,10 @@ func TestInvokeContractConfirm(t *testing.T) {
 			Params:  fftypes.FFIParams{},
 			Returns: fftypes.FFIParams{},
 		},
+		IdempotencyKey: "idem1",
 	}
 
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
@@ -1540,9 +1545,10 @@ func TestInvokeContractFail(t *testing.T) {
 			Params:  fftypes.FFIParams{},
 			Returns: fftypes.FFIParams{},
 		},
+		IdempotencyKey: "idem1",
 	}
 
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
@@ -1613,10 +1619,11 @@ func TestInvokeContractTXFail(t *testing.T) {
 			Params:  fftypes.FFIParams{},
 			Returns: fftypes.FFIParams{},
 		},
+		IdempotencyKey: "idem1",
 	}
 
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(nil, fmt.Errorf("pop"))
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(nil, fmt.Errorf("pop"))
 
 	_, err := cm.InvokeContract(context.Background(), req, false)
 
@@ -1695,10 +1702,11 @@ func TestQueryContract(t *testing.T) {
 			Params:  fftypes.FFIParams{},
 			Returns: fftypes.FFIParams{},
 		},
+		IdempotencyKey: "idem1",
 	}
 
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
 	})).Return(nil)
@@ -1724,10 +1732,11 @@ func TestCallContractInvalidType(t *testing.T) {
 			Params:  fftypes.FFIParams{},
 			Returns: fftypes.FFIParams{},
 		},
+		IdempotencyKey: "idem1",
 	}
 
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
 	})).Return(nil)
@@ -1997,6 +2006,7 @@ func TestInvokeContractAPI(t *testing.T) {
 			ID:   fftypes.NewUUID(),
 			Name: "peel",
 		},
+		IdempotencyKey: "idem1",
 	}
 
 	api := &core.ContractAPI{
@@ -2008,7 +2018,7 @@ func TestInvokeContractAPI(t *testing.T) {
 
 	mim.On("NormalizeSigningKey", mock.Anything, "", identity.KeyNormalizationBlockchainPlugin).Return("key-resolved", nil)
 	mdb.On("GetContractAPIByName", mock.Anything, "ns1", "banana").Return(api, nil)
-	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke).Return(fftypes.NewUUID(), nil)
+	mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeContractInvoke, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
 	mom.On("AddOrReuseOperation", mock.Anything, mock.MatchedBy(func(op *core.Operation) bool {
 		return op.Namespace == "ns1" && op.Type == core.OpTypeBlockchainInvoke && op.Plugin == "mockblockchain"
 	})).Return(nil)
diff --git a/internal/data/message_writer_test.go b/internal/data/message_writer_test.go
index 978148005..d162b0364 100644
--- a/internal/data/message_writer_test.go
+++ b/internal/data/message_writer_test.go
@@ -19,6 +19,7 @@ package data
 import (
 	"context"
 	"fmt"
+	"strings"
 	"testing"
 	"time"
 
@@ -38,7 +39,7 @@ func newTestMessageWriter(t *testing.T) *messageWriter {
 	}, &database.Capabilities{Concurrency: true})
 }
 
-func newTestMessageWriterNoConcrrency(t *testing.T) *messageWriter {
+func newTestMessageWriterNoConcurrency(t *testing.T) *messageWriter {
 	return newTestMessageWriterConf(t, &messageWriterConf{workerCount: 1}, &database.Capabilities{Concurrency: false})
 }
 
@@ -49,7 +50,7 @@ func newTestMessageWriterConf(t *testing.T, conf *messageWriterConf, dbCapabilit
 }
 
 func TestNewMessageWriterNoConcurrency(t *testing.T) {
-	mw := newTestMessageWriterNoConcrrency(t)
+	mw := newTestMessageWriterNoConcurrency(t)
 	assert.Zero(t, mw.conf.workerCount)
 }
 
@@ -70,7 +71,7 @@ func TestWriteDataClosed(t *testing.T) {
 }
 
 func TestWriteNewMessageSyncFallback(t *testing.T) {
-	mw := newTestMessageWriterNoConcrrency(t)
+	mw := newTestMessageWriterNoConcurrency(t)
 	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
 
 	msg1 := &core.MessageInOut{
@@ -99,7 +100,7 @@ func TestWriteNewMessageSyncFallback(t *testing.T) {
 }
 
 func TestWriteDataSyncFallback(t *testing.T) {
-	mw := newTestMessageWriterNoConcrrency(t)
+	mw := newTestMessageWriterNoConcurrency(t)
 	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
 
 	data1 := &core.Data{ID: fftypes.NewUUID()}
@@ -113,7 +114,7 @@ func TestWriteDataSyncFallback(t *testing.T) {
 }
 
 func TestWriteMessagesInsertMessagesFail(t *testing.T) {
-	mw := newTestMessageWriterNoConcrrency(t)
+	mw := newTestMessageWriterNoConcurrency(t)
 
 	msg1 := &core.Message{
 		Header: core.MessageHeader{
@@ -130,7 +131,7 @@ func TestWriteMessagesInsertMessagesFail(t *testing.T) {
 }
 
 func TestWriteMessagesInsertDataArrayFail(t *testing.T) {
-	mw := newTestMessageWriterNoConcrrency(t)
+	mw := newTestMessageWriterNoConcurrency(t)
 
 	data1 := &core.Data{ID: fftypes.NewUUID()}
 
@@ -141,3 +142,233 @@ func TestWriteMessagesInsertDataArrayFail(t *testing.T) {
 
 	assert.Regexp(t, "pop", err)
 }
+
+func TestPersistMWBatchIdempotencyAllFail(t *testing.T) {
+	mw := newTestMessageWriterNoConcurrency(t)
+	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
+
+	m1ID := fftypes.NewUUID()
+	m1d := core.DataArray{
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+	}
+	m1 := &core.Message{
+		Header:         core.MessageHeader{Namespace: "ns1", ID: m1ID},
+		Data:           m1d.Refs(),
+		IdempotencyKey: "idem1",
+	}
+	c1 := make(chan error, 1)
+
+	m2ID := fftypes.NewUUID()
+	m2d := core.DataArray{
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+	}
+	m2 := &core.Message{
+		Header:         core.MessageHeader{Namespace: "ns1", ID: m2ID},
+		Data:           m2d.Refs(),
+		IdempotencyKey: "idem2",
+	}
+	c2 := make(chan error, 1)
+
+	mdi := mw.database.(*databasemocks.Plugin)
+	rag := mdi.On("RunAsGroup", mock.Anything, mock.Anything)
+	rag.Run(func(args mock.Arguments) {
+		rag.Return(args[1].(func(context.Context) error)(customCtx))
+	}).Return(nil)
+	mdi.On("InsertDataArray", customCtx, append(m1d, m2d...)).Return(nil)
+	mdi.On("InsertMessages", customCtx, []*core.Message{m1, m2}).Return(fmt.Errorf("various keys were not unique"))
+	mdi.On("GetMessages", mock.Anything, "ns1", mock.MatchedBy(func(f database.Filter) bool {
+		ff, _ := f.Finalize()
+		return strings.Contains(ff.String(), "idem1")
+	})).Return([]*core.Message{m1}, nil, nil)
+	mdi.On("GetMessages", mock.Anything, "ns1", mock.MatchedBy(func(f database.Filter) bool {
+		ff, _ := f.Finalize()
+		return strings.Contains(ff.String(), "idem2")
+	})).Return([]*core.Message{m2}, nil, nil)
+
+	mw.persistMWBatch(&messageWriterBatch{
+		messages: []*core.Message{m1, m2},
+		data:     append(m1d, m2d...),
+		listeners: map[fftypes.UUID]chan error{
+			*m1ID: c1,
+			*m2ID: c2,
+		},
+	})
+
+	mdi.AssertExpectations(t)
+
+	assert.Regexp(t, "FF10430.*idem1", <-c1)
+	assert.Regexp(t, "FF10430.*idem2", <-c2)
+}
+
+func TestPersistMWBatchHalfFailResubmit(t *testing.T) {
+	mw := newTestMessageWriterNoConcurrency(t)
+	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
+
+	m1ID := fftypes.NewUUID()
+	m1d := core.DataArray{
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+	}
+	m1 := &core.Message{
+		Header:         core.MessageHeader{Namespace: "ns1", ID: m1ID},
+		Data:           m1d.Refs(),
+		IdempotencyKey: "idem1",
+	}
+	c1 := make(chan error, 1)
+
+	m2ID := fftypes.NewUUID()
+	m2d := core.DataArray{
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+	}
+	m2 := &core.Message{
+		Header:         core.MessageHeader{Namespace: "ns1", ID: m2ID},
+		Data:           m2d.Refs(),
+		IdempotencyKey: "idem2",
+	}
+	c2 := make(chan error, 1)
+
+	mdi := mw.database.(*databasemocks.Plugin)
+	rag := mdi.On("RunAsGroup", mock.Anything, mock.Anything)
+	rag.Run(func(args mock.Arguments) {
+		rag.Return(args[1].(func(context.Context) error)(customCtx))
+	}).Return(nil)
+	mdi.On("InsertDataArray", customCtx, append(m1d, m2d...)).Return(nil)
+	mdi.On("InsertMessages", customCtx, []*core.Message{m1, m2}).Return(fmt.Errorf("various keys were not unique"))
+	mdi.On("GetMessages", mock.Anything, "ns1", mock.MatchedBy(func(f database.Filter) bool {
+		ff, _ := f.Finalize()
+		return strings.Contains(ff.String(), "idem1")
+	})).Return([]*core.Message{}, nil, nil) // no result
+	mdi.On("GetMessages", mock.Anything, "ns1", mock.MatchedBy(func(f database.Filter) bool {
+		ff, _ := f.Finalize()
+		return strings.Contains(ff.String(), "idem2")
+	})).Return([]*core.Message{m2}, nil, nil) // found result
+	mdi.On("InsertDataArray", customCtx, m1d).Return(nil)
+	mdi.On("InsertMessages", customCtx, []*core.Message{m1}).Return(nil) // resubmit reduced batch
+
+	mw.persistMWBatch(&messageWriterBatch{
+		messages: []*core.Message{m1, m2},
+		data:     append(m1d, m2d...),
+		listeners: map[fftypes.UUID]chan error{
+			*m1ID: c1,
+			*m2ID: c2,
+		},
+	})
+
+	mdi.AssertExpectations(t)
+
+	assert.Nil(t, <-c1)
+	assert.Regexp(t, "FF10430.*idem2", <-c2)
+}
+
+func TestPersistMWBatchFailIdemCheck(t *testing.T) {
+	mw := newTestMessageWriterNoConcurrency(t)
+	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
+
+	m1ID := fftypes.NewUUID()
+	m1d := core.DataArray{
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+	}
+	m1 := &core.Message{
+		Header:         core.MessageHeader{Namespace: "ns1", ID: m1ID},
+		Data:           m1d.Refs(),
+		IdempotencyKey: "idem1",
+	}
+	c1 := make(chan error, 1)
+
+	mdi := mw.database.(*databasemocks.Plugin)
+	rag := mdi.On("RunAsGroup", mock.Anything, mock.Anything)
+	rag.Run(func(args mock.Arguments) {
+		rag.Return(args[1].(func(context.Context) error)(customCtx))
+	}).Return(nil)
+	mdi.On("InsertDataArray", customCtx, m1d).Return(nil)
+	mdi.On("InsertMessages", customCtx, []*core.Message{m1}).Return(fmt.Errorf("failure1... which leads to..."))
+	mdi.On("GetMessages", mock.Anything, "ns1", mock.MatchedBy(func(f database.Filter) bool {
+		ff, _ := f.Finalize()
+		return strings.Contains(ff.String(), "idem1")
+	})).Return([]*core.Message{}, nil, fmt.Errorf("failure1"))
+
+	mw.persistMWBatch(&messageWriterBatch{
+		messages: []*core.Message{m1},
+		data:     m1d,
+		listeners: map[fftypes.UUID]chan error{
+			*m1ID: c1,
+		},
+	})
+
+	mdi.AssertExpectations(t)
+
+	assert.Regexp(t, "failure1" /* we get the first failure, not the idempotency check failure */, <-c1)
+}
+
+func TestPersistMWBatchIdempotencySkipNoIdem(t *testing.T) {
+	mw := newTestMessageWriterNoConcurrency(t)
+	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
+
+	m1ID := fftypes.NewUUID()
+	m1d := core.DataArray{
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+		&core.Data{Namespace: "ns1", ID: fftypes.NewUUID(), Hash: fftypes.NewRandB32()},
+	}
+	m1 := &core.Message{
+		Header: core.MessageHeader{Namespace: "ns1", ID: m1ID},
+		Data:   m1d.Refs(),
+	}
+	c1 := make(chan error, 1)
+
+	mdi := mw.database.(*databasemocks.Plugin)
+	rag := mdi.On("RunAsGroup", mock.Anything, mock.Anything)
+	rag.Run(func(args mock.Arguments) {
+		rag.Return(args[1].(func(context.Context) error)(customCtx))
+	}).Return(nil)
+	mdi.On("InsertDataArray", customCtx, m1d).Return(nil)
+	mdi.On("InsertMessages", customCtx, []*core.Message{m1}).Return(fmt.Errorf("not idempotency related"))
+
+	mw.persistMWBatch(&messageWriterBatch{
+		messages: []*core.Message{m1},
+		data:     m1d,
+		listeners: map[fftypes.UUID]chan error{
+			*m1ID: c1,
+		},
+	})
+
+	mdi.AssertExpectations(t)
+
+	assert.Regexp(t, "not idempotency related", <-c1)
+}
+
+func TestWriteMessageNoWorkersIdempotencyDuplicate(t *testing.T) {
+	mw := newTestMessageWriterNoConcurrency(t)
+	customCtx := context.WithValue(context.Background(), "dbtx", "on this context")
+	mw.conf.workerCount = 0
+
+	m1ID := fftypes.NewUUID()
+	m1 := &core.MessageInOut{
+		Message: core.Message{
+			Header:         core.MessageHeader{Namespace: "ns1", ID: m1ID},
+			IdempotencyKey: "idem1",
+		},
+	}
+
+	mdi := mw.database.(*databasemocks.Plugin)
+	rag := mdi.On("RunAsGroup", mock.Anything, mock.Anything)
+	rag.Run(func(args mock.Arguments) {
+		rag.Return(args[1].(func(context.Context) error)(customCtx))
+	}).Return(nil)
+	mdi.On("InsertMessages", customCtx, []*core.Message{&m1.Message}).Return(fmt.Errorf("failure1... which leads to..."))
+	mdi.On("GetMessages", mock.Anything, "ns1", mock.MatchedBy(func(f database.Filter) bool {
+		ff, _ := f.Finalize()
+		return strings.Contains(ff.String(), "idem1")
+	})).Return([]*core.Message{
+		{Header: core.MessageHeader{ID: fftypes.NewUUID()}, IdempotencyKey: "idem1"},
+	}, nil, nil)
+
+	err := mw.WriteNewMessage(mw.ctx, &NewMessage{Message: m1})
+	assert.Regexp(t, "FF10430", err)
+
+	mdi.AssertExpectations(t)
+
+}
diff --git a/internal/database/sqlcommon/message_sql_test.go b/internal/database/sqlcommon/message_sql_test.go
index ea12d305d..31fd1a06c 100644
--- a/internal/database/sqlcommon/message_sql_test.go
+++ b/internal/database/sqlcommon/message_sql_test.go
@@ -110,11 +110,12 @@ func TestUpsertE2EWithDB(t *testing.T) {
 			DataHash:  fftypes.NewRandB32(),
 			TxType:    core.TransactionTypeBatchPin,
 		},
-		Hash:      fftypes.NewRandB32(),
-		Pins:      []string{fftypes.NewRandB32().String(), fftypes.NewRandB32().String()},
-		State:     core.MessageStateRejected,
-		Confirmed: fftypes.Now(),
-		BatchID:   bid,
+		Hash:           fftypes.NewRandB32(),
+		Pins:           []string{fftypes.NewRandB32().String(), fftypes.NewRandB32().String()},
+		State:          core.MessageStateRejected,
+		Confirmed:      fftypes.Now(),
+		BatchID:        bid,
+		IdempotencyKey: "myBusinessIdentifier",
 		Data: []*core.DataRef{
 			{ID: dataID1, Hash: rand1},
 			{ID: dataID2, Hash: rand2}, // Note the data refs cannot change, as it would affect the hash, and the hash is immutable
@@ -155,6 +156,7 @@ func TestUpsertE2EWithDB(t *testing.T) {
 		fb.Eq("topics", msgUpdated.Header.Topics),
 		fb.Eq("group", msgUpdated.Header.Group),
 		fb.Eq("cid", msgUpdated.Header.CID),
+		fb.Eq("idempotencykey", msgUpdated.IdempotencyKey),
 		fb.Gt("created", "0"),
 		fb.Gt("confirmed", "0"),
 	)
@@ -548,7 +550,7 @@ func TestGetMessageByIDLoadRefsFail(t *testing.T) {
 	cols := append([]string{}, msgColumns...)
 	cols = append(cols, "id()")
 	mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows(cols).
-		AddRow(msgID.String(), nil, core.MessageTypeBroadcast, "author1", "0x12345", 0, "ns1", "ns1", "t1", "c1", nil, b32.String(), b32.String(), b32.String(), "confirmed", 0, "pin", nil, 0))
+		AddRow(msgID.String(), nil, core.MessageTypeBroadcast, "author1", "0x12345", 0, "ns1", "ns1", "t1", "c1", nil, b32.String(), b32.String(), b32.String(), "confirmed", 0, "pin", nil, "bob", 0))
 	mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop"))
 	_, err := s.GetMessageByID(context.Background(), "ns1", msgID)
 	assert.Regexp(t, "FF10115", err)
@@ -595,7 +597,7 @@ func TestGetMessagesLoadRefsFail(t *testing.T) {
 	cols := append([]string{}, msgColumns...)
 	cols = append(cols, "id()")
 	mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows(cols).
-		AddRow(msgID.String(), nil, core.MessageTypeBroadcast, "author1", "0x12345", 0, "ns1", "ns1", "t1", "c1", nil, b32.String(), b32.String(), b32.String(), "confirmed", 0, "pin", nil, 0))
+		AddRow(msgID.String(), nil, core.MessageTypeBroadcast, "author1", "0x12345", 0, "ns1", "ns1", "t1", "c1", nil, b32.String(), b32.String(), b32.String(), "confirmed", 0, "pin", nil, "bob", 0))
 	mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop"))
 	f := database.MessageQueryFactory.NewFilter(context.Background()).Gt("confirmed", "0")
 	_, _, err := s.GetMessages(context.Background(), "ns1", f)
diff --git a/internal/database/sqlcommon/transaction_sql_test.go b/internal/database/sqlcommon/transaction_sql_test.go
index 2a3207a8f..1eb7a7967 100644
--- a/internal/database/sqlcommon/transaction_sql_test.go
+++ b/internal/database/sqlcommon/transaction_sql_test.go
@@ -83,18 +83,29 @@ func TestTransactionE2EWithDB(t *testing.T) {
 
 	// Update
 	up := database.TransactionQueryFactory.NewUpdate(ctx).
-		Set("blockchainids", core.FFStringArray{"0x12345", "0x23456"})
+		Set("blockchainids", core.FFStringArray{"0x12345", "0x23456"}).
+		Set("idempotencykey", "testKey")
 	err = s.UpdateTransaction(ctx, "ns1", transaction.ID, up)
 	assert.NoError(t, err)
 
+	// Check we cannot insert a duplicate idempotency key
+	err = s.InsertTransaction(ctx, &core.Transaction{
+		Namespace:      "ns1",
+		ID:             fftypes.NewUUID(),
+		IdempotencyKey: "testKey",
+	})
+	assert.Regexp(t, "FF10431", err)
+
 	// Test find updated value
 	filter = fb.And(
 		fb.Eq("id", transaction.ID.String()),
 		fb.Eq("blockchainids", "0x12345,0x23456"),
+		fb.Eq("idempotencykey", "testKey"),
 	)
 	transactions, _, err = s.GetTransactions(ctx, "ns1", filter)
 	assert.NoError(t, err)
 	assert.Equal(t, 1, len(transactions))
+	assert.Equal(t, (core.IdempotencyKey)("testKey"), transactions[0].IdempotencyKey)
 }
 
 func TestInsertTransactionFailBegin(t *testing.T) {
@@ -116,6 +127,18 @@ func TestInsertTransactionFailInsert(t *testing.T) {
 	assert.NoError(t, mock.ExpectationsWereMet())
 }
 
+func TestEmptyResultInsertIdempotencyKey(t *testing.T) {
+	s, mock := newMockProvider().init()
+	mock.ExpectBegin()
+	mock.ExpectExec("INSERT .*").WillReturnResult(sqlmock.NewResult(-1, 0))
+	mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{}))
+	mock.ExpectRollback()
+	transactionID := fftypes.NewUUID()
+	err := s.InsertTransaction(context.Background(), &core.Transaction{ID: transactionID, IdempotencyKey: "idem1"})
+	assert.Regexp(t, "FF10432", err)
+	assert.NoError(t, mock.ExpectationsWereMet())
+}
+
 func TestInsertTransactionFailCommit(t *testing.T) {
 	s, mock := newMockProvider().init()
 	transactionID := fftypes.NewUUID()
diff --git a/internal/multiparty/manager_test.go b/internal/multiparty/manager_test.go
index b5ddd64b6..102e7a602 100644
--- a/internal/multiparty/manager_test.go
+++ b/internal/multiparty/manager_test.go
@@ -262,7 +262,7 @@ func TestSubmitNetworkAction(t *testing.T) {
 	mp.mbi.On("GetNetworkVersion", mock.Anything, mock.Anything).Return(1, nil)
 	mp.mbi.On("AddFireflySubscription", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("test", nil)
 	mp.mdi.On("UpsertNamespace", mock.Anything, mock.AnythingOfType("*core.Namespace"), true).Return(nil)
-	mp.mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeNetworkAction).Return(txid, nil)
+	mp.mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeNetworkAction, core.IdempotencyKey("")).Return(txid, nil)
 	mp.mbi.On("Name").Return("ut")
 	mp.mom.On("AddOrReuseOperation", context.Background(), mock.MatchedBy(func(op *core.Operation) bool {
 		assert.Equal(t, core.OpTypeBlockchainNetworkAction, op.Type)
@@ -305,7 +305,7 @@ func TestSubmitNetworkActionTXFail(t *testing.T) {
 	mp.mbi.On("GetNetworkVersion", mock.Anything, mock.Anything).Return(1, nil)
 	mp.mbi.On("AddFireflySubscription", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("test", nil)
 	mp.mdi.On("UpsertNamespace", mock.Anything, mock.AnythingOfType("*core.Namespace"), true).Return(nil)
-	mp.mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeNetworkAction).Return(nil, fmt.Errorf("pop"))
+	mp.mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeNetworkAction, core.IdempotencyKey("")).Return(nil, fmt.Errorf("pop"))
 
 	err := mp.ConfigureContract(context.Background())
 	assert.NoError(t, err)
@@ -336,7 +336,7 @@ func TestSubmitNetworkActionOpFail(t *testing.T) {
 	mp.mbi.On("GetNetworkVersion", mock.Anything, mock.Anything).Return(1, nil)
 	mp.mbi.On("AddFireflySubscription", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("test", nil)
 	mp.mdi.On("UpsertNamespace", mock.Anything, mock.AnythingOfType("*core.Namespace"), true).Return(nil)
-	mp.mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeNetworkAction).Return(txid, nil)
+	mp.mth.On("SubmitNewTransaction", mock.Anything, core.TransactionTypeNetworkAction, core.IdempotencyKey("")).Return(txid, nil)
 	mp.mbi.On("Name").Return("ut")
 	mp.mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(fmt.Errorf("pop"))
 
diff --git a/internal/txcommon/txcommon_test.go b/internal/txcommon/txcommon_test.go
index cbebefba5..9a711d4ef 100644
--- a/internal/txcommon/txcommon_test.go
+++ b/internal/txcommon/txcommon_test.go
@@ -70,7 +70,7 @@ func TestSubmitNewTransactionOK(t *testing.T) {
 		return e.Type == core.EventTypeTransactionSubmitted && e.Reference.Equals(txidInserted)
 	})).Return(nil)
 
-	txidReturned, err := txHelper.SubmitNewTransaction(ctx, core.TransactionTypeBatchPin)
+	txidReturned, err := txHelper.SubmitNewTransaction(ctx, core.TransactionTypeBatchPin, "idem1")
 	assert.NoError(t, err)
 	assert.Equal(t, *txidInserted, *txidReturned)
 
@@ -89,7 +89,7 @@ func TestSubmitNewTransactionFail(t *testing.T) {
 
 	mdi.On("InsertTransaction", ctx, mock.Anything).Return(fmt.Errorf("pop"))
 
-	_, err := txHelper.SubmitNewTransaction(ctx, core.TransactionTypeBatchPin)
+	_, err := txHelper.SubmitNewTransaction(ctx, core.TransactionTypeBatchPin, "idem1")
 	assert.Regexp(t, "pop", err)
 
 	mdi.AssertExpectations(t)
@@ -108,7 +108,7 @@ func TestSubmitNewTransactionEventFail(t *testing.T) {
 	mdi.On("InsertTransaction", ctx, mock.Anything).Return(nil)
 	mdi.On("InsertEvent", ctx, mock.Anything).Return(fmt.Errorf("pop"))
 
-	_, err := txHelper.SubmitNewTransaction(ctx, core.TransactionTypeBatchPin)
+	_, err := txHelper.SubmitNewTransaction(ctx, core.TransactionTypeBatchPin, "idem1")
 	assert.Regexp(t, "pop", err)
 
 	mdi.AssertExpectations(t)
diff --git a/pkg/core/idempotency_key_test.go b/pkg/core/idempotency_key_test.go
new file mode 100644
index 000000000..8a634da28
--- /dev/null
+++ b/pkg/core/idempotency_key_test.go
@@ -0,0 +1,54 @@
+// Copyright © 2022 Kaleido, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package core
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIdempotencyKeyNilValuer(t *testing.T) {
+	v, err := ((IdempotencyKey)("")).Value()
+	assert.NoError(t, err)
+	assert.Nil(t, v)
+
+	v, err = ((IdempotencyKey)("testValue")).Value()
+	assert.NoError(t, err)
+	assert.Equal(t, "testValue", v.(string))
+}
+
+func TestIdempotencyKeyNilScanner(t *testing.T) {
+	var ik *IdempotencyKey
+	err := ik.Scan(nil)
+	assert.NoError(t, err)
+	assert.Nil(t, ik)
+
+	ik = new(IdempotencyKey)
+	err = ik.Scan("testValue")
+	assert.NoError(t, err)
+	assert.Equal(t, "testValue", (string)(*ik))
+
+	ik = new(IdempotencyKey)
+	err = ik.Scan([]byte("testValue2"))
+	assert.NoError(t, err)
+	assert.Equal(t, "testValue2", (string)(*ik))
+
+	ik = new(IdempotencyKey)
+	err = ik.Scan(12345)
+	assert.Regexp(t, "FF00105", err)
+}
diff --git a/test/e2e/client/restclient.go b/test/e2e/client/restclient.go
index 625733095..644243e88 100644
--- a/test/e2e/client/restclient.go
+++ b/test/e2e/client/restclient.go
@@ -305,11 +305,11 @@ func (client *FireFlyClient) DeleteSubscription(t *testing.T, id *fftypes.UUID)
 	require.Equal(t, 204, resp.StatusCode(), "DELETE %s [%d]: %s", path, resp.StatusCode(), resp.String())
 }
 
-func (client *FireFlyClient) BroadcastMessage(t *testing.T, topic string, data *core.DataRefOrValue, confirm bool) (*resty.Response, error) {
-	return client.BroadcastMessageAsIdentity(t, "", topic, data, confirm)
+func (client *FireFlyClient) BroadcastMessage(t *testing.T, topic, idempotencyKey string, data *core.DataRefOrValue, confirm bool) (*resty.Response, error) {
+	return client.BroadcastMessageAsIdentity(t, "", topic, idempotencyKey, data, confirm)
 }
 
-func (client *FireFlyClient) BroadcastMessageAsIdentity(t *testing.T, did, topic string, data *core.DataRefOrValue, confirm bool) (*resty.Response, error) {
+func (client *FireFlyClient) BroadcastMessageAsIdentity(t *testing.T, did, topic, idempotencyKey string, data *core.DataRefOrValue, confirm bool) (*resty.Response, error) {
 	var msg core.Message
 	res, err := client.Client.R().
 		SetBody(core.MessageInOut{
@@ -320,6 +320,7 @@ func (client *FireFlyClient) BroadcastMessageAsIdentity(t *testing.T, did, topic
 						Author: did,
 					},
 				},
+				IdempotencyKey: core.IdempotencyKey(idempotencyKey),
 			},
 			InlineData: core.InlineData{data},
 		}).
@@ -466,11 +467,11 @@ func (client *FireFlyClient) PrivateBlobMessageDatatypeTagged(t *testing.T, topi
 	return data, res, err
 }
 
-func (client *FireFlyClient) PrivateMessage(topic string, data *core.DataRefOrValue, members []core.MemberInput, tag string, txType core.TransactionType, confirm bool, startTime time.Time) (*resty.Response, error) {
-	return client.PrivateMessageWithKey("", topic, data, members, tag, txType, confirm, startTime)
+func (client *FireFlyClient) PrivateMessage(topic, idempotencyKey string, data *core.DataRefOrValue, members []core.MemberInput, tag string, txType core.TransactionType, confirm bool, startTime time.Time) (*resty.Response, error) {
+	return client.PrivateMessageWithKey("", topic, idempotencyKey, data, members, tag, txType, confirm, startTime)
 }
 
-func (client *FireFlyClient) PrivateMessageWithKey(key, topic string, data *core.DataRefOrValue, members []core.MemberInput, tag string, txType core.TransactionType, confirm bool, startTime time.Time) (*resty.Response, error) {
+func (client *FireFlyClient) PrivateMessageWithKey(key, topic, idempotencyKey string, data *core.DataRefOrValue, members []core.MemberInput, tag string, txType core.TransactionType, confirm bool, startTime time.Time) (*resty.Response, error) {
 	msg := core.MessageInOut{
 		Message: core.Message{
 			Header: core.MessageHeader{
@@ -481,6 +482,7 @@ func (client *FireFlyClient) PrivateMessageWithKey(key, topic string, data *core
 					Key: key,
 				},
 			},
+			IdempotencyKey: core.IdempotencyKey(idempotencyKey),
 		},
 		InlineData: core.InlineData{data},
 		Group: &core.InputGroup{
@@ -574,7 +576,7 @@ func (client *FireFlyClient) GetTokenPools(t *testing.T, startTime time.Time) (p
 	return pools
 }
 
-func (client *FireFlyClient) MintTokens(t *testing.T, mint *core.TokenTransferInput, confirm bool) *core.TokenTransfer {
+func (client *FireFlyClient) MintTokens(t *testing.T, mint *core.TokenTransferInput, confirm bool, expectedStatus ...int) *core.TokenTransfer {
 	var transferOut core.TokenTransfer
 	path := client.namespaced(urlTokenMint)
 	resp, err := client.Client.R().
@@ -583,15 +585,18 @@ func (client *FireFlyClient) MintTokens(t *testing.T, mint *core.TokenTransferIn
 		SetResult(&transferOut).
 		Post(path)
 	require.NoError(t, err)
-	expected := 202
-	if confirm {
-		expected = 200
+	if len(expectedStatus) == 0 {
+		if confirm {
+			expectedStatus = []int{200}
+		} else {
+			expectedStatus = []int{202}
+		}
 	}
-	require.Equal(t, expected, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
+	require.Equal(t, expectedStatus[0], resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
 	return &transferOut
 }
 
-func (client *FireFlyClient) BurnTokens(t *testing.T, burn *core.TokenTransferInput, confirm bool) *core.TokenTransfer {
+func (client *FireFlyClient) BurnTokens(t *testing.T, burn *core.TokenTransferInput, confirm bool, expectedStatus ...int) *core.TokenTransfer {
 	var transferOut core.TokenTransfer
 	path := client.namespaced(urlTokenBurn)
 	resp, err := client.Client.R().
@@ -600,15 +605,18 @@ func (client *FireFlyClient) BurnTokens(t *testing.T, burn *core.TokenTransferIn
 		SetResult(&transferOut).
 		Post(path)
 	require.NoError(t, err)
-	expected := 202
-	if confirm {
-		expected = 200
+	if len(expectedStatus) == 0 {
+		if confirm {
+			expectedStatus = []int{200}
+		} else {
+			expectedStatus = []int{202}
+		}
 	}
-	require.Equal(t, expected, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
+	require.Equal(t, expectedStatus[0], resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
 	return &transferOut
 }
 
-func (client *FireFlyClient) TransferTokens(t *testing.T, transfer *core.TokenTransferInput, confirm bool) *core.TokenTransfer {
+func (client *FireFlyClient) TransferTokens(t *testing.T, transfer *core.TokenTransferInput, confirm bool, expectedStatus ...int) *core.TokenTransfer {
 	var transferOut core.TokenTransfer
 	path := client.namespaced(urlTokenTransfers)
 	resp, err := client.Client.R().
@@ -617,11 +625,14 @@ func (client *FireFlyClient) TransferTokens(t *testing.T, transfer *core.TokenTr
 		SetResult(&transferOut).
 		Post(path)
 	require.NoError(t, err)
-	expected := 202
-	if confirm {
-		expected = 200
+	if len(expectedStatus) == 0 {
+		if confirm {
+			expectedStatus = []int{200}
+		} else {
+			expectedStatus = []int{202}
+		}
 	}
-	require.Equal(t, expected, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
+	require.Equal(t, expectedStatus[0], resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
 	return &transferOut
 }
 
@@ -636,7 +647,7 @@ func (client *FireFlyClient) GetTokenTransfers(t *testing.T, poolID *fftypes.UUI
 	return transfers
 }
 
-func (client *FireFlyClient) TokenApproval(t *testing.T, approval *core.TokenApprovalInput, confirm bool) *core.TokenApproval {
+func (client *FireFlyClient) TokenApproval(t *testing.T, approval *core.TokenApprovalInput, confirm bool, expectedStatus ...int) *core.TokenApproval {
 	var approvalOut core.TokenApproval
 	path := client.namespaced(urlTokenApprovals)
 	resp, err := client.Client.R().
@@ -645,11 +656,14 @@ func (client *FireFlyClient) TokenApproval(t *testing.T, approval *core.TokenApp
 		SetResult(&approvalOut).
 		Post(path)
 	require.NoError(t, err)
-	expected := 202
-	if confirm {
-		expected = 200
+	if len(expectedStatus) == 0 {
+		if confirm {
+			expectedStatus = []int{200}
+		} else {
+			expectedStatus = []int{202}
+		}
 	}
-	require.Equal(t, expected, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
+	require.Equal(t, expectedStatus[0], resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
 	return &approvalOut
 }
 
@@ -772,7 +786,7 @@ func (client *FireFlyClient) DeleteContractListener(t *testing.T, id *fftypes.UU
 	require.Equal(t, 204, resp.StatusCode(), "DELETE %s [%d]: %s", path, resp.StatusCode(), resp.String())
 }
 
-func (client *FireFlyClient) InvokeContractMethod(t *testing.T, req *core.ContractCallRequest) (interface{}, error) {
+func (client *FireFlyClient) InvokeContractMethod(t *testing.T, req *core.ContractCallRequest, expectedStatus ...int) (interface{}, error) {
 	var res interface{}
 	path := client.namespaced(urlContractInvoke)
 	resp, err := client.Client.R().
@@ -780,7 +794,10 @@ func (client *FireFlyClient) InvokeContractMethod(t *testing.T, req *core.Contra
 		SetResult(&res).
 		Post(path)
 	require.NoError(t, err)
-	require.Equal(t, 202, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
+	if len(expectedStatus) == 0 {
+		expectedStatus = []int{202}
+	}
+	require.Equal(t, expectedStatus[0], resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String())
 	return res, err
 }
 
diff --git a/test/e2e/multiparty/contract_migration.go b/test/e2e/multiparty/contract_migration.go
index 4ddedbaf5..63e8cc16c 100644
--- a/test/e2e/multiparty/contract_migration.go
+++ b/test/e2e/multiparty/contract_migration.go
@@ -127,13 +127,13 @@ func runMigrationTest(suite *ContractMigrationTestSuite, address1, address2 stri
 		}
 
 		// Verify that a broadcast and private message on the new namespace succeed under the first contract
-		resp, err := client1.BroadcastMessage(suite.T(), "topic", data, false)
+		resp, err := client1.BroadcastMessage(suite.T(), "topic", "", data, false)
 		require.NoError(suite.T(), err)
 		assert.Equal(suite.T(), 202, resp.StatusCode())
 		e2e.WaitForMessageConfirmed(suite.T(), received1, core.MessageTypeBroadcast)
 		e2e.WaitForMessageConfirmed(suite.T(), received2, core.MessageTypeBroadcast)
 
-		resp, err = client1.PrivateMessage("topic1", data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+		resp, err = client1.PrivateMessage("topic1", "", data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 		require.NoError(suite.T(), err)
 		assert.Equal(suite.T(), 202, resp.StatusCode())
 		e2e.WaitForMessageConfirmed(suite.T(), received1, core.MessageTypePrivate)
@@ -160,7 +160,7 @@ func runMigrationTest(suite *ContractMigrationTestSuite, address1, address2 stri
 	})
 
 	// Verify that a broadcast on the new namespace succeeds under the second contract
-	resp, err := client1.BroadcastMessage(suite.T(), "topic", data, false)
+	resp, err := client1.BroadcastMessage(suite.T(), "topic", "", data, false)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), received1, core.MessageTypeBroadcast)
@@ -172,7 +172,7 @@ func runMigrationTest(suite *ContractMigrationTestSuite, address1, address2 stri
 	assert.Equal(suite.T(), address1, strings.ToLower(events[1].Info["address"].(string)))
 
 	// Verify that a private message on the new namespace succeeds under the second contract
-	resp, err = client1.PrivateMessage("topic1", data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err = client1.PrivateMessage("topic1", "", data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), received1, core.MessageTypePrivate)
diff --git a/test/e2e/multiparty/ethereum_contracts.go b/test/e2e/multiparty/ethereum_contracts.go
index 3b3633ecd..329b125c5 100644
--- a/test/e2e/multiparty/ethereum_contracts.go
+++ b/test/e2e/multiparty/ethereum_contracts.go
@@ -215,14 +215,19 @@ func (suite *EthereumContractTestSuite) TestFFIInvokeMethod() {
 		Input: map[string]interface{}{
 			"newValue": float64(42),
 		},
-		Interface:  suite.interfaceID,
-		MethodPath: "set",
+		Interface:      suite.interfaceID,
+		MethodPath:     "set",
+		IdempotencyKey: core.IdempotencyKey(fftypes.NewUUID().String()),
 	}
 
 	res, err := suite.testState.client1.InvokeContractMethod(suite.T(), invokeContractRequest)
 	assert.NoError(suite.T(), err)
 	assert.NotNil(suite.T(), res)
 
+	// Idempotency check
+	_, err = suite.testState.client1.InvokeContractMethod(suite.T(), invokeContractRequest, 409)
+	assert.NoError(suite.T(), err)
+
 	match := map[string]interface{}{
 		"info": map[string]interface{}{
 			"address": suite.contractAddress,
diff --git a/test/e2e/multiparty/identity.go b/test/e2e/multiparty/identity.go
index 038d28bd2..9fa573e81 100644
--- a/test/e2e/multiparty/identity.go
+++ b/test/e2e/multiparty/identity.go
@@ -85,7 +85,7 @@ func (suite *IdentityTestSuite) TestCustomChildIdentityBroadcasts() {
 
 	// Send a broadcast from each custom identity
 	for did := range identities {
-		resp, err := suite.testState.client1.BroadcastMessageAsIdentity(suite.T(), did, "identitytest", &core.DataRefOrValue{
+		resp, err := suite.testState.client1.BroadcastMessageAsIdentity(suite.T(), did, "identitytest", "", &core.DataRefOrValue{
 			Value: fftypes.JSONAnyPtr(`{"some": "data"}`),
 		}, false)
 		require.NoError(suite.T(), err)
@@ -132,7 +132,7 @@ func (suite *IdentityTestSuite) TestCustomChildIdentityPrivate() {
 		{Identity: custom2.DID},
 	}
 
-	resp, err := suite.testState.client1.PrivateMessageWithKey(org1key, "topic1", &core.DataRefOrValue{
+	resp, err := suite.testState.client1.PrivateMessageWithKey(org1key, "topic1", "", &core.DataRefOrValue{
 		Value: fftypes.JSONAnyPtr(`"test private custom identity"`),
 	}, members, "tag1", core.TransactionTypeBatchPin, true, suite.testState.startTime)
 	require.NoError(suite.T(), err)
diff --git a/test/e2e/multiparty/multi_tenancy.go b/test/e2e/multiparty/multi_tenancy.go
index 766ff3deb..86722699c 100644
--- a/test/e2e/multiparty/multi_tenancy.go
+++ b/test/e2e/multiparty/multi_tenancy.go
@@ -136,7 +136,7 @@ func (suite *NamespaceAliasSuite) TestMultiTenancy() {
 	clientCharlie.RegisterSelfNode(suite.T(), true)
 
 	// Verify that a broadcast on the new namespace succeeds
-	resp, err := clientAlice.BroadcastMessage(suite.T(), "topic", data, false)
+	resp, err := clientAlice.BroadcastMessage(suite.T(), "topic", "", data, false)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), receivedAlice, core.MessageTypeBroadcast)
@@ -160,26 +160,26 @@ func (suite *NamespaceAliasSuite) TestMultiTenancy() {
 
 	// Verify that private messages on the new namespace succeed
 	// Alice -> Charlie
-	resp, err = clientAlice.PrivateMessage("topic", data, toCharlie, "tag1", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err = clientAlice.PrivateMessage("topic", "", data, toCharlie, "tag1", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), receivedAlice, core.MessageTypePrivate)
 	e2e.WaitForMessageConfirmed(suite.T(), receivedCharlie, core.MessageTypePrivate)
 	// Charlie -> Alice
-	resp, err = clientCharlie.PrivateMessage("topic", data, toAlice, "tag2", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err = clientCharlie.PrivateMessage("topic", "", data, toAlice, "tag2", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), receivedAlice, core.MessageTypePrivate)
 	e2e.WaitForMessageConfirmed(suite.T(), receivedCharlie, core.MessageTypePrivate)
 	// Charlie -> Alice+Bob
-	resp, err = clientCharlie.PrivateMessage("topic", data, toAll, "tag3", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err = clientCharlie.PrivateMessage("topic", "", data, toAll, "tag3", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), receivedAlice, core.MessageTypePrivate)
 	e2e.WaitForMessageConfirmed(suite.T(), receivedBob, core.MessageTypePrivate)
 	e2e.WaitForMessageConfirmed(suite.T(), receivedCharlie, core.MessageTypePrivate)
 	// Alice -> Bob
-	resp, err = clientAlice.PrivateMessage("topic", data, toBob, "tag4", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err = clientAlice.PrivateMessage("topic", "", data, toBob, "tag4", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 	e2e.WaitForMessageConfirmed(suite.T(), receivedAlice, core.MessageTypePrivate)
diff --git a/test/e2e/multiparty/onchain_offchain.go b/test/e2e/multiparty/onchain_offchain.go
index 0119ee550..165f7efb1 100644
--- a/test/e2e/multiparty/onchain_offchain.go
+++ b/test/e2e/multiparty/onchain_offchain.go
@@ -57,6 +57,7 @@ func (suite *OnChainOffChainTestSuite) TestE2EBroadcast() {
 	received2 := e2e.WsReader(suite.testState.ws2)
 
 	// Broadcast some messages, that should get batched, across two topics
+	testUUID := fftypes.NewUUID()
 	totalMessages := 10
 	topics := []string{"topicA", "topicB"}
 	expectedData := make(map[string][]*core.DataRefOrValue)
@@ -69,9 +70,15 @@ func (suite *OnChainOffChainTestSuite) TestE2EBroadcast() {
 
 		expectedData[topic] = append(expectedData[topic], data)
 
-		resp, err := suite.testState.client1.BroadcastMessage(suite.T(), topic, data, false)
+		idempotencyKey := fmt.Sprintf("%s/%d", testUUID, i)
+		resp, err := suite.testState.client1.BroadcastMessage(suite.T(), topic, idempotencyKey, data, false)
 		require.NoError(suite.T(), err)
 		assert.Equal(suite.T(), 202, resp.StatusCode())
+
+		// Ensure idempotency
+		resp, err = suite.testState.client1.BroadcastMessage(suite.T(), topic, idempotencyKey, data, false)
+		require.NoError(suite.T(), err)
+		assert.Equal(suite.T(), 409, resp.StatusCode())
 	}
 
 	for i := 0; i < totalMessages; i++ {
@@ -111,7 +118,7 @@ func (suite *OnChainOffChainTestSuite) TestStrongDatatypesBroadcast() {
 	}
 
 	// Should be rejected as datatype not known
-	resp, err := suite.testState.client1.BroadcastMessage(suite.T(), "topic1", &data, true)
+	resp, err := suite.testState.client1.BroadcastMessage(suite.T(), "topic1", "", &data, true)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 400, resp.StatusCode())
 	assert.Contains(suite.T(), resp.String(), "FF10195") // datatype not found
@@ -123,7 +130,7 @@ func (suite *OnChainOffChainTestSuite) TestStrongDatatypesBroadcast() {
 	}
 	suite.testState.client1.CreateDatatype(suite.T(), dt, true)
 
-	resp, err = suite.testState.client1.BroadcastMessage(suite.T(), "topic1", &data, true)
+	resp, err = suite.testState.client1.BroadcastMessage(suite.T(), "topic1", "", &data, true)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 400, resp.StatusCode())
 	assert.Contains(suite.T(), resp.String(), "FF10198") // does not conform
@@ -133,7 +140,7 @@ func (suite *OnChainOffChainTestSuite) TestStrongDatatypesBroadcast() {
 		"name": "mywidget"
 	}`)
 
-	resp, err = suite.testState.client1.BroadcastMessage(suite.T(), "topic1", &data, true)
+	resp, err = suite.testState.client1.BroadcastMessage(suite.T(), "topic1", "", &data, true)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 200, resp.StatusCode())
 
@@ -165,7 +172,7 @@ func (suite *OnChainOffChainTestSuite) TestStrongDatatypesPrivate() {
 	}
 
 	// Should be rejected as datatype not known
-	resp, err := suite.testState.client1.PrivateMessage("topic1", &data, members, "", core.TransactionTypeBatchPin, true, suite.testState.startTime)
+	resp, err := suite.testState.client1.PrivateMessage("topic1", "", &data, members, "", core.TransactionTypeBatchPin, true, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 400, resp.StatusCode())
 	assert.Contains(suite.T(), resp.String(), "FF10195") // datatype not found
@@ -177,7 +184,7 @@ func (suite *OnChainOffChainTestSuite) TestStrongDatatypesPrivate() {
 	}
 	suite.testState.client1.CreateDatatype(suite.T(), dt, true)
 
-	resp, err = suite.testState.client1.PrivateMessage("topic1", &data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err = suite.testState.client1.PrivateMessage("topic1", "", &data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 400, resp.StatusCode())
 	assert.Contains(suite.T(), resp.String(), "FF10198") // does not conform
@@ -187,7 +194,7 @@ func (suite *OnChainOffChainTestSuite) TestStrongDatatypesPrivate() {
 		"name": "mywidget"
 	}`)
 
-	resp, err = suite.testState.client1.PrivateMessage("topic1", &data, members, "", core.TransactionTypeBatchPin, true, suite.testState.startTime)
+	resp, err = suite.testState.client1.PrivateMessage("topic1", "", &data, members, "", core.TransactionTypeBatchPin, true, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 200, resp.StatusCode())
 
@@ -211,6 +218,7 @@ func (suite *OnChainOffChainTestSuite) TestE2EPrivate() {
 	totalMessages := 10
 	topics := []string{"topicA", "topicB"}
 	expectedData := make(map[string][]*core.DataRefOrValue)
+	testUUID := fftypes.NewUUID()
 	for i := 0; i < 10; i++ {
 		value := fftypes.JSONAnyPtr(fmt.Sprintf(`"Hello number %d"`, i))
 		data := &core.DataRefOrValue{
@@ -220,9 +228,16 @@ func (suite *OnChainOffChainTestSuite) TestE2EPrivate() {
 
 		expectedData[topic] = append(expectedData[topic], data)
 
-		resp, err := suite.testState.client1.PrivateMessage(topic, data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+		idempotencyKey := fmt.Sprintf("%s/%d", testUUID, i)
+
+		resp, err := suite.testState.client1.PrivateMessage(topic, idempotencyKey, data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 		require.NoError(suite.T(), err)
 		assert.Equal(suite.T(), 202, resp.StatusCode())
+
+		// Ensure idempotency
+		resp, err = suite.testState.client1.PrivateMessage(topic, idempotencyKey, data, members, "", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+		require.NoError(suite.T(), err)
+		assert.Equal(suite.T(), 409, resp.StatusCode())
 	}
 
 	for i := 0; i < totalMessages; i++ {
@@ -336,7 +351,7 @@ func (suite *OnChainOffChainTestSuite) TestE2EWebhookExchange() {
 	}
 
 	var resp *resty.Response
-	resp, err := suite.testState.client1.PrivateMessage("topic1", &data, members, "myrequest", core.TransactionTypeBatchPin, false, suite.testState.startTime)
+	resp, err := suite.testState.client1.PrivateMessage("topic1", "", &data, members, "myrequest", core.TransactionTypeBatchPin, false, suite.testState.startTime)
 	require.NoError(suite.T(), err)
 	assert.Equal(suite.T(), 202, resp.StatusCode())
 
diff --git a/test/e2e/multiparty/tokens.go b/test/e2e/multiparty/tokens.go
index e0d4e9ba4..1ba4dcc86 100644
--- a/test/e2e/multiparty/tokens.go
+++ b/test/e2e/multiparty/tokens.go
@@ -89,9 +89,11 @@ func (suite *TokensTestSuite) TestE2EFungibleTokensAsync() {
 			Operator: suite.testState.org2key.Value,
 			Approved: true,
 		},
-		Pool: poolName,
+		Pool:           poolName,
+		IdempotencyKey: core.IdempotencyKey(fftypes.NewUUID().String()),
 	}
 	approvalOut := suite.testState.client1.TokenApproval(suite.T(), approval, false)
+	_ = suite.testState.client1.TokenApproval(suite.T(), approval, false, 409) // check for idempotency key rejection
 
 	e2e.WaitForEvent(suite.T(), received1, core.EventTypeApprovalConfirmed, approvalOut.LocalID)
 	approvals := suite.testState.client1.GetTokenApprovals(suite.T(), poolID)
@@ -100,10 +102,12 @@ func (suite *TokensTestSuite) TestE2EFungibleTokensAsync() {
 	assert.Equal(suite.T(), true, approvals[0].Approved)
 
 	transfer := &core.TokenTransferInput{
-		TokenTransfer: core.TokenTransfer{Amount: *fftypes.NewFFBigInt(1)},
-		Pool:          poolName,
+		TokenTransfer:  core.TokenTransfer{Amount: *fftypes.NewFFBigInt(1)},
+		Pool:           poolName,
+		IdempotencyKey: core.IdempotencyKey(fftypes.NewUUID().String()),
 	}
 	transferOut := suite.testState.client1.MintTokens(suite.T(), transfer, false)
+	_ = suite.testState.client1.MintTokens(suite.T(), transfer, false, 409) // check for idempotency key rejection
 
 	e2e.WaitForEvent(suite.T(), received1, core.EventTypeTransferConfirmed, transferOut.LocalID)
 	transfers := suite.testState.client1.GetTokenTransfers(suite.T(), poolID)
@@ -140,8 +144,10 @@ func (suite *TokensTestSuite) TestE2EFungibleTokensAsync() {
 				},
 			},
 		},
+		IdempotencyKey: core.IdempotencyKey(fftypes.NewUUID().String()),
 	}
 	transferOut = suite.testState.client2.TransferTokens(suite.T(), transfer, false)
+	_ = suite.testState.client2.TransferTokens(suite.T(), transfer, false, 409) // check for idempotency key rejection
 
 	e2e.WaitForEvent(suite.T(), received1, core.EventTypeMessageConfirmed, transferOut.Message)
 	transfers = suite.testState.client1.GetTokenTransfers(suite.T(), poolID)