---
title: "Sending transactional email from a Node.js app with PulsePigeon"
canonical: https://pulsepigeon.com/blog/send-email-from-node
last_updated: 2026-10-20
---

# Sending transactional email from a Node.js app with PulsePigeon

Wire a PulsePigeon send into a Node.js HTTP handler or a BullMQ worker. Idempotency keys, error handling, and a typed error model.

PulsePigeon's published TypeScript SDK covers the curated REST surface, including message sends, events, logs, domains, webhooks, suppressions, contacts, and lists. Install version 1.0.0 from npm with `npm install @blitzglobaltech/pulsepigeon` for the project-key surface.

Initialize the client once at process start with your API key and reuse it across requests. The client is designed to be long-lived; it does not hold per-request state.

Pass an idempotency key on every send. Derive it from a stable persisted business-event identifier, such as an order ID, reset-request ID, notification ID, or another logical send ID reused across retries. A UUID minted per inbound request can change when a client or upstream retry re-enters the route, which can enqueue a duplicate.

Branch on the documented error classes. Validation, authentication, permission, not-found, and conflict errors require a caller or state correction. `PulsePigeonRateLimitError` exposes `retryAfterSeconds`, while `PulsePigeonServerError` identifies a retryable 5xx response. Your queue owns the attempt limit and schedule. Use the [HTTP API error reference](/docs/errors) and [SDK method reference](/docs/sdks) to check those interfaces.

Configure a status-webhook subscription once. Subscriptions are persistent and filter by event type, project, or recipient domain, not by a single send. Correlate each webhook payload's `message_id` with the `id` on the accepted send response so support can tell whether that email was delivered, bounced, or complained without polling the API.

## Queue worker send with the TypeScript SDK: sendNotification.ts

```
import {
  PulsePigeon,
  PulsePigeonRateLimitError,
  PulsePigeonServerError,
} from '@blitzglobaltech/pulsepigeon';

// notificationId is the persisted business id, set when the notification was created.
// Do not use the queue's own job id: it can change when the same notification is re-enqueued.
type Job = { notificationId: string; projectId: string; recipientEmail: string };

class RetryLater extends Error {
  constructor(readonly afterSeconds: number | null) {
    super('Retry later');
  }
}

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(name + ' is not set');
  return value;
}

const pulsepigeon = new PulsePigeon(requireEnv('PULSEPIGEON_API_KEY'));

export async function sendNotification(job: Job): Promise<string> {
  try {
    const accepted = await pulsepigeon.messages.send(
      {
        project_id: job.projectId,
        from_email: requireEnv('PULSEPIGEON_FROM_EMAIL'),
        to: [{ email: job.recipientEmail }],
        subject: 'Your export is ready',
        text: 'Your export is ready to download.',
        category: 'transactional',
      },
      { idempotencyKey: 'notification:' + job.notificationId },
    );
    return accepted.id;
  } catch (error) {
    // Throw retryable failures so the queue's attempt limit and backoff run;
    // a normal return marks the job complete and the notification is dropped.
    if (error instanceof PulsePigeonRateLimitError) {
      throw new RetryLater(error.retryAfterSeconds);
    }
    if (error instanceof PulsePigeonServerError) {
      throw new RetryLater(null);
    }
    throw error;
  }
}
```
