PostgREST is excellent at exposing PostgreSQL tables, views, and functions as REST endpoints—but it is not an automation platform with outbound webhooks. To send email from PostgREST with Volanea, use PostgREST to create the business record, let PostgreSQL enqueue an email job in an outbox table, and let a server-side worker securely call Volanea’s email API.
That distinction matters. It keeps database writes fast, prevents an email provider outage from breaking your API request, and gives you a durable place to track retries, send status, and duplicate protection.
The important limitation: PostgREST does not emit webhooks
PostgREST is a standalone server that turns database objects into a REST API. Its public API surface is built around PostgreSQL tables, views, and functions; it is not a hosted workflow builder, webhook dispatcher, or marketplace integration system.
So there is no native PostgREST action named “send webhook,” no PostgREST workflow screen in which to add an HTTP request, and no PostgREST app installation for Volanea. PostgREST also does not have standard-plan tiers that add or remove webhook payload fields. If you see an integration directory that lists PostgREST, treat it as a description of the database API connection—not proof that PostgREST can push an event to an external service by itself.
That means PostgREST sends no outbound event payload when a row is inserted. The payload exists only on the incoming side: a client sends JSON to a PostgREST endpoint, PostgreSQL writes the row, and PostgREST returns an HTTP response according to the request’s representation preferences.
The reliable architecture is therefore:
- A client creates a record through PostgREST, such as
POST /orders. - A PostgreSQL
AFTER INSERTtrigger writes a corresponding pending job to anemail_outboxtable. - A private worker reads pending jobs through a restricted PostgREST endpoint or direct database connection.
- The worker maps the outbox row to
POST https://api.volanea.com/v1/send. - The worker records the Volanea message identifier or retryable failure back in PostgreSQL.
This is an outbox pattern. It may look like more moving pieces than a direct webhook, but it solves a critical consistency problem: an order can commit even if the email provider is temporarily slow or unavailable.
The concrete trigger: a record created through PostgREST
For this guide, the concrete event is an order record created through the PostgREST orders resource. The client makes a POST request to the database-backed endpoint:
POST /orders
Content-Type: application/json
Prefer: return=representation
{
"customer_email": "ada@example.com",
"customer_name": "Ada Lovelace",
"order_number": "ORD-1042",
"total_cents": 4900
}
With Prefer: return=representation, PostgREST can return the inserted representation. A typical JSON response is an array because PostgREST supports inserting multiple rows in one request:
[
{
"id": "5d1a4d61-11fb-489b-8ea7-bf443d1216d3",
"customer_email": "ada@example.com",
"customer_name": "Ada Lovelace",
"order_number": "ORD-1042",
"total_cents": 4900,
"created_at": "2026-10-11T14:05:16.412Z"
}
]
That response is useful to the client, but it is not an outbound webhook payload. Do not build delivery logic around the client response. A browser can disconnect after the database commits, and the client should never hold a Volanea secret anyway.
Instead, make the database trigger the durable enqueue operation in the same transaction as the order insert. Once the transaction commits, the new row and its pending email job exist together. If the insert rolls back, neither exists.
Create an email outbox in PostgreSQL
The outbox should preserve the business data required to send one logical email, a stable idempotency key, state for retries, and enough diagnostic information to investigate a failure later. Avoid storing a completed email body if your content contains sensitive data; store only what the worker genuinely needs.
Here is a practical schema for order-confirmation messages:
create type email_outbox_status as enum (
'pending',
'processing',
'sent',
'failed'
);
create table public.email_outbox (
id uuid primary key default gen_random_uuid(),
event_type text not null,
aggregate_id uuid not null,
recipient_email text not null,
recipient_name text,
order_number text not null,
total_cents integer not null,
status email_outbox_status not null default 'pending',
attempts integer not null default 0,
available_at timestamptz not null default now(),
idempotency_key text not null unique,
provider_message_id text,
last_error text,
created_at timestamptz not null default now(),
sent_at timestamptz
);
create index email_outbox_ready_idx
on public.email_outbox (status, available_at, created_at);
The idempotency_key is not an incidental field. It identifies one business action: “send the order confirmation for order ORD-1042.” It must remain stable across retries. If a worker creates a new key for every attempt, a timeout can turn into two confirmation emails.
Next, create the database trigger function. The function inserts one outbox row after each new order. It uses the order primary key in the idempotency key so the same order cannot enqueue the same confirmation twice accidentally.
create or replace function public.enqueue_order_confirmation()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.email_outbox (
event_type,
aggregate_id,
recipient_email,
recipient_name,
order_number,
total_cents,
idempotency_key
)
values (
'order.confirmed',
new.id,
new.customer_email,
new.customer_name,
new.order_number,
new.total_cents,
'order-confirmation:' || new.id::text
)
on conflict (idempotency_key) do nothing;
return new;
end;
$$;
create trigger orders_enqueue_order_confirmation
after insert on public.orders
for each row
execute function public.enqueue_order_confirmation();
The real trigger in this integration is therefore a PostgreSQL AFTER INSERT trigger on public.orders. PostgREST starts that sequence only because it writes the row through POST /orders; PostgreSQL owns the actual event handling.
Expose only a narrow worker-facing queue API
Do not let a public browser role read all pending emails, update email status, or see recipient data that it does not need. PostgREST’s security model follows database privileges and row-level security, so create a dedicated worker role with narrow access.
At a minimum, the worker needs to do three things:
- Read jobs that are ready to process.
- Mark a claimed job as
processing. - Mark a completed job as
sentor schedule a later retry.
A production implementation should claim jobs atomically. A simple GET followed by PATCH can race when two workers run at the same time: both workers may read the same pending row before either updates it.
A safer design is a PostgreSQL function that uses FOR UPDATE SKIP LOCKED and returns only the jobs claimed by the caller. Expose that function as a PostgREST RPC endpoint rather than exposing raw outbox-table updates.
create or replace function public.claim_email_jobs(job_limit integer default 20)
returns setof public.email_outbox
language sql
security definer
set search_path = public
as $$
with picked as (
select id
from public.email_outbox
where status = 'pending'
and available_at <= now()
order by created_at
for update skip locked
limit job_limit
)
update public.email_outbox o
set
status = 'processing',
attempts = attempts + 1,
last_error = null
from picked
where o.id = picked.id
returning o.*;
$$;
PostgREST exposes database functions as RPC endpoints when they are in an exposed schema and are available to the active role. A private worker can call this endpoint with:
POST /rpc/claim_email_jobs
Authorization: Bearer <worker-postgrest-jwt>
Content-Type: application/json
{
"job_limit": 20
}
The payload returned by this RPC is the actual JSON that your middleware processes. It is not an event that PostgREST pushed outward; it is a response your worker deliberately fetched:
[
{
"id": "1f67a9fd-45ca-4f5f-a7f7-4b5fd46c2521",
"event_type": "order.confirmed",
"aggregate_id": "5d1a4d61-11fb-489b-8ea7-bf443d1216d3",
"recipient_email": "ada@example.com",
"recipient_name": "Ada Lovelace",
"order_number": "ORD-1042",
"total_cents": 4900,
"status": "processing",
"attempts": 1,
"idempotency_key": "order-confirmation:5d1a4d61-11fb-489b-8ea7-bf443d1216d3"
}
]
This boundary is valuable because the worker can validate fields before it sends. An absent recipient_email, invalid amount, or malformed order number becomes a controlled failed job rather than a confusing email API error.
Map the outbox payload to Volanea’s send endpoint
Volanea’s single-message transactional endpoint is POST /v1/send. It accepts a secret API key and supports the Idempotency-Key header for safe retry handling. For a concise confirmation email, map the database job into recipient, sender, subject, HTML, and plain-text fields.
The following Worker example is complete enough to use as a secure middleware route. A scheduler invokes it periodically; it claims jobs from PostgREST, sends every claimed job to Volanea, and writes the outcome back to the outbox.
interface Env {
POSTGREST_URL: string;
POSTGREST_WORKER_TOKEN: string;
VOLANEA_API_KEY: string;
EMAIL_FROM: string;
}
type EmailJob = {
id: string;
recipient_email: string;
recipient_name: string | null;
order_number: string;
total_cents: number;
attempts: number;
idempotency_key: string;
};
const centsToUsd = (cents: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(cents / 100);
async function postgrest(env: Env, path: string, body: unknown) {
const response = await fetch(`${env.POSTGREST_URL}${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.POSTGREST_WORKER_TOKEN}`,
"Content-Type": "application/json",
"Prefer": "return=representation"
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`PostgREST ${response.status}: ${await response.text()}`);
}
return response;
}
async function finishJob(
env: Env,
job: EmailJob,
update: Record<string, unknown>
) {
const response = await fetch(
`${env.POSTGREST_URL}/email_outbox?id=eq.${encodeURIComponent(job.id)}`,
{
method: "PATCH",
headers: {
"Authorization": `Bearer ${env.POSTGREST_WORKER_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify(update)
}
);
if (!response.ok) {
throw new Error(`Could not update job ${job.id}: ${await response.text()}`);
}
}
export default {
async scheduled(_event: ScheduledEvent, env: Env) {
const claimResponse = await postgrest(env, "/rpc/claim_email_jobs", {
job_limit: 20
});
const jobs = (await claimResponse.json()) as EmailJob[];
for (const job of jobs) {
if (!job.recipient_email) {
await finishJob(env, job, {
status: "failed",
last_error: "Missing recipient_email"
});
continue;
}
const total = centsToUsd(job.total_cents);
const recipientName = job.recipient_name || "there";
const volaneaPayload = {
from: env.EMAIL_FROM,
to: job.recipient_email,
subject: `Order ${job.order_number} confirmed`,
html: `<p>Hi ${recipientName},</p><p>Thanks for your order <strong>${job.order_number}</strong>.</p><p>Total: <strong>${total}</strong></p>`,
text: `Hi ${recipientName},\n\nThanks for your order ${job.order_number}.\nTotal: ${total}`
};
try {
const sendResponse = await fetch("https://api.volanea.com/v1/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.VOLANEA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": job.idempotency_key
},
body: JSON.stringify(volaneaPayload)
});
const responseText = await sendResponse.text();
if (!sendResponse.ok) {
throw new Error(`Volanea ${sendResponse.status}: ${responseText}`);
}
const sent = JSON.parse(responseText) as { id?: string };
await finishJob(env, job, {
status: "sent",
provider_message_id: sent.id || null,
sent_at: new Date().toISOString(),
last_error: null
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const nextAttempt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
await finishJob(env, job, {
status: "pending",
available_at: nextAttempt,
last_error: message
});
}
}
}
};
The field mapping is explicit:
email_outbox.recipient_emailbecomes Volaneato.EMAIL_FROMbecomes Volaneafrom.email_outbox.order_numberbecomes part ofsubject,html, andtext.email_outbox.total_centsbecomes a currency-formatted amount.email_outbox.idempotency_keybecomes theIdempotency-Keyrequest header.
For larger or localized messages, use a Volanea template instead of assembling HTML in the worker. Keep the event data in the outbox, render only trusted values, and encode user-supplied values before placing them into HTML. The email API reference and setup guides are the right place to confirm template fields and response details before changing a production message format.
Where the Volanea API key belongs
The Volanea API key belongs in the worker’s server-side secret store. In the example above, it is exposed to runtime code as env.VOLANEA_API_KEY, but it should be configured as a platform secret—not committed in a repository, placed in a browser bundle, or stored in a publicly readable table.
Do not put the Volanea key in any of these places:
- A JavaScript application that calls PostgREST from the browser.
- A PostgREST client configuration file distributed with a mobile or desktop app.
- An
ordersrow,email_outboxrow, customer record, or public database setting. - A client-visible environment variable such as a frontend
PUBLIC_*orNEXT_PUBLIC_*value. - Logs, error traces, screenshots, support tickets, or email templates.
PostgREST itself needs database and authentication configuration, but it is not an outbound HTTP client for this integration. Giving a Volanea key to PostgREST would not create a sending capability; it would only place a high-value credential nearer to your database process.
The worker also needs a credential to call PostgREST. Keep that credential separate from the Volanea key and restrict its database role. Ideally, the worker role can call only claim_email_jobs and update only the status columns needed to complete those jobs. It should not have permission to read every customer table or modify orders.
Why the outbox pattern is safer than sending in a database trigger
It can be tempting to make a PostgreSQL trigger call external HTTP directly through an extension or custom function. That approach creates hard operational trade-offs.
First, a database transaction should not wait on an external email API. A slow network, DNS issue, provider timeout, or email-service outage can make routine inserts slow and unpredictable. Worse, a client may receive an error while the remote email send actually succeeded, leaving you unable to know whether it is safe to retry.
Second, a failed HTTP call inside a trigger forces an uncomfortable choice. If the trigger raises an error, it can roll back the order merely because confirmation email was unavailable. If it swallows the error, you have no reliable retry record. The outbox makes the intended answer explicit: commit the business event, then retry the side effect independently.
Third, an outbox gives you an audit trail. You can answer operational questions such as: Which order generated this message? How many times did the worker try? What Volanea message ID was returned? Is the problem an invalid recipient, an authentication failure, or a temporary provider outage?
Using Zapier or Make instead of custom middleware
Because PostgREST does not push outbound events, Zapier and Make cannot subscribe to a native “new PostgREST record” webhook. A no-code route must poll a restricted PostgREST view or RPC endpoint on a schedule, then make an authenticated HTTP request to Volanea.
The flow is conceptually the same:
- A PostgREST
POST /ordersrequest inserts an order. - The PostgreSQL
AFTER INSERTtrigger creates a pendingemail_outboxrow. - Zapier or Make runs on a schedule and requests a tightly scoped queue endpoint.
- The automation maps the returned row into Volanea’s
POST /v1/sendrequest. - The automation marks the job sent only after Volanea accepts it.
For a small internal workflow, that may be sufficient. For receipts, password resets, account verification, order confirmations, or any message where timing and duplicate control matter, a small worker is usually the stronger choice. It can claim jobs atomically, keep secrets out of visual step configuration, apply precise retry rules, and use the same idempotency key on every resend.
If you do use Zapier or Make, do not treat a successful HTTP action as enough. Preserve the stable outbox key, send it as Volanea’s Idempotency-Key, and update the outbox only after the provider response is successful. Also protect the polling endpoint with a dedicated role; a general-purpose public endpoint can expose customer email addresses to anyone who obtains the automation URL or token.
When this breaks
Every email integration should define failure behavior before the first incident. In this architecture, failures happen at specific hops, and each hop needs a different response.
A retry causes duplicate sends
The most dangerous failure is an ambiguous timeout. The worker may send the request to Volanea, Volanea may accept it, and the connection may fail before the worker receives the response. If the worker blindly retries with a new request identity, recipients may receive two confirmation emails.
Use the same outbox idempotency_key in Volanea’s Idempotency-Key header for every retry of the same logical event. Do not generate that key in the worker for each HTTP attempt. Generate it at enqueue time, store it durably, and reuse it.
Also remember that idempotency does not replace database correctness. Your trigger should use a unique constraint or conflict-safe insert so one business event creates one outbox job. Volanea can deduplicate repeated send attempts; it cannot fix two separately created outbox jobs with two different keys.
The worker times out or is interrupted
A worker can stop after claiming a job but before it sends. Without recovery, jobs can remain in processing forever.
Add a recovery rule appropriate to your environment. For example, a scheduled maintenance task can move jobs that have been processing for more than a defined period back to pending, then retry them with the original idempotency key. Store a claimed_at timestamp and a worker identifier if you need a clear audit trail.
Do not immediately retry every failure. Authentication and malformed-payload errors need configuration fixes, not repeated calls. Network failures, temporary provider errors, and rate limits are the cases for exponential backoff.
Expected fields are missing
There is no PostgREST plan-dependent webhook payload because PostgREST does not emit webhooks. If a field is missing here, investigate the actual source: the incoming order request, the orders table default, the trigger mapping, the outbox row, or the worker’s selected columns.
For example, an order might be valid before an email address is collected, or a guest checkout might store customer_name as NULL. Decide the policy explicitly. A missing name can safely fall back to “there”; a missing recipient email should mark the job failed and alert an operator rather than repeatedly retrying.
Validate values at multiple layers:
- Database constraints for fields that must exist to create an order.
- Trigger logic that only enqueues events when they are eligible for email.
- Worker validation before calling Volanea.
- Optional pre-send verification for user-entered email addresses with the address verification tool.
The Volanea request is rejected
A 4xx response commonly means the request is not retryable as-is. Check the sender identity, API key, request body, recipient format, and any account-level sending restrictions. Record the provider response in last_error without recording secrets.
A 5xx response or transport failure is normally retryable. Back off, retain the same idempotency key, and cap attempts. After the cap, mark the job failed and route it to an operator queue. A failed operational email should be visible; silently dropping it because a worker reached a retry limit is not a reliable system.
Database permissions block the worker
PostgREST executes through PostgreSQL roles, so an apparently correct HTTP request can fail because the worker token resolves to a role without access to the RPC function or status update. Test with the exact worker credential, not an administrator credential.
Grant the narrowest possible permissions. A worker that can send mail should not automatically gain unrestricted access to order history, customer profiles, financial data, or administrative tables.
Testing the full path before production
Test the integration as a series of observable contracts rather than as one opaque end-to-end action.
Start by inserting a test order through the same PostgREST endpoint your application will use. Confirm that the AFTER INSERT trigger creates exactly one outbox row and that its idempotency_key is stable. Then call the claim RPC once and verify that the job state changes to processing.
Next, run the worker with a test Volanea key and a verified test sender. Confirm that the request maps the expected recipient, subject, HTML, plain text, and idempotency header. Finally, verify that the database stores the returned Volanea message ID and changes the job to sent.
Test these negative cases deliberately:
- Insert an order with no email address and confirm the job becomes a visible failure.
- Force a temporary network failure after claiming a job and verify recovery works.
- Run two workers at once and verify a job is claimed only once.
- Retry the same job and confirm its idempotency key does not change.
- Return a Volanea 4xx response and ensure the worker does not retry forever.
- Return a Volanea 5xx response and ensure the retry schedule applies.
The goal is not merely to prove that one email arrives. The goal is to prove that one order causes one logical send, that failures remain recoverable, and that credentials never leave the trusted server-side boundary.
A practical production checklist
Before relying on this flow for customer-facing messages, check the following:
- The sending domain has been verified in Volanea and the
fromaddress uses that domain. - The database trigger creates one outbox record per eligible business event.
- The outbox has a unique, stable idempotency key for every message.
- A restricted PostgREST role can claim and complete jobs but cannot read unnecessary data.
- The Volanea API key is stored only as a server-side worker secret.
- The worker validates recipient and message inputs before sending.
- Retryable failures use backoff and retain the original idempotency key.
- Non-retryable failures become visible in an operator workflow.
- Stuck
processingjobs can be reclaimed safely. - You can correlate an order ID, outbox ID, idempotency key, and Volanea message ID during incident response.
Conclusion
The reliable way to send email from PostgREST with Volanea is not a direct PostgREST webhook because PostgREST does not provide one. Instead, let PostgREST create the database record, let PostgreSQL atomically enqueue an email outbox job, and use a protected worker to call Volanea’s transactional send endpoint.
This architecture separates an order’s success from an external email request, protects the Volanea API key, makes retries safe with idempotency, and gives you a durable audit trail. It is a little more deliberate than a one-click connector, but it is much better suited to transactional email that customers actually depend on.
FAQ
Can PostgREST call the Volanea API directly?
No. PostgREST exposes PostgreSQL tables, views, and functions as HTTP resources, but it does not natively dispatch outbound webhooks or make external REST calls as part of its standard API behavior. Use a worker, automation that polls a restricted endpoint, or a database-specific extension only after assessing its operational trade-offs.
What starts the email send in this setup?
A PostgreSQL AFTER INSERT trigger starts the durable process by inserting a pending row into email_outbox whenever an eligible orders record is created through PostgREST. A scheduled worker later claims that job and sends the email.
Where should I store the Volanea API key?
Store it in the server-side secret manager for the worker or middleware runtime. Never place it in browser code, a PostgREST client configuration, a publicly readable table, or any client-visible environment variable.
How do I prevent duplicate order emails?
Create one stable idempotency key per logical order-confirmation event, store it in the outbox, and send it in Volanea’s Idempotency-Key header on the original request and every retry. Also enforce uniqueness when creating outbox rows.
Can I use Zapier or Make with PostgREST?
Yes, but not through a native PostgREST outbound webhook. Use a scheduled polling workflow against a restricted PostgREST RPC or view, then call Volanea from the automation. For high-value transactional mail, a dedicated worker generally provides better locking, secret isolation, retry control, and observability.