Skip to content
← BlogDeveloper

Building a Slack alert for new replies with the Norbelys API

A practical build: poll GET /v1/messages?direction=inbound for unread replies and post them to Slack, without duplicate alerts or a second inbox to babysit.

By Gabriel Lara, Developer Relations, Norbelys

Founder-reviewed ·How we research and correct articles

The unibox is a fine place to triage replies if you’re already in the dashboard. Most reps aren’t — they’re in Slack, and a reply that sits unnoticed for six hours because nobody happened to check the inbox is a reply that’s cooled off before anyone answers it. This is a small, self-contained build: a script that polls the Norbelys API for new inbound messages and posts each one to a Slack channel the moment it shows up. Nothing here needs a webhook subscription or a queue on your side — a polling loop and two API calls are the whole mechanism. Before wiring it up, mint an API key scoped to this one job instead of reusing a broader credential — authenticating server-to-server with Norbelys covers why that separation is worth the extra five minutes.

What “new reply” means on the wire

Inbound and outbound messages are the same resource with a direction — there’s no separate /inbox endpoint to learn, and the API reference documents both directions under the one messages resource. What you actually poll is:

GET /v1/messages?direction=inbound&isRead=false

Each message carries isRead and classification (the reply’s inferred sentiment — interested, not_interested, and so on) alongside the usual resource fields. isRead is the flag this build leans on: it’s already the API’s own notion of “has anyone looked at this,” so instead of inventing local state to track which replies you’ve already alerted on, the script uses the platform’s flag as the source of truth. Fetch the unread ones, alert on them, mark them read. Run again in five minutes and you only ever see what’s actually new.

The build

Build sequence

  1. Create a scoped credential

    Mint an org-scoped API key (ak_...) for this script specifically, not your main backend key — see the server-to-server auth guide for why a single-purpose credential matters here.

  2. Create a Slack incoming webhook

    In the target Slack channel, add an Incoming Webhooks app and copy the webhook URL. That URL is itself a secret — treat it the same way you treat the API key.

  3. Write the poll loop

    Fetch GET /v1/messages?direction=inbound&isRead=false, format each message into a Slack payload, POST it to the webhook URL.

  4. Mark each alerted message read

    PATCH /v1/messages/:id { isRead: true } right after a successful Slack post, so a message is never announced twice even if the script restarts.

  5. Schedule it

    Run the script on an interval — a cron job, a scheduled Worker, whatever your infra already uses for periodic tasks. Five minutes is a reasonable default; tighten it if replies are time-sensitive.

The script

This is illustrative — adjust error handling, retries, and the Slack message format to your own stack. The shape is what matters: fetch unread, post, mark read, in that order, so a Slack outage doesn’t silently swallow a reply.

// poll-replies.ts — run this on a schedule (cron, scheduled Worker, etc.)

const API_BASE = "https://api.norbelys.com/v1";
const API_KEY = process.env.NORBELYS_API_KEY;
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;

interface InboundMessage {
  id: string;
  from: { email: string; name?: string };
  subject?: string;
  snippet?: string;
  classification?: string;
  dateCreated: string;
}

async function fetchUnreadReplies(): Promise<InboundMessage[]> {
  const res = await fetch(
    `${API_BASE}/messages?direction=inbound&isRead=false&limit=25`,
    { headers: { Authorization: `Bearer ${API_KEY}` } }
  );
  if (!res.ok) {
    throw new Error(`messages.list failed: ${res.status} ${await res.text()}`);
  }
  const body = await res.json();
  return body.data as InboundMessage[];
}

async function postToSlack(message: InboundMessage): Promise<void> {
  const label = message.classification
    ? ` _(${message.classification})_`
    : "";
  const text =
    `*New reply*${label} from ${message.from.name ?? message.from.email}\n` +
    `${message.subject ? `*${message.subject}*\n` : ""}` +
    `${message.snippet ?? ""}`;

  const res = await fetch(SLACK_WEBHOOK_URL as string, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text }),
  });
  if (!res.ok) {
    throw new Error(`Slack webhook failed: ${res.status}`);
  }
}

