A /refund command that support can run from Slack, built by a coding agent as a TypeScript endpoint with a verified signature, a secret and a Postgres table, and deployed to your own AWS account.
Some of the most used internal tools are a single Slack command. Support types /refund ord_123, the request is logged with who asked for it, and finance sees a list at the end of the day without anyone opening an admin panel. A coding agent can write the handler in a few minutes, and the time goes into what surrounds it: the signature check Slack requires, the signing secret, a database for the log, and a public HTTPS endpoint on AWS that only Slack can usefully call.
This guide builds that /refund command with a coding agent: a TypeScript endpoint that verifies Slack's signature, logs each request to Postgres and answers within three seconds, deployed to your own AWS account. The walkthrough uses Encore for the infrastructure, tested locally with encore run (Encore v1.58.8), and then lists what the same tool takes with Lambda and API Gateway by hand. For tools with a browser UI, How to deploy an AI-built internal tool to AWS with SSO and a private database covers the access side.
Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions what the code declares in your own AWS or GCP account. The agent writes the handler, the secret and the database in the same files, encore run starts Postgres locally, and a deploy creates the RDS database, the Secrets Manager entry and the Fargate service in your account, where Encore provisions an IAM role for the service.

A prompt that gets this right in one pass names the constraints Slack puts on the endpoint:
Add a
/refundSlack slash command to the tools service. Verify X-Slack-Signature against the raw body with the signing secret from an Encore secret, reject timestamps older than five minutes, accept only order refs likeord_abc123, log each request with the Slack user in Postgres, and answer in the channel.
The service gets a database with one table for the requests, in tools/migrations/1_create_tables.up.sql:
CREATE TABLE refund_requests (
id BIGSERIAL PRIMARY KEY,
order_ref TEXT NOT NULL,
requested_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
// tools/db.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";
export const db = new SQLDatabase("tools", { migrations: "./migrations" });
Slack posts the command as a form body and signs v0:<timestamp>:<body> with HMAC-SHA256, as Slack's request verification docs describe. A raw endpoint reads the body as bytes before anything parses it:
// tools/slack.ts
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { createHmac, timingSafeEqual } from "node:crypto";
import { db } from "./db";
// Set with `encore secret set --type dev,prod SlackSigningSecret`.
const slackSigningSecret = secret("SlackSigningSecret");
function verifySlack(body: string, timestamp: string, signature: string): boolean {
// Reject requests older than five minutes to stop replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"v0=" + createHmac("sha256", slackSigningSecret()).update(`v0:${timestamp}:${body}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// Slack posts the slash command here as application/x-www-form-urlencoded.
export const refundCommand = api.raw(
{ expose: true, method: "POST", path: "/slack/refund" },
async (req, resp) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks).toString("utf8");
const ok = verifySlack(
body,
String(req.headers["x-slack-request-timestamp"] ?? ""),
String(req.headers["x-slack-signature"] ?? ""),
);
if (!ok) {
resp.writeHead(401).end("invalid signature");
return;
}
const form = new URLSearchParams(body);
const orderRef = (form.get("text") ?? "").trim();
const user = form.get("user_name") ?? "unknown";
if (!/^ord_[a-z0-9]+$/.test(orderRef)) {
resp.writeHead(200, { "content-type": "application/json" });
resp.end(JSON.stringify({ response_type: "ephemeral", text: "Usage: /refund ord_123" }));
return;
}
const row = await db.queryRow<{ id: number }>`
INSERT INTO refund_requests (order_ref, requested_by) VALUES (${orderRef}, ${user})
RETURNING id`;
resp.writeHead(200, { "content-type": "application/json" });
resp.end(
JSON.stringify({
response_type: "in_channel",
text: `Refund request #${row!.id} for ${orderRef} logged by ${user}.`,
}),
);
},
);
timingSafeEqual compares the signatures in constant time, and the length check first keeps it from throwing on a malformed header. An invalid order ref gets an ephemeral reply only the caller sees, with a 200 so Slack shows it instead of an error.
The agent can test the whole path with curl by signing the request the way Slack does. With SlackSigningSecret: "slack_test" in .secrets.local.cue:
A request with text=bogus returns the ephemeral usage message. The local development dashboard shows each request's trace with the SQL insert.
Set the real signing secret from the Slack app's Basic Information page with encore secret set --type prod SlackSigningSecret, connect your AWS account and push. Encore provisions the RDS database, stores the secret in Secrets Manager and runs the service on Fargate in your own account, and applies the migrations on deploy. Point the slash command's Request URL at https://<your-api-domain>/slack/refund.

The command lives in the same repository as the rest of the backend, so the next request, such as /refund-status, is another endpoint the agent adds and tests against the same local Postgres. The secrets docs and database docs cover the two primitives used here.
Slack shows the user a timeout error when a command gets no response within three seconds, so keep the handler to a database write and a reply. For slower work, publish to a Pub/Sub topic, reply right away, and post the result to the response_url from the subscriber. The request is public by design, which is why the signature check runs before anything else.
Without Encore, the agent produces the handler and someone with AWS access sets up the rest:
secretsmanager:GetSecretValue on it for the function's role, and code that caches the value across invocations.sam local start-api with environment overrides, or a deployed dev stack.Each piece is a few lines of Terraform or CDK, and the agent can write them, but they are reviewed and applied by a different person than the one who wrote the handler, which slows down the second and third commands.
encore run and deployed to your own AWS account with their secrets and tables.Building internal tools with AI agents without giving them your AWS keys covers where cloud access sits when an agent builds the tool, and scheduled reports for internal tools covers the reverse direction, from the backend into Slack.
Ask for an HTTP endpoint that reads the raw form body, verifies the X-Slack-Signature header with the signing secret, rejects old timestamps and answers within three seconds. With Encore, the agent writes an api.raw endpoint, declares the signing secret with secret("SlackSigningSecret") and a database with new SQLDatabase, tests the endpoint locally with encore run, and on deploy Encore provisions the database and secret in your AWS account.
Slack signs the exact bytes it sends, prefixed with v0 and the request timestamp. If a framework parses the form body before you compute the HMAC, the reconstructed string can differ and every signature check fails, so the handler has to read the body itself.
Slack expects an HTTP response within three seconds. Work that takes longer should be acknowledged right away and finished in the background, posting the result to the response_url Slack includes in the request.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.