diff --git a/src/client/pages/api-console.vue b/src/client/pages/api-console.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2b05038acf2d0285246f2a50e72757cf4c5569f0
--- /dev/null
+++ b/src/client/pages/api-console.vue
@@ -0,0 +1,96 @@
+<template>
+<div>
+<section class="_section">
+	<MkInput v-model:value="endpoint" :datalist="endpoints" @update:value="onEndpointChange()">
+		<span>Endpoint</span>
+	</MkInput>
+	<MkTextarea v-model:value="body" code>
+		<span>Params (JSON or JSON5)</span>
+	</MkTextarea>
+	<MkSwitch v-model:value="withCredential">
+		With credential
+	</MkSwitch>
+	<MkButton primary full @click="send" :disabled="sending">
+		<template v-if="sending"><MkEllipsis/></template>
+		<template v-else><Fa :icon="faPaperPlane"/> Send</template>
+	</MkButton>
+</section>
+<section class="_section" v-if="res">
+	<MkTextarea v-model:value="res" code readonly tall>
+		<span>Response</span>
+	</MkTextarea>
+</section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faTerminal, faPaperPlane } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/ui/input.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkButton, MkInput, MkTextarea, MkSwitch,
+	},
+
+	data() {
+		return {
+			INFO: {
+				header: [{
+					title: 'API console',
+					icon: faTerminal
+				}]
+			},
+
+			endpoint: '',
+			body: '{}',
+			res: null,
+			sending: false,
+			endpoints: [],
+			withCredential: true,
+
+			faPaperPlane
+		};
+	},
+
+	created() {
+		os.api('endpoints').then(endpoints => {
+			this.endpoints = endpoints;
+		});
+	},
+
+	methods: {
+		send() {
+			this.sending = true;
+			os.api(this.endpoint, JSON5.parse(this.body)).then(res => {
+				this.sending = false;
+				this.res = JSON5.stringify(res, null, 2);
+			}, err => {
+				this.sending = false;
+				this.res = JSON5.stringify(err, null, 2);
+			});
+		},
+
+		onEndpointChange() {
+			os.api('endpoint', { endpoint: this.endpoint }, this.withCredential ? undefined : null).then(endpoint => {
+				const body = {};
+				for (const p of endpoint.params) {
+					body[p.name] =
+						p.type === 'String' ? '' :
+						p.type === 'Number' ? 0 :
+						p.type === 'Boolean' ? false :
+						p.type === 'Array' ? [] :
+						p.type === 'Object' ? {} :
+						null;
+				}
+				this.body = JSON5.stringify(body, null, 2);
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue
index 326ba900629fac938794cbd376a1506bf1ea0ddb..4f14aa1ba7cdbac7094a9f402e43364e3aab23a1 100644
--- a/src/client/pages/settings/api.vue
+++ b/src/client/pages/settings/api.vue
@@ -1,9 +1,14 @@
 <template>
-<section class="_section">
-	<div class="_content">
-		<MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton>
+<div>
+	<div class="_section">
+		<div class="_content">
+			<MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton>
+		</div>
 	</div>
-</section>
+	<div class="_section">
+		<MkA to="/api-console">API console</MkA>
+	</div>
+</div>
 </template>
 
 <script lang="ts">
diff --git a/src/client/router.ts b/src/client/router.ts
index ef540f0d4ba87e52499c9fe1bbf3505c99cd7807..56320d224eb49159d45eb18729541922ceafb5b3 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -70,6 +70,7 @@ export const router = createRouter({
 		{ path: '/instance/abuses', component: page('instance/abuses') },
 		{ path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) },
 		{ path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) },
+		{ path: '/api-console', component: page('api-console') },
 		{ path: '/auth/:token', component: page('auth') },
 		{ path: '/miauth/:session', component: page('miauth') },
 		{ path: '/authorize-follow', component: page('follow') },