Cold email,from your code.

An API, typed SDKs, signed webhooks, an MCP server and a CLI, built so a retry never sends the same email twice.

Get an API key
npm i @norbelys/sdk

Open the box.

Norbelys is a few small processes around one Postgres database. Pick what happens to the email and follow it through.

  1. Your app calls POST /v1/messages with an Idempotency-Key.
  2. The API writes it to Postgres and answers 202 Accepted. It’s queued.
  3. When it’s due, the sender sends it from your own mailbox, paced to your limits.
  4. It reaches Alex, and the worker tells your app: message.sent.

Your first email, in three steps.

Start in a test workspace: everything works, and nothing reaches a real inbox.

  1. Make a test key

    Create one in the dashboard. A test key belongs to a test workspace, where the first word of an address decides what happens to the email.

    What a test workspace does with each address
    Send toAnd it’s
    bounce@Refused at RCPT TO 550 5.1.1
    blocked@Refused for good 550 5.7.1
    defer@Refused for now, tried again 451 4.3.0
    throttle@Asked to slow down 421 4.7.0
    uncertain@Lost reply, never resent uncertain
    anything elseAccepted 250
    Create a key
  2. Install

    Typed SDKs for TypeScript and Python, made from the same contract as the API. Or the CLI.

    • TypeScript
      npm i @norbelys/sdk
    • Python
      pip install norbelys
    • CLI
      curl --proto '=https' --tlsv1.2 -LsSf https://cli.norbelys.com/install.sh | sh

    Keep the key in NORBELYS_API_KEY. The SDKs and the CLI read it from there.

  3. Send

    One request, and the email is queued. The SDKs add the Idempotency-Key for you; with cURL, name your own.

    import { Norbelys } from "@norbelys/sdk";
    
    const norbelys = new Norbelys(); // reads NORBELYS_API_KEY
    
    const message = await norbelys.messages.create({
      from: "sam@acme.example",
      to: ["alex@northwind.example"],
      subject: "Quick idea for {{ variables.company }}",
      html: "<p>Hi {{ variables.name }}, …</p>",
      variables: { name: "Alex", company: "Northwind" },
    });
    202 Accepted
    {
      "id": "msg_01J9…",
      "kind": "direct",
      "state": "queued",
      "thread_id": "thr_01J9…",
      "subject": "Quick idea for Northwind",
      …
    }

Retry anything. It sends once.

An email can’t be unsent, so the API is careful in the boring places. Here they are, on a real request and its answers.

Your request, sent twice
  1. POST /v1/messages HTTP/1.1
  2. Authorization: Bearer nb_test_…

    Keys. A test key belongs to a test workspace, where nothing reaches an inbox. Keys are shown once and stored only as a hash.

  3. Idempotency-Key: first-email-alex

    Retries. Send the same key again within 24 hours and you get the first answer back. The SDKs make one up for every write.

  4. {"from": "sam@acme.example", "to": ["alex@northwind.example"], …}
What the second one gets back
  1. HTTP/1.1 202 Accepted
  2. Idempotent-Replayed: true

    No second email. The answer to the first request, replayed. Nothing new was queued.

  3. Location: /v1/messages/msg_01J9…
  4. RateLimit: "default";r=5998;t=1

    Limits. 6,000 requests a minute per workspace, reported as you go, with Retry-After when you reach it.

An edit made from an old copy
  1. PATCH /v1/campaigns/cmp_01J9… HTTP/1.1
  2. If-Match: "1790000000000000"

    Updates. Send the version you read. If someone changed it since, nothing is overwritten.

And what it gets back
  1. HTTP/1.1 412 Precondition Failed
  2. Content-Type: application/problem+json

    Errors. Problem details (RFC 9457) with a stable code to switch on, and a request id to quote to support.

  3. {"code": "precondition_failed", "request_id": "0199b8a2-…", …}

Lists come in pages of up to 100 with a cursor, and the SDKs walk every page for you. Every resource is in the API reference, made from the same contract.

Replies come back signed.

19 events, signed the Standard Webhooks way. If your server is down, Norbelys keeps trying for about three days.

  1. Attempt 1Now+0 sYour server answers 503
  2. Attempt 25 slater+5 sYour server answers 503
  3. Attempt 35 minlater+5 minYour server answers 503
  4. Attempt 430 minlater+35 minYour server answers 503
  5. Attempt 52 hlater+2 h 35 minYour server answers 503
  6. Attempt 65 hlater+7 h 35 minYour server answers 503
  7. Attempt 710 hlater+17 h 35 minYour server answers 200 Delivered
  8. Attempt 814 hlater+1 d 7 hNot sent
  9. Attempt 920 hlater+2 d 3 hNot sent
  10. Attempt 1024 hlater+3 d 3 hNot sent
