PostgreSQL can be the source of an email event without becoming your mail server. This guide shows how to send email from PostgreSQL with Volanea by using a database trigger, PostgreSQL NOTIFY, and a small server-side worker that calls the Volanea REST API.

There is one important constraint up front: stock PostgreSQL does not include a native outbound HTTP client, a built-in email action, or a native Volanea marketplace integration. PostgreSQL does provide triggers plus the LISTEN/NOTIFY notification system; those are the reliable database-side primitives used here. A worker receives the notification, loads the relevant record, and sends the email through Volanea over HTTPS.

What this integration does

The concrete trigger in this example is a row-level AFTER INSERT trigger on a public.users table. When a new user record is committed, PostgreSQL writes an email job to an outbox table and emits a NOTIFY event on the volanea_email_outbox channel.

The worker listens on that channel, claims the pending outbox job, maps the user fields into a Volanea send request, and calls POST https://api.volanea.com/v1/send. The email is therefore tied to a committed database change rather than an application request that may later roll back.

The resulting flow looks like this:

  1. Your application inserts a new row into public.users.
  2. An AFTER INSERT trigger creates one row in email_outbox in the same transaction.
  3. The trigger publishes a lightweight PostgreSQL NOTIFY payload after commit.
  4. A private worker receives the notification and claims the outbox record.
  5. The worker maps database fields into a Volanea REST payload.
  6. Volanea accepts the transactional message and processes it for delivery.
  7. The worker records the response or schedules a retry.

This design deliberately separates three responsibilities:

  • PostgreSQL decides that an event happened and persists the intent to send.
  • The worker owns credentials, HTTP calls, retry policy, and observability.
  • Volanea accepts and dispatches the email through the transactional sending API.

That separation is safer than trying to send mail from a trigger synchronously. A trigger should not wait for an external email provider, and an HTTP timeout should not decide whether a user row can be inserted.

Why PostgreSQL needs middleware for email

PostgreSQL supports CREATE TRIGGER for table events including INSERT, UPDATE, DELETE, and TRUNCATE. It also supports NOTIFY channel, payload, which sends an event to database clients that executed LISTEN channel.

What PostgreSQL does not provide in its standard distribution is a native webhook builder, a native HTTP request function, or a standard email-sending feature. That means there is no PostgreSQL dashboard setting where you install a Volanea app, choose a sender, and paste an API key.

Some managed Postgres platforms and self-hosted installations add extensions such as pg_net or http. Those are extensions, not a portable core PostgreSQL feature. pg_net, for example, can issue asynchronous HTTP requests from SQL, but it is not available everywhere and its queued requests require particularly careful credential handling.

For a production email path, the middleware route is usually the better default:

  • It works with ordinary PostgreSQL installations.
  • The Volanea API key stays outside application-visible database configuration.
  • You can implement durable retries and alerting.
  • You can inspect provider responses without holding open a database transaction.
  • You can add policies for consent, tenant isolation, templates, and rate limits in one place.

If you already run a Node.js, Python, Go, Ruby, Java, or serverless backend, that process can be the worker. A dedicated queue consumer is also a good option at higher volume.

The PostgreSQL event that starts the send

In PostgreSQL terms, the event is an AFTER INSERT FOR EACH ROW trigger. “Record created” is common application language, but PostgreSQL calls the underlying action an INSERT on a table.

This example sends a welcome email after a user is created. Assume the application owns this basic table:

create table public.users (
  id uuid primary key,
  email text not null,
  first_name text,
  created_at timestamptz not null default now()
);

Do not put the entire email HTML document inside NOTIFY. PostgreSQL notifications are signals between connected database clients, not a durable message queue. Instead, place the durable email intent in an outbox row and send only its ID in the notification.

Create the outbox table first:

create table public.email_outbox (
  id uuid primary key,
  event_type text not null,
  recipient_email text not null,
  recipient_name text,
  user_id uuid not null references public.users(id),
  payload jsonb not null,
  status text not null default 'pending'
    check (status in ('pending', 'processing', 'sent', 'failed')),
  attempts integer not null default 0,
  available_at timestamptz not null default now(),
  locked_at timestamptz,
  sent_at timestamptz,
  provider_message_id text,
  last_error text,
  created_at timestamptz not null default now()
);

