Frameworks and agents · Published 2026-10-31 · Updated 2026-10-31 · AgentiSend
Send email with Railway
A Node server you deploy on Railway, with the key in a service variable and one POST /send route.
To send email with Railway, deploy one Node process that exposes POST /send and reads the key from the service variables. Railway's variables guide says a service variable is present on the running deployment as an environment variable, and that you add it from the service's Variables tab with New Variable, or paste a file in the RAW Editor. The route below checks the address, calls emails.send with an idempotency key derived from the recipient, and returns the message id or the refusal's code and fix. The suite listens on an ephemeral port and posts to that route against a local AgentiSend server.
A deploy that retries the same request is the case POST /limits/kill-all is for: it stops every key on the account until a person resumes sending. One key is POST /limits/keys/{id}/kill.
Install
pnpm add agentisendRailway's start-command guide is the process the deployment runs. Point it at the file that calls listen:
import { server } from './server';
const port = Number(process.env.PORT ?? 3000);
server.listen(port);The start-command page uses $PORT in its shell example. Set the command in the service settings when the detected command is not this process.
Environment
Set the variables on the Railway service. A variable that exists only in a .env on your laptop is absent on the deployment until you add it on the Variables tab. Adding or removing one stages a change you deploy.
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The route
/**
* Railway — one POST route on `node:http`.
*
* Railway runs the start command and injects the service variables into
* `process.env`. The server is created and not started, so a test or the
* process entry can call `listen` on `PORT`. `AGENTISEND_API_KEY` is the key.
* `MAIL_FROM` is an address on a domain you have verified.
* `AGENTISEND_BASE_URL` is optional and defaults to https://api.agentisend.com.
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { AgentiSend, AgentiSendError } from 'agentisend';
const agentisend = new AgentiSend();
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
/** Enough to refuse an obvious typo here; the API checks the address properly. */
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
export async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method !== 'POST' || req.url !== '/send') {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
return;
}
let email = '';
let note = '';
try {
const parsed = JSON.parse(await readBody(req)) as { email?: string; note?: string };
email = String(parsed.email ?? '').trim();
note = String(parsed.note ?? '');
} catch {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'email must be one address, like you@example.com' }));
return;
}
if (!ADDRESS.test(email)) {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'email must be one address, like you@example.com' }));
return;
}
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: 'Your service is live',
text: `Deploy note: ${note}`,
},
// Derived from the recipient, not from the moment: a retry replays.
{ idempotencyKey: `railway/${email}` },
);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ id }));
} catch (err) {
if (err instanceof AgentiSendError) {
res.writeHead(err.status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: err.code, fix: err.fix }));
return;
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'send failed' }));
}
}
/** Exported without `listen`, so it can be mounted or started elsewhere. */
export const server: Server = createServer((req, res) => {
void handle(req, res);
});
export default server;Try it
curl -X POST https://<your-service>/send \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","note":"revision 3"}'The response is {"id":"…"}. GET /emails/{id} reads that deploy notice.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The route writes code and fix with the API's status. A body that is not JSON is answered by the route. The error catalogue lists every code.