An example: your server is down until the seventh attempt, 17 hours and 35 minutes in. Each attempt waits 15 seconds for an answer, and without a 2xx the tenth would come about three days after the first.
  • webhook-id, webhook-timestamp and webhook-signature on every delivery. The id stays the same on every attempt, so you can drop a repeat.
  • Answer 410 and the endpoint switches itself off.
  • Rotate a secret and the old one keeps signing for 24 hours.
  • Replay an endpoint’s last days, or retry one delivery, from the API.

TypeScript

import { verifyWebhook } from "@norbelys/sdk";

export async function POST(request: Request) {
  const event = await verifyWebhook(
    await request.text(),
    request.headers,
    process.env.NORBELYS_WEBHOOK_SECRET!, // whsec_…
  );

  if (event.type === "inbound_message.received") {
    // Fetch the reply by its id and hand it to your CRM.
  }
  return new Response(null, { status: 204 });
}

All 19 events

Messages

  • message.queued
  • message.sent
  • message.failed
  • message.uncertain
  • message.cancelled
  • message.snippets_fallback

Delivery and replies

  • delivery_event.recorded
  • inbound_message.received

Campaigns

  • enrollment.stopped
  • enrollment.completed
  • campaign.status_changed

Mailboxes

  • connection.health_changed

Lists

  • import.completed
  • export.completed
  • suppression.created

AI

  • ai.budget_warning
  • ai.budget_exceeded

Endpoints

  • endpoint.test
  • webhook_endpoint.disabled

Bring your AI.

The MCP server gives Claude, ChatGPT, Cursor or any MCP client the API’s operations as tools, and only the ones its access allows.

claude mcp add --transport http \
  norbelys https://mcp.norbelys.com/mcp
  • Sign in with OAuth and pick the workspace. The consent screen lists what the assistant may do.
  • 98 tools, named like the API: messages.create, inbound_messages.list, campaigns.start.
  • Access lasts 10 minutes at a time. Revoke it whenever you like under Connected apps.
  1. You Who replied to the Q4 founders campaign today?
  2. inbound_messages.list
  3. Claude Two people. Alex Morgan at Northwind asked whether Tuesday at 10 works, and Chen Li is out of the office until the 14th.
  4. You Tell Alex that Tuesday at 10 works.
  5. messages.create
  6. Claude Done. I replied in the same thread, from sam@acme.example.
An example conversation.

Building it with a coding agent?

Hand it the contract and the house rules. The whole API is one OpenAPI file, and this prompt covers keys, test mode, retries, errors and webhooks.

Read the prompt
Add Norbelys (cold email from my own inboxes) to this project.

- Install the SDK: npm i @norbelys/sdk (TypeScript) or pip install norbelys (Python).
- Read the API key from NORBELYS_API_KEY; never put it in code. Use a test-mode key while building: nothing reaches an inbox, and a recipient address starting with bounce, blocked, defer, throttle or uncertain simulates that outcome.
- Send with norbelys.messages.create({ from, to, subject, html, variables }). It answers 202 with the message in state "queued".
- Every write takes an Idempotency-Key. The SDK generates one; pass your own idempotencyKey for an action you may repeat after a crash, and reuse it with the same body.
- Errors are RFC 9457 problem+json: switch on "code", log "request_id".
- Verify webhooks with verifyWebhook(body, headers, secret) from @norbelys/sdk before trusting them. Answer 2xx quickly; Norbelys retries ten times over about three days.
- The whole API, 99 operations: https://api.norbelys.com/openapi.json

Every operation, a command.

One binary for macOS, Linux and Windows. Approve it in your browser once, then all 99 operations are commands, with --json when a script is reading.

Terminal

# Approve this device in your browser, once
norbelys login

norbelys campaigns list --limit 20
norbelys people create --email alex@northwind.example

# Your workspace's events, on your laptop
norbelys listen --forward-to localhost:3000/hooks
norbelys trigger message.sent

listen forwards your workspace’s events to your laptop, signed like the real ones, so your handler is tested before it’s deployed.

Or run the box yourself.

Norbelys is Apache-2.0. The processes on the map run on your own machine, with Docker.

Your machine

  • postgres:5432
  • api:3001
  • worker
  • sender
  • inbox
  • tracking:3002
  • app:5173
Five of them are one server binary in different roles, and the dashboard talks only to the API. Every port they publish is bound to 127.0.0.1.
  1. bun run selfhost:setup

    Writes private settings, pulls the pinned images and starts Postgres.

  2. bun run selfhost:migrate

    Prepares the database from a checkout of the same release.

  3. bun run selfhost:start

    Checks the schema and starts every service. The dashboard opens on localhost:5173.

Read the source

Your first email isone request away.

The API, webhooks, the MCP server and the CLI are covered by section 7 of our Terms, and personal data by our DPA. The code itself is yours under Apache-2.0.