create index email_outbox_pending_idx
  on public.email_outbox (status, available_at, created_at)
  where status = 'pending';

Next, create the trigger function. It creates a stable event ID, stores the event data, and publishes a small JSON payload containing the event ID and type.

create extension if not exists pgcrypto;

create or replace function public.queue_user_welcome_email()
returns trigger
language plpgsql
as $$
declare
  outbox_id uuid := gen_random_uuid();
  notify_payload text;
begin
  insert into public.email_outbox (
    id,
    event_type,
    recipient_email,
    recipient_name,
    user_id,
    payload
  )
  values (
    outbox_id,
    'user.created',
    new.email,
    new.first_name,
    new.id,
    jsonb_build_object(
      'userId', new.id,
      'email', new.email,
      'firstName', new.first_name,
      'createdAt', new.created_at
    )
  );

  notify_payload := json_build_object(
    'eventId', outbox_id,
    'eventType', 'user.created'
  )::text;

  perform pg_notify('volanea_email_outbox', notify_payload);

  return new;
end;
$$;

create trigger users_queue_welcome_email
  after insert on public.users
  for each row
  execute function public.queue_user_welcome_email();

The actual PostgreSQL notification payload produced by this trigger has this shape:

{
  "eventId": "ddf4d022-654e-4d5e-89cf-bec1bfa290e5",
  "eventType": "user.created"
}

That is intentionally not the entire email request. The worker treats eventId as a pointer to the durable outbox row, where it can retrieve the recipient and event data safely.

Why use an outbox table as well as NOTIFY

It is tempting to use NOTIFY alone: insert a user, emit JSON, and let a listener call an email API. That can work for noncritical signals, but it is not sufficient for important email flows such as verification, receipts, account access, or security notices.

A listener can be disconnected during a notification. A worker deployment can restart. An API request can time out after Volanea accepts the request but before your worker receives the response. With only an in-memory notification, recovery is hard.

The outbox table gives you an auditable source of truth. Every intended send has a row with a status, timestamp, retry count, stored provider result, and error message. NOTIFY becomes an optimization that wakes the worker quickly; a periodic outbox scan catches anything that was missed.

The outbox also gives you a clean answer to a common operational question: “Did we decide to send this email, did we attempt it, and what happened afterward?” Your database can answer all three.

For each logical email, preserve one immutable event ID. That ID becomes the Volanea Idempotency-Key, so repeated attempts refer to the same send rather than creating a duplicate message.

Build the private PostgreSQL listener worker

The worker below uses Node.js and the pg package. It does two jobs:

  1. LISTENs for PostgreSQL notification events so pending work starts promptly.
  2. Polls the outbox on a short interval, ensuring notification loss does not lose an email.

The database connection string and Volanea API key are server-side environment variables. They must never be compiled into browser JavaScript, shipped in a mobile application, stored in a public repository, or returned from an API endpoint.

import pg from 'pg';

const { Client } = pg;

const databaseUrl = process.env.DATABASE_URL;
const volaneaApiKey = process.env.VOLANEA_API_KEY;
const emailFrom = process.env.EMAIL_FROM;

if (!databaseUrl || !volaneaApiKey || !emailFrom) {
  throw new Error('DATABASE_URL, VOLANEA_API_KEY, and EMAIL_FROM are required');
}

const listener = new Client({ connectionString: databaseUrl });
const worker = new Client({ connectionString: databaseUrl });

async function claimNextEvent(requestedEventId) {
  const result = await worker.query(
    `with candidate as (
       select id
       from public.email_outbox
       where status = 'pending'
         and available_at <= now()
         and ($1::uuid is null or id = $1::uuid)
       order by created_at
       for update skip locked
       limit 1
     )
     update public.email_outbox o
     set status = 'processing',
         locked_at = now(),
         attempts = attempts + 1
     from candidate
     where o.id = candidate.id
     returning o.*`,
    [requestedEventId ?? null]
  );

  return result.rows[0] ?? null;
}