async function markRead(id: string): Promise<void> {
  const res = await fetch(`${API_BASE}/messages/${id}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ isRead: true }),
  });
  if (!res.ok) {
    throw new Error(`messages.update failed: ${res.status}`);
  }
}

async function run(): Promise<void> {
  const replies = await fetchUnreadReplies();
  for (const message of replies) {
    await postToSlack(message);
    await markRead(message.id);
  }
}

run().catch((err) => {
  console.error(err);
  process.exitCode = 1;
});

The order inside the loop is the one detail worth being deliberate about: post to Slack first, mark read second. If the script crashes between the two, the message just stays unread and gets picked up — and re-alerted — on the next run. That’s a harmless duplicate. Flipping the order risks the opposite failure: a message silently marked read with no alert ever sent, which is much worse, because nothing in Slack tells you it happened.

test-the-poll
confirm the filter returns what you expect before wiring the schedule$ curl -s "https://api.norbelys.com/v1/messages?direction=inbound&isRead=false&limit=5" \$ -H "Authorization: Bearer $NORBELYS_API_KEY" | jq '.data[] | {id, from, classification}'{ "id": "msg_9f2k...", "from": {"email":"jane@acme.com"}, "classification": "interested" }

The first run has a backlog, later runs don’t

The very first time this script runs against a workspace that’s been collecting replies for a while, isRead=false can return far more than the limit=25 in the example fetches in one page. That’s expected — the query is cursor-paginated like every other list endpoint in the API, so a real first run should loop on the returned cursor until a page comes back with fewer than limit results, rather than assuming one page is the whole backlog:

async function fetchAllUnreadReplies(): Promise<InboundMessage[]> {
  const all: InboundMessage[] = [];
  let cursor: string | undefined;
  for (;;) {
    const url = new URL(`${API_BASE}/messages`);
    url.searchParams.set("direction", "inbound");
    url.searchParams.set("isRead", "false");
    url.searchParams.set("limit", "25");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    if (!res.ok) throw new Error(`messages.list failed: ${res.status}`);
    const body = await res.json();
    all.push(...(body.data as InboundMessage[]));
    if (!body.cursor) break;
    cursor = body.cursor as string;
  }
  return all;
}

After the first run clears the backlog, every subsequent run only sees whatever arrived since the last poll — usually zero or one page — so the loop above is mostly dead weight in steady state. It’s worth keeping anyway: a script that only ever assumed one page will quietly under-alert the one time someone reconnects a mailbox after a long pause and a hundred replies land at once.

Cutting the noise: alert on classification, not every reply

Alerting on every inbound message works, but a sequence that gets a lot of out-of-office replies and hard “not interested” one-liners will flood the channel fast. A simple refinement: only post to Slack when classification === "interested" (or is missing, since classification is computed shortly after the reply lands and won’t always be present on the very first poll), and still mark every inbound message read so the low-signal ones don’t pile up as permanently unread noise in the API either. Which threshold makes sense depends entirely on your reply volume — start by alerting on everything for a day, see how loud the channel gets, then narrow it.

Watch your call volume

A poll every five minutes against a filtered messages list is light traffic, but if you tighten the interval or run several of these scripts across different segments, keep rate limits and backoff in mind — the fix for a 429 here is almost always a longer poll interval, not a retry loop.

Running this from an agent instead of a cron job

If you’re already driving Norbelys from an AI agent rather than a standalone script, the same three calls exist as MCP tools — messages_list, messages_update — so the logic above maps directly onto an agent’s tool calls instead of raw fetch. The developer portal has the connection details; the polling shape and the mark-read ordering don’t change either way. Marking a reply read and posting it to Slack is a light-touch write with an obvious undo path, which is exactly the kind of CRM-adjacent action an agent should be trusted with more freely than, say, editing a contact’s segment membership.

Slack reply alert FAQ

Will this alert on my own outbound follow-ups by mistake?

No — the filter is direction=inbound, which only returns replies received, never messages your senders sent out. Outbound activity uses the same messages resource but with direction=outbound.

What happens if two poll runs overlap?

isRead is the guard: a message marked read by the first run won't appear in the second run's isRead=false query, even if the runs overlap in time. The main risk is a duplicate Slack post for a message caught by both runs before either marks it read, which is a rare timing window and a harmless duplicate.

Should I use a webhook API key or my main backend key for this script?

A dedicated key. If this script's credential ever leaks, you want its blast radius limited to "reads inbound messages and flips isRead," not your entire backend's authority — see the server-to-server auth guide.

Can I alert on classification changes after the fact instead of at reply time?

Classification is computed shortly after a reply lands, so a poll running a few minutes later will usually already see it. If you need to react to a classification change specifically, poll on classification alongside isRead rather than relying on the very first pass.