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.
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.
- Your app calls
POST /v1/messageswith anIdempotency-Key. - The API writes it to Postgres and answers
202 Accepted. It’squeued. - When it’s due, the sender sends it from your own mailbox, paced to your limits.
- It reaches Alex, and the worker tells your app:
message.sent.
- The network drops, so your app sends the same request with the same key.
- The API finds the first answer and replays it:
Idempotent-Replayed: true. - Nothing new is queued. Alex gets one email.
- Alex answers. The inbox reads the reply from your mailbox.
- Norbelys sorts it, stops the campaign for Alex and keeps it in Postgres.
- The worker posts
inbound_message.receivedto your app, signed.
- You ask Claude to email Alex. It calls the
messages.createtool on the MCP server. - The API checks what the assistant was allowed on the consent screen, then queues it:
202. - From there it’s the same trip: your own mailbox, then Alex.
- Your app answers
503, or nothing within 15 seconds. - The worker tries again after 5 s, 5 min, 30 min, 2 h and on: ten attempts over about three days.
- Your app answers
200and the event is delivered.
Your first email, in three steps.
Start in a test workspace: everything works, and nothing reaches a real inbox.
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.
Create a keyWhat a test workspace does with each address Send to And 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 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/ | shinstall.sh
Keep the key in
NORBELYS_API_KEY. The SDKs and the CLI read it from there.- TypeScript
Send
One request, and the email is queued. The SDKs add the
Idempotency-Keyfor 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" }, });from norbelys import Norbelys with Norbelys() as norbelys: # reads NORBELYS_API_KEY message = 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"}, })curl https://api.norbelys.com/v1/messages \ -H "Authorization: Bearer $NORBELYS_API_KEY" \ -H "Idempotency-Key: first-email-alex" \ -H "Content-Type: application/json" \ -d '{ "from": "sam@acme.example", "to": ["alex@northwind.example"], "subject": "Quick idea for Northwind", "html": "<p>Hi Alex, …</p>" }'norbelys messages create -d '{ "from": "sam@acme.example", "to": ["alex@northwind.example"], "subject": "Quick idea for Northwind", "html": "<p>Hi Alex, …</p>" }'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.
POST /v1/messages HTTP/1.1Authorization: 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.
Idempotency-Key: first-email-alexRetries. Send the same key again within 24 hours and you get the first answer back. The SDKs make one up for every write.
{"from": "sam@acme.example", "to": ["alex@northwind.example"], …}
HTTP/1.1 202 AcceptedIdempotent-Replayed: trueNo second email. The answer to the first request, replayed. Nothing new was queued.
Location: /v1/messages/msg_01J9…RateLimit: "default";r=5998;t=1Limits. 6,000 requests a minute per workspace, reported as you go, with Retry-After when you reach it.
PATCH /v1/campaigns/cmp_01J9… HTTP/1.1If-Match: "1790000000000000"Updates. Send the version you read. If someone changed it since, nothing is overwritten.
HTTP/1.1 412 Precondition FailedContent-Type: application/problem+jsonErrors. Problem details (RFC 9457) with a stable code to switch on, and a request id to quote to support.
{"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.
- Attempt 1Now+0 sYour server answers
503 - Attempt 25 slater+5 sYour server answers
503 - Attempt 35 minlater+5 minYour server answers
503 - Attempt 430 minlater+35 minYour server answers
503 - Attempt 52 hlater+2 h 35 minYour server answers
503 - Attempt 65 hlater+7 h 35 minYour server answers
503 - Attempt 710 hlater+17 h 35 minYour server answers
200Delivered - Attempt 814 hlater+1 d 7 hNot sent
- Attempt 920 hlater+2 d 3 hNot sent
- Attempt 1024 hlater+3 d 3 hNot sent
2xx the tenth would come about three days after the first.webhook-id,webhook-timestampandwebhook-signatureon every delivery. The id stays the same on every attempt, so you can drop a repeat.- Answer
410and 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.
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.queuedmessage.sentmessage.failedmessage.uncertainmessage.cancelledmessage.snippets_fallback
Delivery and replies
delivery_event.recordedinbound_message.received
Campaigns
enrollment.stoppedenrollment.completedcampaign.status_changed
Mailboxes
connection.health_changed
Lists
import.completedexport.completedsuppression.created
AI
ai.budget_warningai.budget_exceeded
Endpoints
endpoint.testwebhook_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// .cursor/mcp.json
{
"mcpServers": {
"norbelys": { "url": "https://mcp.norbelys.com/mcp" }
}
}// .vscode/mcp.json
{
"servers": {
"norbelys": { "type": "http", "url": "https://mcp.norbelys.com/mcp" }
}
}# Claude, ChatGPT or any client that takes a remote MCP server:
# add a custom connector with this address.
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.
- You Who replied to the Q4 founders campaign today?
inbound_messages.list- Claude Two people. Alex Morgan at Northwind asked whether Tuesday at 10 works, and Chen Li is out of the office until the 14th.
- You Tell Alex that Tuesday at 10 works.
messages.create- Claude Done. I replied in the same thread, from sam@acme.example.
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- The contract
api.norbelys.com/openapi.json- For AI readers
norbelys.com/llms.txt
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.
# 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.sentlisten 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
bun run selfhost:setupWrites private settings, pulls the pinned images and starts Postgres.
bun run selfhost:migratePrepares the database from a checkout of the same release.
bun run selfhost:startChecks the schema and starts every service. The dashboard opens on localhost:5173.
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.