function escapeHtml(value = '') {
  return String(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#039;');
}

async function sendWithVolanea(event) {
  const firstName = event.payload.firstName || 'there';

  // PostgreSQL outbox fields -> Volanea REST request fields.
  const body = {
    from: emailFrom,
    to: [event.recipient_email],
    subject: 'Welcome to Acme',
    html: `<p>Hi ${escapeHtml(firstName)},</p><p>Thanks for creating your account.</p>`
  };

  const response = await fetch('https://api.volanea.com/v1/send', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${volaneaApiKey}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': event.id
    },
    body: JSON.stringify(body)
  });

  const responseText = await response.text();
  let responseBody = null;

  try {
    responseBody = responseText ? JSON.parse(responseText) : null;
  } catch {
    responseBody = { raw: responseText };
  }

  if (!response.ok) {
    throw new Error(`Volanea returned ${response.status}: ${responseText}`);
  }

  return responseBody;
}

async function markSent(eventId, providerResponse) {
  await worker.query(
    `update public.email_outbox
     set status = 'sent',
         sent_at = now(),
         provider_message_id = coalesce($2, provider_message_id),
         last_error = null
     where id = $1`,
    [eventId, providerResponse?.id ?? providerResponse?.messageId ?? null]
  );
}

async function markForRetry(eventId, attempts, error) {
  const delaySeconds = Math.min(3600, 30 * 2 ** Math.min(attempts, 7));

  await worker.query(
    `update public.email_outbox
     set status = case when attempts >= 8 then 'failed' else 'pending' end,
         available_at = now() + ($2 || ' seconds')::interval,
         locked_at = null,
         last_error = $3
     where id = $1`,
    [eventId, delaySeconds, error.message]
  );
}

async function processOne(requestedEventId = null) {
  const event = await claimNextEvent(requestedEventId);
  if (!event) return;

  try {
    const providerResponse = await sendWithVolanea(event);
    await markSent(event.id, providerResponse);
  } catch (error) {
    await markForRetry(event.id, event.attempts, error);
    console.error(`Email event ${event.id} failed`, error);
  }
}

await listener.connect();
await worker.connect();

await listener.query('listen volanea_email_outbox');

listener.on('notification', ({ payload }) => {
  try {
    const event = JSON.parse(payload);
    void processOne(event.eventId);
  } catch (error) {
    console.error('Invalid PostgreSQL notification payload', error);
  }
});

setInterval(() => void processOne(), 15_000);
console.log('Listening for PostgreSQL email events');

The field mapping is explicit:

PostgreSQL outbox fieldVolanea fieldPurpose
event.recipient_emailto[0]Recipient address
EMAIL_FROM environment variablefromVerified sending address
constant or event-derived valuesubjectEmail subject
event.payload.firstNamehtmlPersonalized email content
event.idIdempotency-Key headerStable deduplication key

For reusable templates, keep the outbox pattern and change only the Volanea request construction according to the template-send endpoint and request shape in the email API reference and setup guides. The database event, secret handling, outbox state machine, and retry logic do not need to change.

Authenticate without exposing the Volanea API key

The Volanea API key belongs in the worker’s secret store or server-side environment, not in PostgreSQL client-visible configuration.

For this integration, the key lives in the deployment environment that runs the worker:

VOLANEA_API_KEY=sk_...
EMAIL_FROM=Acme <hello@updates.example.com>
DATABASE_URL=postgresql://email_worker:...@db.example.com/app

Use your hosting platform’s encrypted secret manager, container runtime secrets, or a managed function’s environment secret feature. Restrict access so only the worker identity can read the Volanea key.

Do not put the key in any of these places:

  • A frontend .env file such as VITE_*, NEXT_PUBLIC_*, or PUBLIC_* variables.
  • Browser configuration returned by an API.
  • A mobile app bundle.
  • Trigger source code checked into a migration with a literal credential.
  • A plain-text application settings table readable by ordinary database users.
  • PostgreSQL NOTIFY payloads, logs, or error messages.

