# Slack: the /horoscope command

Someone in a channel types `/horoscope leo`, and today's Leo horoscope appears in the channel. Slack posts the command to your server and waits no longer than 3 seconds for the reply, so the server needs a public HTTPS address. No Bolt library is needed: the whole exchange is one request and one response.

## 1. The Slack app

1. On [api.slack.com/apps](https://api.slack.com/apps) click **Create New App → From scratch** and pick the workspace.
2. **Slash Commands → Create New Command**: command `/horoscope`, **Request URL** `https://bot.example.com/slack/horoscope`, **Usage Hint** `leo`.
3. **Basic Information → App Credentials**: copy the **Signing Secret**.
4. **Install App → Install to Workspace**.
5. A **Server only** AstroWay key scoped to `public/horoscope/*`, as described in [A key for an automation](/en/integrations/#a-key-for-an-automation).

```bash frame="terminal"
export ASTROWAY_API_KEY="aw_live_..."
export SLACK_SIGNING_SECRET="..."
```

## 2. The code

Save it as `slack.mjs` and run `node slack.mjs`:

```js title="slack.mjs"
// Node 22 or newer, no dependencies.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const ASTROWAY = 'https://api.astroway.info/v1';
const LANG = 'en';
// One text per sign per day: at most 12 calls a day, whatever the traffic.
const cache = new Map();
const SIGNS = ['aries', 'taurus', 'gemini', 'cancer', 'leo', 'virgo',
  'libra', 'scorpio', 'sagittarius', 'capricorn', 'aquarius', 'pisces'];

function signedBySlack(raw, timestamp, signature) {
  // Older than five minutes reads as a replay.
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = 'v0=' + createHmac('sha256', process.env.SLACK_SIGNING_SECRET)
    .update(`v0:${timestamp}:${raw}`).digest('hex');
  return signature?.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

async function horoscope(sign) {
  const key = `${new Date().toISOString().slice(0, 10)} ${sign}`;
  if (!cache.has(key)) {
    const res = await fetch(`${ASTROWAY}/public/horoscope/daily?sign=${sign}&lang=${LANG}`, {
      headers: { 'X-Api-Key': process.env.ASTROWAY_API_KEY },
    });
    const json = await res.json();
    if (!json.ok) return { response_type: 'ephemeral', text: `AstroWay: ${json.error.message}` };
    cache.set(key, `*${sign}, ${json.data.date}*\n${json.data.horoscope}`);
  }
  return { response_type: 'in_channel', text: cache.get(key) };
}

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks).toString();
  if (!signedBySlack(raw, req.headers['x-slack-request-timestamp'], req.headers['x-slack-signature'])) {
    res.writeHead(401).end();
    return;
  }

  const sign = new URLSearchParams(raw).get('text')?.trim().toLowerCase() ?? '';
  const reply = SIGNS.includes(sign)
    ? await horoscope(sign)
    : { response_type: 'ephemeral', text: `Usage: /horoscope leo\n${SIGNS.join(', ')}` };
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(reply));
}).listen(process.env.PORT ?? 3000);
```

## How it works

- **The signature.** Slack signs every request: HMAC-SHA256 of `v0:<timestamp>:<body>` with your Signing Secret. The code checks it and drops a request older than five minutes, so a foreign or replayed request gets `401`.
- **`in_channel` or `ephemeral`.** Everyone in the channel sees the horoscope; only the person who typed the command sees a hint or an error.
- **3 seconds.** The first request for a sign each day goes to the API, the rest come from the cache in a few milliseconds. If you swap the horoscope for something slower, a PDF report for example, answer at once and send the result later to the `response_url` from the request body.
- **The limit.** A Free key on `/v1/public/*` counts as a call with no key, 30 an hour from the server's IP; the cache keeps the bot within 12 calls a day. A wrong key gives no `401` here, it quietly gets the same anonymous limit.

**Note:**
Checked on 2026-11-07: the server from the example ran locally and was called with the same fields and signature Slack sends; the horoscope came from AstroWay production, a repeat for the same sign answered from cache in 2 ms, and a forged and a stale signature both got `401`. We did not install the command in a real Slack workspace.
