diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx
new file mode 100644
index 0000000000..c58167e8e0
--- /dev/null
+++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx
@@ -0,0 +1,531 @@
+---
+sidebar_label: '3. Making State-Mutating Requests'
+---
+
+import CodeBlock from '@theme/CodeBlock';
+import ordersService2 from '!!raw-loader!../02-read-only-requests/orders-service.ts';
+import ordersServiceMethodActionTypes2 from '!!raw-loader!../02-read-only-requests/orders-service-method-action-types.ts';
+import ordersServiceTest2 from '!!raw-loader!../02-read-only-requests/orders-service.test.ts';
+import ordersService3 from '!!raw-loader!./orders-service.ts';
+import ordersServiceMethodActionTypes3 from '!!raw-loader!./orders-service-method-action-types.ts';
+import ordersServiceTest3 from '!!raw-loader!./orders-service.test.ts';
+
+# Writing a Data Service, Part 3: Making State-Mutating Requests
+
+In [part 1](../01-getting-started) and [part 2](../02-read-only-requests) of this tutorial we've created a service class that handles the following API:
+
+- **GET `/v1/orders`**: Retrieve a paginated list of orders, limited to 100 at a time (latest first by default).
+- **GET `/v1/orders/:id`**: Retrieve data about an order.
+
+Here's what we have so far:
+
+
+ View code
+
+ {ordersService2}
+
+
+ {ordersServiceMethodActionTypes2}
+
+
+ {ordersServiceTest2}
+
+
+
+Let's say that our API now allows us to place and cancel orders as needed. Now we have the following operations:
+
+- **POST `/v1/orders`**: Enqueue a new order for processing.
+- **DELETE `/v1/orders/:id`**: Cancel a pending order.
+
+Up to now, the operations we've supported are read-only, but the new operations are different, because they can change data on the server side.
+
+Let's now add these to our data service.
+
+## Creating a new order
+
+### Implementing the new method
+
+Starting with the `POST` endpoint, we'll add some types:
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+/**
+ * An order object that the Orders API returns.
+ */
+export type ResponseOrder = Infer;
+
+// highlight-start
+/**
+ * The arguments for `createOrder`.
+ */
+export type CreateOrderParams = Omit<
+ ResponseOrder,
+ 'createdTime' | 'orderId' | 'status' | 'updatedTime'
+>;
+// highlight-end
+```
+
+Now we'll add a new method. Note the following:
+
+- We pass `method` and `body` to the `fetch` function.
+- We pass a `staleTime` of 0 to `fetchQuery`. This instructs TanStack Query not to cache these kinds of requests.
+- We reuse `FetchOrderResponse` and `FetchOrderResponseStruct`, which we [previously defined for `fetchOrder`](../02-read-only-requests#handling-the-response).
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+export class OrdersService extends BaseDataService* ... */> {
+ // ...
+
+ /**
+ * Creates an order.
+ *
+ * @param params - The params.
+ * @param params.details - Extra data with which to create the order.
+ * @param params.from - The sender.
+ * @param params.objectId - The ID of the object being sent. If `type` is
+ * "asset", a CAIP-19 asset ID; if `type` is "token", a CAIP-19 asset type.
+ * @param params.to - The recipient.
+ * @param params.type - The type of object being sent (either "asset" or
+ * "token").
+ * @returns The created order.
+ */
+ async createOrder(params: CreateOrderParams): Promise {
+ const url = new URL(`/v1/orders`, BASE_URL);
+
+ const responseData = await this.fetchQuery({
+ queryKey: [`${this.name}:createOrder`, url.toString()],
+ queryFn: async () => {
+ const response = await fetch(url, {
+ method: 'POST',
+ body: JSON.stringify(params),
+ });
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Orders API failed with status '${response.status}'`,
+ );
+ }
+
+ return response.json();
+ },
+ staleTime: 0,
+ });
+
+ const [error, validatedResponseData] = validate(
+ responseData,
+ FetchOrderResponseStruct,
+ );
+ if (error) {
+ throw new Error(
+ `Malformed response received from Orders API (${error.toString()})`,
+ );
+ }
+
+ return validatedResponseData;
+ }
+}
+```
+
+As with `fetchOrders` and `fetchOrder`, we'll register an action for the new method:
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+const MESSENGER_EXPOSED_METHODS = [
+ 'fetchOrders',
+ 'fetchOrder',
+ // highlight-next-line
+ 'createOrder',
+] as const;
+```
+
+We'll run `yarn workspace @metamask/orders-service run generate-action-types` and see that `packages/orders-service/src/orders-service-method-action-types.ts` has this additional content:
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+// highlight-start
+/**
+ * Retrieves details about an order.
+ *
+ * @param params - The order ID.
+ * @returns The requested order.
+ */
+export type OrdersServiceCreateOrderAction = {
+ type: `OrdersService:createOrder`;
+ handler: OrdersService['createOrder'];
+};
+// highlight-end
+
+/**
+ * Union of all OrdersService action types.
+ */
+export type OrdersServiceMethodActions =
+ | OrdersServiceFetchOrdersAction
+ | OrdersServiceFetchOrderAction
+ // highlight-next-line
+ | OrdersServiceCreateOrderAction;
+```
+
+Finally, we'll write tests. We'll update the mock objects at the top of the test:
+
+```typescript title="packages/orders-service/src/orders-service.test.ts"
+// ::diff-added-start::
+const MOCK_ORDER = {
+ details: {
+ amount: '0xde0b6b3a7640000',
+ },
+ from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d',
+ to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ type: 'token',
+} satisfies CreateOrderParams;
+// ::diff-added-end::
+
+const MOCK_VALID_ORDER_RESPONSE_DATA = {
+ order: {
+ // ::diff-added-next::
+ ...MOCK_ORDER,
+ createdTime: 1747526400,
+ // ::diff-deleted-start::
+ details: {
+ amount: '0xde0b6b3a7640000',
+ },
+ from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d',
+ // ::diff-deleted-end::
+ orderId: '0000000000000000001',
+ status: 'pending',
+ // ::diff-deleted-start::
+ to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ type: 'token',
+ // ::diff-deleted-end::
+ updatedTime: 1747526400,
+ },
+} satisfies FetchOrderResponse;
+```
+
+### Adding tests
+
+Then we'll add the new tests:
+
+```typescript title="packages/orders-service/src/orders-service.test.ts"
+describe('OrdersService', () => {
+ // ...
+
+ describe('OrdersService:createOrder', () => {
+ it('creates an order', async () => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:createOrder',
+ MOCK_ORDER,
+ );
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA);
+ });
+
+ it('throws if the API returns a non-200 status', async () => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .times(DEFAULT_MAX_RETRIES + 1)
+ .reply(500);
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:createOrder', MOCK_ORDER),
+ ).rejects.toThrow("Orders API failed with status '500'");
+ });
+
+ it.each([
+ 'not an object',
+ { missing: 'order' },
+ { order: 'not an array' },
+ { order: ['not an object'] },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ createdTime: 'not a timestamp',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ createdTime: 2 ** 53 - 1,
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ details: 'not an object',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ from: 'not a CAIP account ID',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ orderId: {
+ not: 'a string',
+ },
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ status: 'not a valid status',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ to: 'not a CAIP account ID',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ updatedTime: 'not a timestamp',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ objectId: 'not a CAIP asset type',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ type: 'not a valid type',
+ },
+ },
+ ])(
+ 'throws if the API returns a malformed response %o',
+ async (response) => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .reply(200, JSON.stringify(response));
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:createOrder', MOCK_ORDER),
+ ).rejects.toThrow('Malformed response received from Orders API');
+ },
+ );
+
+ it('does not cache requests', async () => {
+ const scope = nock('https://api.example.com')
+ .post('/v1/orders')
+ .times(2)
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER);
+ await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER);
+ expect(scope.isDone()).toBe(true);
+ });
+ });
+
+ describe('createOrder', () => {
+ it('creates an order, same as the messenger action', async () => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { service } = createService();
+
+ const responseData = await service.createOrder(MOCK_ORDER);
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA);
+ });
+ });
+});
+```
+
+## Deleting an order
+
+### Implementing the method
+
+Let's also implement the `DELETE` endpoint. This is similar to `createOrder` except that we don't need to capture the response data (we will assume there is none).
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+export class OrdersService extends BaseDataService* ... */> {
+ // ...
+
+ /**
+ * Cancels an order.
+ *
+ * @param id - The order ID.
+ */
+ async cancelOrder(id: string): Promise {
+ const url = new URL(`/v1/orders/${id}`, BASE_URL);
+
+ await this.fetchQuery({
+ queryKey: [`${this.name}:cancelOrder`, url.toString()],
+ queryFn: async () => {
+ const response = await fetch(url, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Orders API failed with status '${response.status}'`,
+ );
+ }
+
+ return response.json();
+ },
+ staleTime: 0,
+ });
+ }
+}
+```
+
+As before we'll update `MESSENGER_EXPOSED_METHODS`:
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+const MESSENGER_EXPOSED_METHODS = [
+ 'fetchOrders',
+ 'fetchOrder',
+ 'createOrder',
+ // highlight-next-line
+ 'cancelOrder',
+] as const;
+```
+
+And we'll update the method action types file:
+
+```typescript title="packages/orders-service/src/orders-service.ts"
+// highlight-start
+/**
+ * Cancels an order.
+ *
+ * @param id - The order ID.
+ */
+export type OrdersServiceCancelOrderAction = {
+ type: `OrdersService:cancelOrder`;
+ handler: OrdersService['cancelOrder'];
+};
+// highlight-end
+
+/**
+ * Union of all OrdersService action types.
+ */
+export type OrdersServiceMethodActions =
+ | OrdersServiceFetchOrdersAction
+ | OrdersServiceFetchOrderAction
+ | OrdersServiceCreateOrderAction
+ // highlight-next-line
+ | OrdersServiceCancelOrderAction;
+```
+
+### Adding tests
+
+We'll also add tests:
+
+```typescript title="packages/orders-service/src/orders-service.test.ts"
+describe('OrdersService', () => {
+ // ...
+
+ describe('OrdersService:cancelOrder', () => {
+ it('cancels an order', async () => {
+ nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .reply(200);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:cancelOrder',
+ '0000000000000000001',
+ );
+
+ expect(responseData).toBeUndefined();
+ });
+
+ it('throws if the API returns a non-200 status', async () => {
+ nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .times(DEFAULT_MAX_RETRIES + 1)
+ .reply(500);
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:cancelOrder', '0000000000000000001'),
+ ).rejects.toThrow("Orders API failed with status '500'");
+ });
+
+ it('does not cache requests', async () => {
+ const scope = nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .times(2)
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ await rootMessenger.call(
+ 'OrdersService:cancelOrder',
+ '0000000000000000001',
+ );
+ await rootMessenger.call(
+ 'OrdersService:cancelOrder',
+ '0000000000000000001',
+ );
+ expect(scope.isDone()).toBe(true);
+ });
+ });
+
+ describe('cancelOrder', () => {
+ it('cancels an order, same as the messenger action', async () => {
+ nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .reply(200);
+ const { service } = createService();
+
+ const responseData = await service.cancelOrder('0000000000000000001');
+
+ expect(responseData).toBeUndefined();
+ });
+ });
+});
+```
+
+## Summary
+
+In this section we added two methods, `createOrder` and `deleteOrder`, which use `POST` and `DELETE` HTTP methods to change the state of the server.
+
+Here's what we have so far:
+
+
+ View code
+
+ {ordersService3}
+
+
+ {ordersServiceMethodActionTypes3}
+
+
+ {ordersServiceTest3}
+
+
+
+What else might we want to add to our data service? In the next section (upcoming), we'll discuss how to subscribe to a feed of orders as they are created.
diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts
new file mode 100644
index 0000000000..8cb33dd870
--- /dev/null
+++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts
@@ -0,0 +1,68 @@
+/**
+ * This file is auto generated.
+ * Do not edit manually.
+ */
+
+import type { OrdersService } from './orders-service';
+
+/**
+ * Retrieves orders.
+ *
+ * @param params - Parameters to qualify the request.
+ * @param params.sortField - The field by which to sort the list of orders.
+ * @param params.sortOrder - The direction in which to sort the list of
+ * orders.
+ * @returns The orders from the API.
+ */
+export type OrdersServiceFetchOrdersAction = {
+ type: `OrdersService:fetchOrders`;
+ handler: OrdersService['fetchOrders'];
+};
+
+/**
+ * Retrieves details about an order.
+ *
+ * @param id - The order ID.
+ * @returns The requested order.
+ */
+export type OrdersServiceFetchOrderAction = {
+ type: `OrdersService:fetchOrder`;
+ handler: OrdersService['fetchOrder'];
+};
+
+/**
+ * Creates an order.
+ *
+ * @param params - The params.
+ * @param params.details - Extra data with which to create the order.
+ * @param params.from - The sender.
+ * @param params.objectId - The ID of the object being sent. If `type` is
+ * "asset", a CAIP-19 asset ID; if `type` is "token", a CAIP-19 asset type.
+ * @param params.to - The recipient.
+ * @param params.type - The type of object being sent (either "asset" or
+ * "token").
+ * @returns The created order.
+ */
+export type OrdersServiceCreateOrderAction = {
+ type: `OrdersService:createOrder`;
+ handler: OrdersService['createOrder'];
+};
+
+/**
+ * Cancels an order.
+ *
+ * @param id - The order ID.
+ */
+export type OrdersServiceCancelOrderAction = {
+ type: `OrdersService:cancelOrder`;
+ handler: OrdersService['cancelOrder'];
+};
+
+/**
+ * Union of all OrdersService action types.
+ */
+export type OrdersServiceMethodActions =
+ | OrdersServiceFetchOrdersAction
+ | OrdersServiceFetchOrderAction
+ | OrdersServiceCreateOrderAction
+ | OrdersServiceCancelOrderAction;
diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts
new file mode 100644
index 0000000000..bf61bcffec
--- /dev/null
+++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts
@@ -0,0 +1,609 @@
+import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils';
+import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
+import type {
+ MockAnyNamespace,
+ MessengerActions,
+ MessengerEvents,
+} from '@metamask/messenger';
+import nock from 'nock';
+
+import type {
+ CreateOrderParams,
+ FetchOrderResponse,
+ FetchOrdersResponse,
+ OrdersServiceMessenger,
+} from './orders-service';
+import { OrdersService } from './orders-service';
+
+const MOCK_VALID_ORDERS_RESPONSE_DATA = {
+ orders: [
+ {
+ createdTime: 1747526400,
+ details: {
+ amount: '0xde0b6b3a7640000',
+ },
+ from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d',
+ orderId: '0000000000000000001',
+ status: 'pending',
+ to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ type: 'token',
+ updatedTime: 1747526400,
+ },
+ {
+ createdTime: 1747440000,
+ from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ objectId:
+ 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769',
+ orderId: '0000000000000000002',
+ status: 'completed',
+ to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ type: 'asset',
+ updatedTime: 1747526400,
+ },
+ ],
+} satisfies FetchOrdersResponse;
+
+const MOCK_ORDER = {
+ details: {
+ amount: '0xde0b6b3a7640000',
+ },
+ from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d',
+ to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ type: 'token',
+} satisfies CreateOrderParams;
+
+const MOCK_VALID_ORDER_RESPONSE_DATA = {
+ order: {
+ ...MOCK_ORDER,
+ orderId: '0000000000000000001',
+ createdTime: 1747526400,
+ updatedTime: 1747526400,
+ status: 'pending',
+ },
+} satisfies FetchOrderResponse;
+
+describe('OrdersService', () => {
+ describe('OrdersService:fetchOrders', () => {
+ it('requests orders with the default sortField and sortOrder', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders')
+ .query({ sortField: 'createdTime', sortOrder: 'asc' })
+ .reply(200, MOCK_VALID_ORDERS_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:fetchOrders',
+ );
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDERS_RESPONSE_DATA);
+ });
+
+ it('requests orders with the given sortField and sortOrder', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders')
+ .query({ sortField: 'updatedTime', sortOrder: 'desc' })
+ .reply(200, MOCK_VALID_ORDERS_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:fetchOrders',
+ {
+ sortField: 'updatedTime',
+ sortOrder: 'desc',
+ },
+ );
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDERS_RESPONSE_DATA);
+ });
+
+ it('throws if the API returns a non-200 status', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders')
+ .query({ sortField: 'createdTime', sortOrder: 'asc' })
+ .times(DEFAULT_MAX_RETRIES + 1)
+ .reply(500);
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:fetchOrders'),
+ ).rejects.toThrow("Orders API failed with status '500'");
+ });
+
+ it.each([
+ 'not an object',
+ { missing: 'orders' },
+ { orders: 'not an array' },
+ { orders: ['not an object'] },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ createdTime: 'not a timestamp',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ createdTime: 2 ** 53 - 1,
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ details: 'not an object',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ from: 'not a CAIP account ID',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ orderId: {
+ not: 'a string',
+ },
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ status: 'not a valid status',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ to: 'not a CAIP account ID',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ updatedTime: 'not a timestamp',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ objectId: 'not a CAIP asset type',
+ },
+ ],
+ },
+ {
+ orders: [
+ {
+ ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0],
+ type: 'not a valid type',
+ },
+ ],
+ },
+ ])(
+ 'throws if the API returns a malformed response %o',
+ async (response) => {
+ nock('https://api.example.com')
+ .get('/v1/orders')
+ .query({ sortField: 'createdTime', sortOrder: 'asc' })
+ .reply(200, JSON.stringify(response));
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:fetchOrders'),
+ ).rejects.toThrow('Malformed response received from Orders API');
+ },
+ );
+ });
+
+ describe('fetchOrders', () => {
+ it('requests orders from the API, same as the messenger action', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders')
+ .query({ sortField: 'createdTime', sortOrder: 'asc' })
+ .reply(200, MOCK_VALID_ORDERS_RESPONSE_DATA);
+ const { service } = createService();
+
+ const responseData = await service.fetchOrders();
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDERS_RESPONSE_DATA);
+ });
+ });
+
+ describe('OrdersService:fetchOrder', () => {
+ it('requests an order with the default sortField and sortOrder', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders/AAAA-BBBB-CCCC-DDDD')
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:fetchOrder',
+ 'AAAA-BBBB-CCCC-DDDD',
+ );
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA);
+ });
+
+ it('throws if the API returns a non-200 status', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders/AAAA-BBBB-CCCC-DDDD')
+ .times(DEFAULT_MAX_RETRIES + 1)
+ .reply(500);
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:fetchOrder', 'AAAA-BBBB-CCCC-DDDD'),
+ ).rejects.toThrow("Orders API failed with status '500'");
+ });
+
+ it.each([
+ 'not an object',
+ { missing: 'order' },
+ { order: 'not an array' },
+ { order: ['not an object'] },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ createdTime: 'not a timestamp',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ createdTime: 2 ** 53 - 1,
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ details: 'not an object',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ from: 'not a CAIP account ID',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ orderId: {
+ not: 'a string',
+ },
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ status: 'not a valid status',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ to: 'not a CAIP account ID',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ updatedTime: 'not a timestamp',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ objectId: 'not a CAIP asset type',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ type: 'not a valid type',
+ },
+ },
+ ])(
+ 'throws if the API returns a malformed response %o',
+ async (response) => {
+ nock('https://api.example.com')
+ .get('/v1/orders/AAAA-BBBB-CCCC-DDDD')
+ .reply(200, JSON.stringify(response));
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:fetchOrder', 'AAAA-BBBB-CCCC-DDDD'),
+ ).rejects.toThrow('Malformed response received from Orders API');
+ },
+ );
+ });
+
+ describe('fetchOrder', () => {
+ it('requests an order from the API, same as the messenger action', async () => {
+ nock('https://api.example.com')
+ .get('/v1/orders/AAAA-BBBB-CCCC-DDDD')
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { service } = createService();
+
+ const responseData = await service.fetchOrder('AAAA-BBBB-CCCC-DDDD');
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA);
+ });
+ });
+
+ describe('OrdersService:createOrder', () => {
+ it('creates an order', async () => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:createOrder',
+ MOCK_ORDER,
+ );
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA);
+ });
+
+ it('throws if the API returns a non-200 status', async () => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .times(DEFAULT_MAX_RETRIES + 1)
+ .reply(500);
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:createOrder', MOCK_ORDER),
+ ).rejects.toThrow("Orders API failed with status '500'");
+ });
+
+ it.each([
+ 'not an object',
+ { missing: 'order' },
+ { order: 'not an array' },
+ { order: ['not an object'] },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ createdTime: 'not a timestamp',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ createdTime: 2 ** 53 - 1,
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ details: 'not an object',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ from: 'not a CAIP account ID',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ orderId: {
+ not: 'a string',
+ },
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ status: 'not a valid status',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ to: 'not a CAIP account ID',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ updatedTime: 'not a timestamp',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ objectId: 'not a CAIP asset type',
+ },
+ },
+ {
+ order: {
+ ...MOCK_VALID_ORDER_RESPONSE_DATA.order,
+ type: 'not a valid type',
+ },
+ },
+ ])(
+ 'throws if the API returns a malformed response %o',
+ async (response) => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .reply(200, JSON.stringify(response));
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:createOrder', MOCK_ORDER),
+ ).rejects.toThrow('Malformed response received from Orders API');
+ },
+ );
+
+ it('does not cache requests', async () => {
+ const scope = nock('https://api.example.com')
+ .post('/v1/orders')
+ .times(2)
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER);
+ await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER);
+ expect(scope.isDone()).toBe(true);
+ });
+ });
+
+ describe('createOrder', () => {
+ it('creates an order, same as the messenger action', async () => {
+ nock('https://api.example.com')
+ .post('/v1/orders')
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { service } = createService();
+
+ const responseData = await service.createOrder(MOCK_ORDER);
+
+ expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA);
+ });
+ });
+
+ describe('OrdersService:cancelOrder', () => {
+ it('cancels an order', async () => {
+ nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .reply(200);
+ const { rootMessenger } = createService();
+
+ const responseData = await rootMessenger.call(
+ 'OrdersService:cancelOrder',
+ '0000000000000000001',
+ );
+
+ expect(responseData).toBeUndefined();
+ });
+
+ it('throws if the API returns a non-200 status', async () => {
+ nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .times(DEFAULT_MAX_RETRIES + 1)
+ .reply(500);
+ const { rootMessenger } = createService();
+
+ await expect(
+ rootMessenger.call('OrdersService:cancelOrder', '0000000000000000001'),
+ ).rejects.toThrow("Orders API failed with status '500'");
+ });
+
+ it('does not cache requests', async () => {
+ const scope = nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .times(2)
+ .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA);
+ const { rootMessenger } = createService();
+
+ await rootMessenger.call(
+ 'OrdersService:cancelOrder',
+ '0000000000000000001',
+ );
+ await rootMessenger.call(
+ 'OrdersService:cancelOrder',
+ '0000000000000000001',
+ );
+ expect(scope.isDone()).toBe(true);
+ });
+ });
+
+ describe('cancelOrder', () => {
+ it('cancels an order, same as the messenger action', async () => {
+ nock('https://api.example.com')
+ .delete('/v1/orders/0000000000000000001')
+ .reply(200);
+ const { service } = createService();
+
+ const responseData = await service.cancelOrder('0000000000000000001');
+
+ expect(responseData).toBeUndefined();
+ });
+ });
+});
+
+/**
+ * The type of the messenger populated with all external actions and events
+ * required by the service under test.
+ */
+type RootMessenger = Messenger<
+ MockAnyNamespace,
+ MessengerActions,
+ MessengerEvents
+>;
+
+/**
+ * Constructs the messenger populated with all external actions and events
+ * required by the service under test.
+ *
+ * @returns The root messenger.
+ */
+function createRootMessenger(): RootMessenger {
+ return new Messenger({ namespace: MOCK_ANY_NAMESPACE });
+}
+
+/**
+ * Constructs the messenger for the service under test.
+ *
+ * @param rootMessenger - The root messenger, with all external actions and
+ * events required by the controller's messenger.
+ * @returns The service-specific messenger.
+ */
+function createServiceMessenger(
+ rootMessenger: RootMessenger,
+): OrdersServiceMessenger {
+ return new Messenger({
+ namespace: 'OrdersService',
+ parent: rootMessenger,
+ });
+}
+
+/**
+ * Constructs the service under test.
+ *
+ * @param args - The arguments to this function.
+ * @param args.options - The options that the service constructor takes. All are
+ * optional and will be filled in with defaults in as needed (including
+ * `messenger`).
+ * @returns The new service, root messenger, and service messenger.
+ */
+function createService({
+ options = {},
+}: {
+ options?: Partial[0]>;
+} = {}): {
+ service: OrdersService;
+ rootMessenger: RootMessenger;
+ messenger: OrdersServiceMessenger;
+} {
+ const rootMessenger = createRootMessenger();
+ const messenger = createServiceMessenger(rootMessenger);
+ const service = new OrdersService({
+ messenger,
+ ...options,
+ });
+
+ return { service, rootMessenger, messenger };
+}
diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts
new file mode 100644
index 0000000000..3472897836
--- /dev/null
+++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts
@@ -0,0 +1,390 @@
+import { BaseDataService } from '@metamask/base-data-service';
+import type {
+ DataServiceCacheUpdatedEvent,
+ DataServiceGranularCacheUpdatedEvent,
+ DataServiceInvalidateQueriesAction,
+} from '@metamask/base-data-service';
+import type { CreateServicePolicyOptions } from '@metamask/controller-utils';
+import { HttpError } from '@metamask/controller-utils';
+import type { Messenger } from '@metamask/messenger';
+import type { Infer } from '@metamask/superstruct';
+import {
+ array,
+ intersection,
+ literal,
+ number,
+ optional,
+ record,
+ refine,
+ string,
+ type,
+ union,
+ unknown,
+ validate,
+} from '@metamask/superstruct';
+import {
+ CaipAccountIdStruct,
+ CaipAssetIdStruct,
+ CaipAssetTypeStruct,
+} from '@metamask/utils';
+import type { QueryClientConfig } from '@tanstack/query-core';
+
+import type { OrdersServiceMethodActions } from './orders-service-method-action-types';
+
+/**
+ * The name of the {@link OrdersService}, used to namespace the service's
+ * actions and events.
+ */
+export const DATA_SERVICE_NAME = 'OrdersService';
+
+/**
+ * All of the methods within {@link OrdersService} that are exposed via the
+ * messenger.
+ */
+const MESSENGER_EXPOSED_METHODS = [
+ 'fetchOrders',
+ 'fetchOrder',
+ 'createOrder',
+ 'cancelOrder',
+] as const;
+
+/**
+ * Invalidates cached queries for {@link OrdersService}.
+ */
+export type OrdersServiceInvalidateQueriesAction =
+ DataServiceInvalidateQueriesAction;
+
+/**
+ * Actions that {@link OrdersService} exposes to other consumers.
+ */
+export type OrdersServiceActions =
+ | OrdersServiceInvalidateQueriesAction
+ | OrdersServiceMethodActions;
+
+/**
+ * Actions from other messengers that {@link OrdersService} calls.
+ */
+type AllowedActions = never;
+
+/**
+ * Published when {@link OrdersService}'s cache is updated.
+ */
+export type OrdersServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent<
+ typeof DATA_SERVICE_NAME
+>;
+
+/**
+ * Published when a key within {@link OrdersService}'s cache is updated.
+ */
+export type OrdersServiceGranularCacheUpdatedEvent =
+ DataServiceGranularCacheUpdatedEvent;
+
+/**
+ * Events that {@link OrdersService} exposes to other consumers.
+ */
+export type OrdersServiceEvents =
+ | OrdersServiceCacheUpdatedEvent
+ | OrdersServiceGranularCacheUpdatedEvent;
+
+/**
+ * Events from other messengers that {@link OrdersService} subscribes to.
+ */
+type AllowedEvents = never;
+
+/**
+ * The messenger which is restricted to actions and events accessed by {@link
+ * OrdersService}.
+ */
+export type OrdersServiceMessenger = Messenger<
+ typeof DATA_SERVICE_NAME,
+ OrdersServiceActions | AllowedActions,
+ OrdersServiceEvents | AllowedEvents
+>;
+
+/**
+ * A struct that represents a timestamp (number of seconds since the UNIX
+ * epoch).
+ */
+const TimestampStruct = refine(number(), 'timestamp', (value) => {
+ if (new Date(value).toString() === 'Invalid Date') {
+ return 'Expected a valid timestamp';
+ }
+ return true;
+});
+
+/**
+ * Struct to validate an order object that the Orders API returns.
+ */
+const ResponseOrderStruct = intersection([
+ // Need to list this first, otherwise the inferred type is never
+ // See:
+ union([
+ type({
+ objectId: CaipAssetTypeStruct,
+ type: literal('token'),
+ }),
+ type({
+ objectId: CaipAssetIdStruct,
+ type: literal('asset'),
+ }),
+ ]),
+ type({
+ createdTime: TimestampStruct,
+ details: optional(record(string(), unknown())),
+ from: CaipAccountIdStruct,
+ orderId: string(),
+ status: union([
+ literal('pending'),
+ literal('completed'),
+ literal('canceled'),
+ ]),
+ to: CaipAccountIdStruct,
+ updatedTime: TimestampStruct,
+ }),
+]);
+
+/**
+ * An order object that the Orders API returns.
+ */
+export type ResponseOrder = Infer;
+
+/**
+ * Struct to validate what `GET /v1/orders` returns.
+ */
+const FetchOrdersResponseStruct = type({
+ orders: array(ResponseOrderStruct),
+});
+
+/**
+ * The data that `GET /v1/orders` returns.
+ */
+export type FetchOrdersResponse = Infer;
+
+/**
+ * Struct to validate what `GET /v1/orders/:id` returns.
+ */
+const FetchOrderResponseStruct = type({
+ order: ResponseOrderStruct,
+});
+
+/**
+ * The data that `GET /v1/orders/:id` returns.
+ */
+export type FetchOrderResponse = Infer;
+
+/**
+ * The arguments for `createOrder`.
+ */
+export type CreateOrderParams = Omit<
+ ResponseOrder,
+ 'createdTime' | 'orderId' | 'status' | 'updatedTime'
+>;
+
+/**
+ * The base URL of the API that the service represents.
+ */
+const BASE_URL = 'https://api.example.com';
+
+/**
+ * This service wraps the Orders API.
+ */
+export class OrdersService extends BaseDataService<
+ typeof DATA_SERVICE_NAME,
+ OrdersServiceMessenger
+> {
+ /**
+ * Constructs a new OrdersService object.
+ *
+ * @param args - The constructor arguments.
+ * @param args.messenger - The messenger suited for this service.
+ * @param args.queryClientConfig - Configuration for the underlying TanStack
+ * Query client.
+ * @param args.policyOptions - Options to pass to `createServicePolicy`, which
+ * is used to wrap each request. See {@link CreateServicePolicyOptions}.
+ */
+ constructor({
+ messenger,
+ queryClientConfig = {},
+ policyOptions = {},
+ }: {
+ messenger: OrdersServiceMessenger;
+ queryClientConfig?: QueryClientConfig;
+ policyOptions?: CreateServicePolicyOptions;
+ }) {
+ super({
+ name: DATA_SERVICE_NAME,
+ messenger,
+ queryClientConfig,
+ policyOptions,
+ });
+
+ this.messenger.registerMethodActionHandlers(
+ this,
+ MESSENGER_EXPOSED_METHODS,
+ );
+ }
+
+ /**
+ * Retrieves orders.
+ *
+ * @param params - Parameters to qualify the request.
+ * @param params.sortField - The field by which to sort the list of orders.
+ * @param params.sortOrder - The direction in which to sort the list of
+ * orders.
+ * @returns The orders from the API.
+ */
+ async fetchOrders({
+ sortField = 'createdTime',
+ sortOrder = 'asc',
+ }: {
+ sortField?: 'createdTime' | 'updatedTime';
+ sortOrder?: 'asc' | 'desc';
+ } = {}): Promise {
+ const url = new URL('/v1/orders', BASE_URL);
+ url.searchParams.append('sortField', sortField);
+ url.searchParams.append('sortOrder', sortOrder);
+
+ const responseData = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchOrders`, url.toString()],
+ queryFn: async () => {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Orders API failed with status '${response.status}'`,
+ );
+ }
+
+ return response.json();
+ },
+ });
+
+ const [error, validatedResponseData] = validate(
+ responseData,
+ FetchOrdersResponseStruct,
+ );
+ if (error) {
+ throw new Error(
+ `Malformed response received from Orders API (${error.toString()})`,
+ );
+ }
+
+ return validatedResponseData;
+ }
+
+ /**
+ * Retrieves details about an order.
+ *
+ * @param id - The order ID.
+ * @returns The requested order.
+ */
+ async fetchOrder(id: string): Promise {
+ const url = new URL(`/v1/orders/${id}`, BASE_URL);
+
+ const responseData = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchOrder`, url.toString()],
+ queryFn: async () => {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Orders API failed with status '${response.status}'`,
+ );
+ }
+
+ return response.json();
+ },
+ });
+
+ const [error, validatedResponseData] = validate(
+ responseData,
+ FetchOrderResponseStruct,
+ );
+ if (error) {
+ throw new Error(
+ `Malformed response received from Orders API (${error.toString()})`,
+ );
+ }
+
+ return validatedResponseData;
+ }
+
+ /**
+ * Creates an order.
+ *
+ * @param params - The params.
+ * @param params.details - Extra data with which to create the order.
+ * @param params.from - The sender.
+ * @param params.objectId - The ID of the object being sent. If `type` is
+ * "asset", a CAIP-19 asset ID; if `type` is "token", a CAIP-19 asset type.
+ * @param params.to - The recipient.
+ * @param params.type - The type of object being sent (either "asset" or
+ * "token").
+ * @returns The created order.
+ */
+ async createOrder(params: CreateOrderParams): Promise {
+ const url = new URL(`/v1/orders`, BASE_URL);
+
+ const responseData = await this.fetchQuery({
+ queryKey: [`${this.name}:createOrder`, url.toString()],
+ queryFn: async () => {
+ const response = await fetch(url, {
+ method: 'POST',
+ body: JSON.stringify(params),
+ });
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Orders API failed with status '${response.status}'`,
+ );
+ }
+
+ return response.json();
+ },
+ staleTime: 0,
+ });
+
+ const [error, validatedResponseData] = validate(
+ responseData,
+ FetchOrderResponseStruct,
+ );
+ if (error) {
+ throw new Error(
+ `Malformed response received from Orders API (${error.toString()})`,
+ );
+ }
+
+ return validatedResponseData;
+ }
+
+ /**
+ * Cancels an order.
+ *
+ * @param id - The order ID.
+ */
+ async cancelOrder(id: string): Promise {
+ const url = new URL(`/v1/orders/${id}`, BASE_URL);
+
+ await this.fetchQuery({
+ queryKey: [`${this.name}:cancelOrder`, url.toString()],
+ queryFn: async () => {
+ const response = await fetch(url, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Orders API failed with status '${response.status}'`,
+ );
+ }
+
+ return null;
+ },
+ staleTime: 0,
+ });
+ }
+}
diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md b/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md
index 55f048d8d9..ab96a544d4 100644
--- a/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md
+++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md
@@ -4,6 +4,6 @@ This tutorial is divided into 5 parts. You can navigate them below:
1. [Getting Started](./01-getting-started)
2. [Making Read-only Requests](./02-read-only-requests)
-3. Making State-Mutating Requests
+3. [Making State-Mutating Requests](./03-state-mutating-requests)
4. Data Subscriptions _(to be written)_
5. Advanced Use Cases _(to be written)_