A database extension route requires extra caution. For example, an HTTP extension may place request headers in a database-side queue while work is pending. If an authorization header is visible to database roles that should not be able to send email, the API key has effectively become a shared credential. That is why the middleware route is the secure default.

The EMAIL_FROM value must use a sender identity you have verified in Volanea. Set up the sending domain before testing production messages, and keep staging and production senders separate. Before a large import or backfill, validate destination addresses with the free email verification tool rather than using a database trigger to send to unverified data.

Make retries safe with an idempotency key

The hard part of transactional email is not creating the HTTP request. It is handling the uncertain state after a timeout.

Suppose the worker sends the request, Volanea accepts it, and the network connection closes before the worker receives the response. If the worker retries with a newly generated request identifier, the recipient may receive two welcome emails. If it never retries, a transient network failure may suppress an important message.

The safe approach is to use the outbox event ID as the Idempotency-Key header on every attempt for that one logical email. Do not generate a new UUID in the retry loop. The header should identify the business event, not one HTTP attempt.

In this guide, email_outbox.id is created once inside the same PostgreSQL transaction as the user insert. Every retry of that welcome email uses the same value:

Idempotency-Key: ddf4d022-654e-4d5e-89cf-bec1bfa290e5

Use a fresh outbox row and a fresh idempotency key only for a genuinely new message. For example, a new password-reset request should be a new event; retrying the original password-reset send should reuse its original event ID.

Idempotency does not replace your own database state. Your worker should still record whether the event is pending, processing, sent, or failed. That state is what lets your team reconcile an incident, requeue a failed operation intentionally, and distinguish an accepted send from an actual delivery outcome.

When this breaks

Every integration hop has failure modes. Plan for them before an account-creation spike or a production deployment reveals them.

A PostgreSQL notification is missed

NOTIFY is a wake-up signal, not a durable work queue. A worker that is disconnected cannot process a notification it never received. The outbox table solves this only if the worker also polls for pending rows; that is why the example runs processOne() every 15 seconds.

For high-volume systems, query and claim a batch of pending jobs with FOR UPDATE SKIP LOCKED, run multiple workers, and monitor the age of the oldest pending outbox record.

The worker retries and sends a duplicate

Retries are expected after transient network errors, gateway failures, and worker restarts. Duplicates happen when each retry uses a new idempotency key or when the same logical event is inserted twice.

Use email_outbox.id as the stable Volanea Idempotency-Key. Also make the database-side intent unique where appropriate. A welcome email can often be constrained to one event per user; an order receipt can be constrained to one event per order and receipt type.

The Volanea request times out

A timeout does not prove Volanea rejected the request. The API may have accepted the message while the response was lost. Keep the event in a retryable state, reuse the same Idempotency-Key, and retry according to bounded exponential backoff.

Avoid retrying permanent failures forever. A malformed address, unauthorized sender, invalid request body, or missing credential needs a code or configuration fix, not another attempt in five minutes.

Some fields are missing in source data

PostgreSQL does not have a plan-based webhook payload model. The payload in this guide is created by your own trigger, so it contains only fields you explicitly build with jsonb_build_object.

That means a first_name value can be null, a legacy row can have an invalid email, or a later schema change can rename a column. Use fallbacks in the worker, validate required data before calling Volanea, and version important event payloads if several services consume them.

If you replace this approach with a managed provider’s database-webhook or automation feature, verify its plan limits, extension availability, payload fields, retention behavior, and retry policy. Those capabilities are provider-specific and should not be assumed to be part of PostgreSQL itself.

A transaction rolls back

An AFTER INSERT trigger runs as part of the same transaction as the insert. PostgreSQL delivers NOTIFY events only after the transaction commits. If the user creation rolls back, the outbox row and notification roll back as well, which is exactly what you want.

Do not create the email event in application code before the transaction commits unless you have another durable mechanism to cancel it. Otherwise, a rolled-back account creation can still produce a welcome email.

The worker crashes while processing

A crash can leave an outbox row in processing. Add a recovery task that returns stale locks to pending after a reasonable lease period, such as 10 minutes. Keep the same outbox ID during recovery so the repeated Volanea request remains idempotent.

Also use FOR UPDATE SKIP LOCKED when claiming rows. It prevents two healthy workers from claiming the same pending event at the same time.

Choosing direct HTTP extensions versus a worker

A direct SQL-to-HTTP approach can be reasonable for a tightly controlled environment, especially when your managed Postgres provider supports an asynchronous HTTP extension. It can reduce infrastructure for a low-volume internal notification.

However, it changes the security and operational tradeoffs. The database must hold or access the Volanea key, outbound network access must be permitted from the database, and extension-specific queues and response tables become part of your reliability model.

Choose a private worker when you need any of the following:

  • A portable solution across PostgreSQL providers.
  • Secret isolation from database clients and application users.
  • Richer template selection and personalization logic.
  • A durable retry policy with categorized failures.
  • Centralized logs, metrics, alerts, and delivery-event reconciliation.
  • A place to enforce consent, suppression, tenant, or fraud policies before sending.

Choose a direct extension only after confirming that the extension is available on your exact host, that its request-header security model is acceptable, and that you can safely monitor its asynchronous responses. It is an infrastructure choice, not a replacement for the outbox and idempotency patterns.

Production checklist for PostgreSQL-triggered email

Before enabling the trigger in production, work through this checklist:

  • Verify the sending domain and use a production sender address in EMAIL_FROM.
  • Create the outbox table, index, trigger function, and trigger in a migration.
  • Grant the worker only the database permissions it needs.
  • Store VOLANEA_API_KEY in a server-side secret manager.
  • Ensure no client-side code can read the Volanea key.
  • Use one stable idempotency key per logical email event.
  • Add bounded exponential backoff and a maximum retry count.
  • Add recovery for stale processing locks.
  • Poll pending outbox rows in addition to listening for notifications.
  • Escape or template user-supplied data before placing it in HTML.
  • Log event IDs, HTTP status, and provider response IDs, but redact authorization headers and personal data where possible.
  • Monitor pending outbox age, failed event count, and worker connection health.
  • Process delivery, bounce, complaint, and suppression events separately from send acceptance.

A successful API response means Volanea accepted the request for processing. It does not by itself mean the recipient’s inbox provider accepted or displayed the message. Treat provider delivery events and bounce feedback as the next stage of your email system, then update your contact or application records accordingly.

Conclusion

To send email from PostgreSQL with Volanea, use PostgreSQL for what it does best: reliably committing data changes and emitting database events. Use a server-side worker for what a database should not own: API credentials, outbound HTTPS, retry scheduling, template logic, and delivery observability.

The key design is simple but important: an AFTER INSERT trigger writes a durable outbox event, NOTIFY wakes the worker after commit, and the worker calls Volanea with the outbox ID as its idempotency key. That gives you a recoverable, secure path from a database event to transactional email without inventing a native PostgreSQL plugin that does not exist.

FAQ

Can PostgreSQL send an HTTP request by itself?

Not in standard PostgreSQL. Core PostgreSQL provides triggers and LISTEN/NOTIFY, while outbound HTTP requires a third-party extension or an external worker. The worker pattern in this guide works on ordinary PostgreSQL installations.

What PostgreSQL trigger should I use to send a welcome email?

Use an AFTER INSERT FOR EACH ROW trigger on the table that represents account creation, such as public.users. Have it insert an outbox row in the same transaction, then use pg_notify to wake the worker after commit.

Where should the Volanea API key be stored?

Store it only in the private worker’s environment secret store or secret manager. Do not place it in browser-visible configuration, mobile apps, notification payloads, SQL migrations, or ordinary database settings readable by application clients.

How do I stop duplicate emails after retries?

Create one durable outbox event ID per logical email and pass that same ID in the Idempotency-Key header on every retry. Do not generate a new key for each HTTP attempt.

Do I need both an outbox table and LISTEN/NOTIFY?

For reliable email, yes. The outbox is the durable record of work; NOTIFY is the low-latency wake-up signal. Polling the outbox provides recovery if the listener is disconnected or a notification is missed.