Prove the lifecycle, then your real inbox
Get a real email ID and lifecycle events without provider traffic, send one private fixed message to your signed-in address, then authenticate your domain when the code path is ready.
Create an API key
Accept the current Terms and Acceptable Use Policy in each workspace, create a sending key in the dashboard, then store it as a server-side environment variable. Keys pause automatically if a new policy version is waiting for the workspace owner.
delivered@forward.test. They work before domain verification, never contact the provider, and read the secret only from FORWARD_API_KEY.FORWARD_BASE_URL=https://your-forward-host.example
FORWARD_API_KEY=fw_live_xxxxxxxxxRun a provider-free delivery
Use the exact production endpoint and SDK shape. The response, log, recipient state, and signed webhook events are real ForwardHello records.
const response = await fetch(
`${process.env.FORWARD_BASE_URL}/api/v1/emails`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FORWARD_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID()
},
body: JSON.stringify({
from: 'Forward <sandbox@forward.test>',
to: 'delivered@forward.test',
subject: 'Provider-free integration check ✨',
text: 'This message never leaves Forward.'
})
}
);
const email = await response.json();
console.log(email.status, email.test_mode); // delivered, truetest_mode: true, an email ID, and x-forward-request-id. Test traffic never consumes daily or monthly sending quota.Send a private real-inbox test
From the Dashboard launch checklist, send ForwardHello's fixed onboarding message through the live delivery provider only to your current signed-in workspace email. The recipient and content cannot be changed, the request is deterministic per member and workspace, and durable notification retries preserve the same provider idempotency key. This account-level proof does not consume customer quota or replace API integration testing.
Authenticate your domain and switch recipients
Publish the ForwardHello ownership TXT, then the provider SPF and DKIM records. ForwardHello also checks optional DMARC guidance without blocking send readiness. Replace the forward.test sender and recipient only after the two required stages pass.
forward-verification=your-domain-tokenSend with types, not hand-written fetch calls
The zero-dependency @forward-email/sdk package covers every public sending, receiving, API-key, domain, template, Event, Automation, Audience, Broadcast, Webhook, and Suppression operation and works in Node.js 20 or newer through native ESM or CommonJS. Both entry points share the same declarations, convert friendly camelCase inputs to the wire contract, return typed data plus request, rate, and quota metadata, and throw a structured ForwardError for API, timeout, cancellation, and network failures.
npm install @forward-email/sdkimport { Forward, ForwardError } from '@forward-email/sdk';
const forward = new Forward(process.env.FORWARD_API_KEY!, {
baseUrl: process.env.FORWARD_BASE_URL!
});
try {
const { data: email, meta } = await forward.emails.send(
{
from: 'Mia <hello@paperkite.co>',
to: 'friend@example.com',
subject: "You're in ✨",
html: '<strong>Welcome aboard.</strong>'
},
{ idempotencyKey: crypto.randomUUID() }
);
console.log(email.id, meta.requestId, meta.monthlyQuota?.remaining);
} catch (error) {
if (error instanceof ForwardError) {
console.error(error.code, error.requestId, error.retryAfter);
}
}import { Forward } from '@forward-email/sdk'; CommonJS can const { Forward } = require('@forward-email/sdk'). The published package routes both to tested builds without bundling another runtime dependency.idempotencyKey, retry the exact same payload, and respect error.retryAfter when the API asks you to wait.meta.requestId; API failures expose the same value as error.requestId. Include it in a support request without copying an API key or message content.Use forward.emails to send, batch, list, inspect, reschedule, or cancel. Use forward.events and forward.automations to define typed product events, trigger email, and inspect durable Runs. Use forward.apiKeys to provision and revoke server credentials, forward.domains to automate sending-domain setup and DNS verification, forward.webhooks to manage signed event endpoints and secret rotation, forward.suppressions to synchronize blocked recipients, and forward.templates to synchronize reusable content from a deployment pipeline. forward.attachments and forward.receiving cover retained files and inbound mail. Consent-aware profiles are available through forward.contacts, forward.segments, and forward.topics; newsletters use forward.broadcasts.
Let customers connect without sharing an API key
Create a developer app from Connected Apps, register up to five exact callback URLs, and send each customer through ForwardHello's bright consent screen. ForwardHello implements the OAuth 2.1 authorization-code flow for public clients with mandatory S256 PKCE: there is no client secret to leak from a desktop app, CLI, browser extension, or agent.
// Public clients generate a fresh verifier for every authorization.
const bytes = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = btoa(String.fromCharCode(...bytes))
.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(codeVerifier)
);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
const state = crypto.randomUUID();
const authorize = new URL('/oauth/authorize', FORWARD_BASE_URL);
authorize.search = new URLSearchParams({
response_type: 'code',
client_id: FORWARD_CLIENT_ID,
redirect_uri: 'https://app.example.com/oauth/callback',
scope: 'email.send',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}).toString();
await saveOneTimeOAuthTransaction({ state, codeVerifier });
window.location.assign(authorize);
// After the user approves, exchange the returned code once.
const callback = new URL(window.location.href);
const transaction = await takeOneTimeOAuthTransaction(
callback.searchParams.get('state')
);
const tokens = await fetch(new URL('/oauth/token', FORWARD_BASE_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: FORWARD_CLIENT_ID,
redirect_uri: 'https://app.example.com/oauth/callback',
code: callback.searchParams.get('code'),
code_verifier: transaction.codeVerifier
})
}).then((response) => response.json());
const forward = new Forward(tokens.access_token, {
baseUrl: FORWARD_BASE_URL
});/oauth/authorize/oauth/token/oauth/revokeRequest exactly one scope: email.send maps to Sending access, while workspace.full maps to Full access. Authorization codes expire after five minutes and work once. Access tokens use the fw_oauth_* prefix and expire after one hour. Refresh tokens expire after 30 days and rotate on every successful refresh; replaying an older token fails.
Pass the access token to the TypeScript, Python, or Go SDK anywhere it accepts the normal ForwardHello credential. The request then follows the same current workspace membership, legal acceptance, permission, domain, rate-limit, quota, suppression, production-access, and Activity controls as an API key. Each active connection uses one API credential slot from the workspace plan, so a connection cannot silently bypass the published credential allowance.
state, refuses plain PKCE, and never sends a token through the authorization redirect.Discovery is available at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource. Token and revocation requests accept form-encoded bodies and return standard OAuth error fields. Keep the refresh token in the platform's secure storage; browser local storage is not a credential vault.
Use the whole ForwardHello API from Python
The type-annotated forward-email package supports Python 3.10 and newer with no runtime dependencies. Its synchronous resources cover every public ForwardHello operation, use the exact snake_case OpenAPI wire fields, return data plus request, rate, and quota metadata, and raise structured ForwardError values for API, timeout, and network failures.
pip install forward-emailimport os
from forward_email import Forward, ForwardError
forward = Forward(
os.environ["FORWARD_API_KEY"],
base_url=os.environ["FORWARD_BASE_URL"],
)
try:
result = forward.emails.send(
{
"from": "Mia <hello@paperkite.co>",
"to": "friend@example.com",
"subject": "You're in ✨",
"html": "<strong>Welcome aboard.</strong>",
},
idempotency_key="welcome-customer-1042",
)
print(result.data["id"], result.meta.request_id)
except ForwardError as error:
print(error.code, error.request_id, error.retry_after)idempotency_key only when your application deliberately retries the identical payload.verify_webhook uses HMAC SHA-256 and constant-time comparison, rejects stale timestamps and mismatched event IDs, and supports active, pending, and previous secret-rotation headers.Use forward.api_keys and forward.api_logs for Pythonic resource names. Request dictionaries intentionally stay identical to the REST contract, so the same validated payload can move between the SDK, a job queue, and a direct API call without case conversion.
Use the whole ForwardHello API from Go
The context-aware github.com/forward-email/forward-go module supports Go 1.22 and newer using only the standard library. Its resources cover all 82 public ForwardHello operations, use the exact snake_case OpenAPI wire fields, return data plus request, rate, and quota metadata, and expose structured ForwardError values for API, timeout, cancellation, and network failures.
go get github.com/forward-email/forward-gopackage main
import (
"context"
"log"
"os"
forward "github.com/forward-email/forward-go"
)
func main() {
client, err := forward.New(os.Getenv("FORWARD_API_KEY"), forward.Options{
BaseURL: os.Getenv("FORWARD_BASE_URL"),
})
if err != nil {
log.Fatal(err)
}
result, err := client.Emails.Send(context.Background(), forward.Object{
"from": "Mia <hello@paperkite.co>",
"to": "friend@example.com",
"subject": "You're in ✨",
"html": "<strong>Welcome aboard.</strong>",
}, "welcome-customer-1042")
if err != nil {
log.Fatal(err)
}
log.Println(result.Data["id"], result.Meta.RequestID)
}context.Context and performs each write once. Reuse one stable idempotency key only when your application deliberately retries the identical payload.VerifyWebhook uses HMAC SHA-256 with constant-time comparison, enforces a five-minute replay window by default, validates the signed event ID, and supports pending and previous rotation signatures.Request bodies use forward.Object or your own JSON-serializable structs, so validated snake_case payloads can move between the SDK, a queue, and the REST API without case conversion. The client refuses redirects by default so a deployment mistake cannot forward its bearer credential to another host.
Prove the path before changing application code
The zero-runtime-dependency @forward-email/cli package sends and inspects email, changes or cancels schedules, creates and verifies domains, enables or pauses domain receiving, reads usage and privacy-minimized API logs, and returns stable JSON for scripts and CI. It reads FORWARD_BASE_URL and FORWARD_API_KEY from the process environment and never writes the key to a config file.
npm install --global @forward-email/cli
forward emails send \
--from "Forward <sandbox@forward.test>" \
--to delivered@forward.test \
--subject "Provider-free CLI check ✨" \
--text "This message never leaves Forward." \
--tag category=quickstart \
--idempotency-key "quickstart-$(date +%s)"forward doctor to check the public health route, OpenAPI contract, and whether the current key is accepted. A least-privilege Sending key is reported as limited—not broken—because usage and management intentionally require Full access.data plus request, rate, and quota metadata. Failures return a bounded error with status, code, retry guidance, and safe request ID without echoing the API key.Use forward help emails and forward help domains for the bounded command surface. Supply one stable --idempotency-key when a send may be retried, and use a workspace-wide Full access key only for list, inspect, domain, usage, or log operations.
Keep SMTP clients on ForwardHello's ordinary safety path
Use the ForwardHello SMTP hostname configured by your service operator, port 587 with required STARTTLS, username forward, and a ForwardHello API key as the password. A Sending access key is the least-privilege default; domain-scoped keys remain limited to their sender domain. WordPress, Supabase Auth, Rails, Laravel, Django, PHPMailer, and Nodemailer can use the same credential shape without receiving a separate SMTP password.
Host smtp.your-forward-domain.example
Port 587
Security STARTTLS (required)
Username forward
Password fw_live_xxxxxxxxxxxxxxxxxxxxxxxxnpm install nodemailerimport nodemailer from 'nodemailer';
const transport = nodemailer.createTransport({
host: process.env.FORWARD_SMTP_HOST,
port: 587,
secure: false,
requireTLS: true,
auth: {
user: 'forward',
pass: process.env.FORWARD_API_KEY
},
tls: { minVersion: 'TLSv1.2' }
});
const result = await transport.sendMail({
from: 'Mia <hello@paperkite.co>',
to: 'friend@example.com',
subject: "You're in ✨",
html: '<strong>Welcome aboard.</strong>',
headers: {
'Resend-Idempotency-Key': 'welcome-customer-1042',
'X-Forward-Tag': 'category=welcome'
}
});
console.log(result.response); // 250 ... Queued as em_...The gateway parses bounded MIME, preserves visible To/CC plus envelope-only BCC recipients, translates Reply-To, HTML, text, inline content IDs, and up to 10 attachments, then submits one ordinary POST /api/v1/emails. The API still owns legal acceptance, verified sender and domain scope, production access, suppression checks, workspace rate and quota reservations, reputation protection, provider retries, Email logs, and signed customer webhooks. SMTP traffic therefore appears in the same Dashboard rather than a second delivery ledger.
Add Resend-Idempotency-Key, Idempotency-Key, or X-Forward-Idempotency-Key to prevent one logical message from being accepted twice. Repeat X-Forward-Tag: name=value for searchable tags, or use X-Forward-Scheduled-At for the same schedule syntax as the Email API. Conflicting control headers, unsafe custom headers, BCC-only messages without a visible To recipient, and messages outside ForwardHello's recipient, content, or attachment limits fail before an Email record is created.
@forward-email/smtp gateway permits only PLAIN or LOGIN after STARTTLS or implicit TLS, never logs the API key or message content, keeps the credential only in connection memory, and returns 250 only after ForwardHello assigns the normal em_* ID.5xx. Rate limits, API timeouts, and service availability return retryable 4xx; clients should retry the unchanged message with the same idempotency key./readyz check, and the same separate 32+ character gateway secret on both services.Let Codex, Claude, or Cursor operate ForwardHello safely
The zero-dependency @forward-email/mcp package reads the live ForwardHello OpenAPI contract and turns every documented operation into a typed MCP tool. Agents can send and inspect email, manage Events and Automations, manage domains and API keys, synchronize contacts, prepare Broadcasts, diagnose webhook delivery, and review privacy-safe operational telemetry without ForwardHello maintaining a second, drifting tool catalog.
codex mcp add forward \
--env FORWARD_API_KEY=fw_live_xxxxxxxxxxxxxxxxxxxxxxxx \
--env FORWARD_BASE_URL=https://your-forward-host.example \
-- npx -y @forward-email/mcpsendEmail, createDomain, and listApiLogs. New public operations enter MCP coverage through the same release contract and regression gate.Use local Streamable HTTP when stdio is not available
FORWARD_BASE_URL=https://your-forward-host.example \
npx -y @forward-email/mcp --http --port 3000
# Connect to http://127.0.0.1:3000/mcp and send the
# Forward API key as an Authorization: Bearer header.The HTTP listener binds to localhost by default, requires a ForwardHello Bearer key per client unless an operator deliberately supplies a shared default, validates browser Origin headers, bounds JSON bodies, and keeps sessions stateless. Attachment downloads return Base64 plus content type and byte length; treat that output as private message content.
Generate types and clients from the real API
ForwardHello publishes an OpenAPI 3.1 document for every customer-facing Email, Event, Automation, API Key, Domain, Template, Audience, Broadcast, Webhook, Suppression, and Observability operation. Import the URL into an OpenAPI-compatible generator, API client, contract test, or development portal instead of copying request types by hand.
/api/v1/openapi.jsonfw_live_* API key or short-lived fw_oauth_* access token and preserve all workspace, domain, quota, and rate-limit checks.The contract includes single and batch sends, API-key provisioning and revocation, sending-domain lifecycle and DNS verification, webhook lifecycle and secret rotation, suppression synchronization, inbound email and attachment downloads, versioned template management and references, cursor pagination, per-recipient delivery outcomes, scheduling, cancellation, request-tracing and quota response headers, and the shared error envelope. Its relative server URL automatically targets the ForwardHello deployment that served the document.
Send email
Create and queue a transactional email for delivery.
/api/v1/emailsconst invoiceBase64 = Buffer.from('Invoice #1042').toString('base64');
const response = await fetch(
`${process.env.FORWARD_BASE_URL}/api/v1/emails`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FORWARD_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID()
},
body: JSON.stringify({
from: 'Mia <hello@paperkite.co>',
to: 'friend@example.com',
cc: 'team@example.com',
bcc: 'audit@example.com',
headers: { 'X-Correlation-Id': 'welcome-cus-1042' },
tags: [
{ name: 'customer_id', value: 'cus_1042' },
{ name: 'category', value: 'welcome' }
],
attachments: [{
filename: 'invoice.txt',
content: invoiceBase64,
content_type: 'text/plain'
}],
scheduled_at: new Date(Date.now() + 15 * 60_000).toISOString(),
subject: "You're in ✨",
html: '<strong>Welcome aboard.</strong>'
})
}
);
const email = await response.json();Request body
fromstring · requiredA name and address using a verified domain.
tostring or string[] · requiredOne or more recipient email addresses, up to 50.
ccstring or string[]Optional visible copy recipients.
bccstring or string[]Optional hidden copy recipients.
subjectstring · conditionalThe subject line shown in the inbox. Omit it when using a template.
htmlstringHTML body. Provide html, text, or both.
templateobjectA workspace template identified by alias or ID, plus its variables. Do not mix it with subject, html, or text.
reply_tostring or string[]Optional reply-to addresses.
headersobjectUp to 30 safe custom message headers. Envelope, MIME, authentication, and ForwardHello/provider fields are reserved.
tagsarrayUp to 74 searchable name/value pairs carried into logs, webhooks, retrieval, and analytics.
attachmentsarrayUp to 10 Base64 files with filename, optional content type, and optional content ID.
scheduled_atstringAn ISO 8601 date with timezone, or a phrase such as in 15 minutes. Up to 30 days ahead.
x-forward-request-id; a caller-supplied value is never trusted. Authenticated API calls share a workspace-wide limit of 5 requests per second and include RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset; a 429 also includes Retry-After. Successful sends return total, -outbound, and -inbound monthly usage plus the hard limit, included limit, continuity reserve and use, remaining, period, and Unix reset time through the x-forward-monthly-quota* headers. Free sends also return the matching x-forward-daily-quota* headers.403 sending_access_limited; a suspended workspace returns 403 sending_suspended.A request accepts at most 50 total recipients across to, cc, and bcc. The same address cannot appear twice across those fields. Every recipient is checked against suppressions, counts independently toward rate and plan quotas, and keeps its own durable delivery status.
Custom header names are case-insensitive and must be unique. Values cannot contain control characters. Set recipients, sender, reply-to, subject, and message content through their request fields: ForwardHello rejects headers that could replace the verified envelope, MIME structure, provider authentication, or the x-forward-* and x-resend-* namespaces. Application headers such as X-Correlation-Id and standards-based list headers remain available.
Tag names and values accept 1–256 ASCII letters, numbers, underscores, or dashes. Tag names must be unique within one email, so identifiers such as customer_id, order_id, and category stay deterministic. ForwardHello accepts up to 74 customer tags and always reserves one additional provider tag for secure event correlation. forward_internal_id is never exposed as a customer tag.
Attachment content must be canonical Base64. The encoded message and attachments together can be at most 40 MB. Filenames cannot contain paths, and optional content_id values must be unique and shorter than 128 characters. ForwardHello accepts file bytes only—never a remote URL—so a send cannot make the service fetch an untrusted host.
Retry safely with Idempotency-Key
Add a unique Idempotency-Key of up to 256 characters to single or batch sends. ForwardHello binds the key to a canonical SHA-256 fingerprint of the original JSON request, including its template reference; large attachment content is hashed before it enters the fingerprint material. An identical retry returns the original ForwardHello IDs without sending again.
409 invalid_idempotent_request. If the matching request is still being processed, ForwardHello returns 409 concurrent_idempotent_requests plus Retry-After: 1; retry the exact request after that delay.Free workspaces can send to 50 recipients per UTC day and 500 per UTC calendar month. Paid plans remove the daily cap and renew their included recipient allowance on the billing-provider cycle confirmed by a signed callback. Every send stores the period it reserved and whether it actually consumed the Free daily allowance, so a cancellation or definitive provider rejection rolls back only that original reservation once—even when a renewal, upgrade, or downgrade occurs while delivery is pending. If acceptance cannot be confirmed safely, ForwardHello returns status: "queued", keeps the reservation, and retries with the same provider idempotency key.
429, ForwardHello honors Retry-After, retains quota, and retries with the same idempotency key.unknown and emits email.delivery_unknown; a later authenticated provider event can still resolve it.Trigger deterministic lifecycle outcomes
Send one normal POST /api/v1/emails request to a reserved address. ForwardHello authenticates the API key, stores the message and recipient log, writes the normal durable webhook outbox, and returns the requested terminal state without calling the delivery provider.
const response = await fetch(
`${process.env.FORWARD_BASE_URL}/api/v1/emails`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FORWARD_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID()
},
body: JSON.stringify({
from: 'Forward <sandbox@forward.test>',
to: 'delivered@forward.test',
subject: 'Provider-free integration check ✨',
text: 'This message never leaves Forward.'
})
}
);
const email = await response.json();
console.log(email.status, email.test_mode); // delivered, truesent@forward.testemail.sentStops after provider acceptance.
delivered@forward.testemail.sent → email.deliveredExercises the happy-path delivery lifecycle.
bounced@forward.testemail.sent → email.bouncedExercises bounce handling without changing the suppression list.
complained@forward.testemail.sent → email.complainedExercises complaint handling without affecting sender reputation.
failed@forward.testemail.failedReturns a stored test_failure detail for failure UI and webhook handling.
test_mode in send and list responses and testMode in email detail, and are excluded from quota, delivery analytics, suppressions, and reputation safeguards.to recipient with no CC or BCC. Scheduling, attachments, unknown forward.test addresses, mixed real recipients, and batch requests fail before any provider or quota work. Each workspace can store 20 provider-free test emails per UTC day, with up to 100,000 combined HTML and text characters per test.Send up to 100 emails in one request
The batch endpoint accepts an array of independent email objects. Each item can use its own recipients, content, template, headers, and tags; the response preserves the same zero-based order.
/api/v1/emails/batchconst response = await fetch(
`${process.env.FORWARD_BASE_URL}/api/v1/emails/batch`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FORWARD_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'orders-2026-07-14'
},
body: JSON.stringify([
{
from: 'Mia <hello@paperkite.co>',
to: 'ari@example.com',
subject: 'Order #1042 is ready',
text: 'Your order is ready to collect.',
tags: [{ name: 'order_id', value: 'order_1042' }]
},
{
from: 'Mia <hello@paperkite.co>',
to: 'jo@example.com',
subject: 'Order #1043 is ready',
text: 'Your order is ready to collect.'
}
])
}
);
const batch = await response.json();
console.log(batch.id, batch.data);Batch size is 1–100 emails, and each email retains the normal 50-recipient envelope limit. Strict validation reports the zero-based failing index and item error code. Add one Idempotency-Key for the entire request; exact retries return the original batch and ordered ForwardHello email IDs without sending again, while changed items are rejected with invalid_idempotent_request.
attachments and scheduled_at are intentionally unsupported in batches. Use the single-email endpoint for either feature; ForwardHello returns the indexed item codes batch_attachments_not_supported or batch_scheduling_not_supported. Atomic provider batching currently requires the Resend delivery provider; Amazon SES and custom HTTP adapters return 409 batch_delivery_not_supported.
Schedule, change, or cancel a send
Add scheduled_at to the normal send request to reserve delivery between one minute and 30 days from now. ForwardHello stores the normalized time, passes it to the delivery provider, returns status: "scheduled", and records an email.scheduled event.
/api/v1/emails/:idChange a pending delivery with { "scheduled_at": new Date(Date.now() + 60 * 60_000).toISOString() }. Only emails that are still scheduled can be changed.
/api/v1/emails/:id/cancelCanceling is idempotent. A canceled email cannot be rescheduled; create a new send instead. ForwardHello records email.canceled, returns only the daily and monthly quota that this email originally reserved, and keeps the message log for audit history. A plan change between scheduling and cancellation cannot alter that calculation.
operation_pending: true; inspect the email for progress or an exhausted error that can be explicitly retried. The Dashboard exposes the same state instead of pretending the change already finished.Stop every outbound lane with one workspace switch
Owners and Admins can pause sending from Workspace settings. Trusted automation uses a workspace-wide Full access key: fetch the current object, then PATCH the exact updated_at back as expected_updated_at.
/api/v1/sending-controlconst current = await forward.sendingControl.get();
await forward.sendingControl.update({
paused: true,
expectedUpdatedAt: current.data.updated_at,
});API, SMTP, provider-free tests, Automation Runs, Broadcast deliveries, and provider retries stop before provider traffic. Local jobs stay queued without consuming attempts. Email already scheduled at the provider is canceled because a provider-confirmed schedule cannot be frozen; recreate it after resume. One provider request already in flight may still complete.
Turn one product event into the right thoughtfully timed email
An Event definition gives a product moment a stable name and a flat typed payload. An Automation can send one template immediately or after a durable delay, split one typed condition into two outcomes, or wait for a different follow-up Event with event-received and timeout outcomes. Condition, a nonzero initial delay, and Wait for Event are mutually exclusive. Every accepted trigger creates a durable Run for every matching enabled Automation, so two Automations listening to the same Event intentionally create two separate Runs. A Run start consumes the workspace's monthly Automation allowance; resuming a waiting Run does not consume a second start.
/api/v1/events/api/v1/events/send/api/v1/automations/api/v1/automations/:id/runsconst { data: event } = await forward.events.create({
name: 'trial.started',
schema: { trial_days: 'number', product_name: 'string' }
});
const { data: converted } = await forward.events.create({
name: 'subscription.started',
schema: { plan: 'string' }
});
const { data: automation } = await forward.automations.create({
name: 'Welcome new trials',
eventId: event.id,
templateId: 'tpl_0123456789abcdef01234567',
from: 'Mia <hello@paperkite.co>',
wait: {
eventId: converted.id,
timeoutSeconds: 86400,
filter: { field: 'plan', operator: 'eq', value: 'pro' },
timeoutTemplateId: 'tpl_fedcba9876543210fedcba98'
},
status: 'enabled'
});
const { data: accepted } = await forward.events.send({
event: 'trial.started',
email: 'delivered@forward.test',
payload: { trial_days: 14, product_name: 'Paper Kite' }
}, { idempotencyKey: 'trial-cus_1042-v1' });
const { data: resumed } = await forward.events.send({
event: 'subscription.started',
email: 'delivered@forward.test',
payload: { plan: 'pro' }
}, { idempotencyKey: 'paid-cus_1042-v1' });
const { data: runs } = await forward.automations.listRuns(automation.id);
console.log(accepted.matched_automations, resumed.resumed_runs, runs.data[0]?.branch);Define the payload before producers depend on it
Each Event can declare up to 25 fields using string, number, boolean, or timezone-qualified ISO 8601 date. A sent payload must include every declared field and no undeclared field, and its encoded size cannot exceed 100 KB. Templates may also use the built-in email, first_name, and last_name variables. ForwardHello refuses an incompatible template or schema change before an enabled flow can become silently incomplete.
Split one typed field into two honest paths
Set condition to compare one declared event field and provide a different fallback_template_id. String fields support equality, contains, prefix, suffix, exists, and empty checks; numbers and dates support ordered comparisons; booleans support equality and exists. The primary template_id is the condition-met path and the fallback template is the condition-not-met path. Both templates must be compatible with the same Event schema. Set condition: null to return future events to one path.
Wait for a follow-up Event—or take the timeout path
Set wait with a different event_id, a timeout from 60 seconds through 30 days, an optional typed filter, and a distinct timeout_template_id. A follow-up Event resumes every matching waiting Run for the same normalized email address before its deadline. The first atomic transition wins: the primary template sends on event_received, while the timeout template sends on timeout. The trigger payload renders both templates; the follow-up payload is used only for the optional filter. The Event response separates newly started matched_automations from resumed_runs.
Target exactly one person and retry safely
Send exactly one email or contact_id, never both. Supply a stable Idempotency-Key whenever your producer may retry: reusing it with the same event, target, and payload returns the original accepted event, while changed content is rejected. A Run captures the Automation name, selected branch, template versions, sender, recipient, initial delay, and any wait deadline and filter at acceptance; editing an Automation affects new Runs only.
Event acceptance reserves every matching Run atomically. If the monthly Run allowance or active waiting/queued/processing capacity cannot fit the complete event, ForwardHello returns 429 without storing a partial event or starting only some Automations. Retry the same idempotency key after the allowance resets, active Runs finish, stale Runs are canceled, or the workspace upgrades. The Automation Dashboard and GET /api/v1/usage show both limits and remaining capacity.
The minute-level worker leaves delayed or waiting Runs safely durable until their schedule or timeout, reclaims abandoned locks, retries transient delivery, quota, and rate-limit failures up to five total attempts, and links a successful Run to the ordinary Email log. Runs expose waiting, queued, processing, completed, failed, or canceled state plus a bounded error. Cancel is available while work is waiting, queued, or processing.
delivered@forward.test, bounced@forward.test, or another lifecycle test target. The Automation still creates a real Run and Email record, but test traffic consumes no sending quota and never leaves ForwardHello.Manage and send versioned templates
Create and preview templates in the dashboard, or synchronize them from CI through the public API and official SDK. Sends always resolve the current published version using its stable alias or ID. New Dashboard work can begin with a responsive Welcome, Magic link, Receipt, or Team invitation starter; each includes plain text, realistic sample variables, and completely editable source. Existing work can enter through a local UTF-8 .html or .htm file up to 1 MB; ForwardHello derives an editable name, alias, and subject without uploading the file anywhere except the ordinary draft-preview request.
/api/v1/templates/api/v1/templates/:idThe creation studio sends each unpublished draft to ForwardHello's production template validator and renderer after a short pause. Imported HTML follows this exact path: invalid UTF-8, empty or binary content, non-HTML files, and files above the content limit fail locally before preview. Its sandboxed preview blocks scripts, forms, remote images, and external resources, while showing the escaped sample-variable result that will be published. API creation and updates use the exact same validator. Invalid syntax, missing content, unsafe subject output, and oversized content fail before a version exists.
Every declared {{variable}} is required. ForwardHello rejects missing or unknown variables before consuming recipient quota. Creating a template rechecks the current Owner or Admin—or the exact Full access API key—plus workspace plan and template allowance at commit time, then stores the template, first version, and Activity event in one transaction. Publishing and deleting likewise recheck current authority and resource state before committing. API updates require expected_version in the body, while deletes require it in the query; a stale deployment receives 409 template_version_conflict instead of overwriting or deleting a newer version.
const { data: template } = await forward.templates.create({
name: 'Welcome garden',
alias: 'welcome',
subject: 'Welcome, {{name}} ✨',
html: '<strong>Hello {{name}}</strong>',
text: 'Hello {{name}}'
});
// Stale automation cannot overwrite newer Dashboard or CI work.
const { data: published } = await forward.templates.update(template.id, {
name: template.name,
alias: template.alias,
subject: 'A brighter welcome, {{name}} ✨',
html: template.html,
text: template.text,
expectedVersion: template.version
});
await forward.emails.send({
from: 'Mia <hello@paperkite.co>',
to: 'friend@example.com',
template: {
alias: published.alias,
variables: { name: 'Ari' }
}
}, { idempotencyKey: crypto.randomUUID() });Search, list, and inspect emails
Search the complete retained email history or synchronize references with stable cursor pagination, then fetch one ForwardHello ID to inspect its content, provider reference, RFC Message-ID, error details, and ordered event timeline. The provider reference identifies the API request inside the delivery provider; message_id identifies the actual message for mailbox threading and becomes available after a signed provider event reports it. Every list and detail query stays inside the API key's workspace and optional sending-domain scope.
/api/v1/emailsconst params = new URLSearchParams({
limit: '20',
q: 'ari@example.com',
status: 'delivered',
tag_name: 'category',
tag_value: 'welcome'
});
const response = await fetch(
`${process.env.FORWARD_BASE_URL}/api/v1/emails?${params}`,
{
headers: {
Authorization: `Bearer ${process.env.FORWARD_API_KEY}`
}
}
);
const page = await response.json();
const nextCursor = page.has_more ? page.data.at(-1).id : null;limitintegerPage size from 1 to 100. Defaults to 20.
afteremail IDLoad the next, older page after the last returned email.
beforeemail IDLoad the previous, newer page before the first returned email. Do not combine it with after.
qstringCase-insensitive search across ForwardHello IDs, subjects, senders, recipients, statuses, tags, provider references, RFC Message-IDs, and batch IDs. Percent and underscore characters are matched literally.
statusstringOptional ForwardHello status such as delivered, bounced, or failed.
tag_name + tag_valuestringsOptional exact tag pair. Provide both values together.
List responses use { object: "list", has_more, data }. Pass the last item's ID as after while has_more is true, and repeat the same search and filters on every page. Cursors are excluded from their result page and remain stable when new emails arrive. The unknown status means ForwardHello exhausted safe retries without enough evidence to call the request sent or failed.
/api/v1/emails/:idconst response = await fetch(
`${process.env.FORWARD_BASE_URL}/api/v1/emails/em_123`,
{
headers: {
Authorization: `Bearer ${process.env.FORWARD_API_KEY}`
}
}
);
const { email } = await response.json();
console.log(email.status, email.deliverability, email.events);recipients collection with each address's envelope kind, status, latest provider event type, and event time. The Dashboard shows the same outcomes beside tags and the event timeline without executing stored HTML.Share a safe snapshot
From an outbound email detail, any current workspace member can create one public share link that lasts for 48 hours. ForwardHello returns the plaintext URL once and stores only its SHA-256 token hash. Creating another link for the same email immediately replaces the previous one; Revoke ends the current link early. Both changes are attributed in Workspace Activity.
Read the delivery checkup
Every email detail also returns deliverability: nine practical checks for a plain-text alternative, reply-friendly sender, combined body size below Gmail's 102 KB clipping point, link-domain alignment, full YouTube URLs, a dedicated sending subdomain, current DMARC protection, and the branded click- and open-tracking setup. The cheerful Dashboard card shows attention items first, followed by optional improvements and passing checks.
Each insight has a stable id, category, status, title, detail, and optional suggestion, while counts supports CI or internal tooling. Message checks use retained source; tracking checks describe the sending domain's current configuration. When retention has removed content—or a provider-free test has no production domain—affected checks return unavailable instead of inventing a pass or failure.
Watch quota and delivery health from your own systems
The public observability endpoints expose the same email quota periods, Automation Run starts and active capacity, rate-limit windows, delivery aggregates, and privacy-minimized request evidence as the Dashboard. Email quota combines outbound recipients with inbound messages: every unique To, CC, or BCC recipient counts once, and every received provider message counts once. Use a workspace-wide Full access key from a trusted backend to alert before quota runs out, export delivery health, or connect one failed request to support without copying a message payload.
/api/v1/usage/api/v1/analytics?days=30/api/v1/api-logsconst usage = await forward.usage.get();
const quota = usage.data.quota.monthly;
console.log(quota.outbound_used, quota.inbound_used, quota.remaining);
const analytics = await forward.analytics.get({ days: 30 });
console.log(analytics.data.summary.delivery_rate);
const failures = await forward.apiLogs.list({
status: 'error',
search: 'provider_error',
limit: 25
});
while (failures.data.has_more) {
// Continue with failures.data.next_cursor as before.
break;
}/usage returns the current plan, sending-access state, daily and monthly or billing-cycle quota, and the active request-per-second and recipient-per-minute windows. Every quota window keeps used as the shared billable total and breaks its provenance into outbound_used and inbound_used, so spam or an unexpected receiving route cannot look like unexplained outbound traffic. Paid quota separates the ordinary included_limit from the 300-email continuity_reserve and reports continuity_used; both the plan and quota object expose the cushion without turning it into a billable overage. Its request counter includes the usage call itself. Reset values are exact ISO timestamps, so monitors do not have to infer UTC or provider billing boundaries.
Find one API exchange without exposing its payload
The Dashboard and GET /api/v1/api-logs expose successful and failed authenticated Email, Domain, Template, Audience, Broadcast, Webhook, Suppression, and Observability operations retained for up to 30 days. Search by request ID, safe resource ID, normalized route, error code, or API-key label and masked prefix; filter successes or errors; then copy the request ID into a support ticket when you need help.
/api/v1/api-logsWhen POST /api/v1/emails returns one email ID, the Dashboard email detail attaches a matching workspace-scoped single-send request and Open in API logs applies its request ID automatically. Dashboard sends and batch-created messages correctly omit the card because they do not have a one-to-one single-send API trace.
Use limit from 1 to 100, before with the returned next_cursor, literal q search, and status=all|success|error. The summary always covers the unfiltered last 24 hours. The current listing request is appended only after its response finishes, so a page cannot contain the request that produced itself.
Each row contains only the server-generated request ID, masked key identity, HTTP method, normalized route, status and safe error/resource IDs, duration, and timestamp. ForwardHello never stores the URL query, authorization value, recipients, contact email or properties, subject, custom headers, signing secret, or message body in this log.
Inspect and download attachments
ForwardHello stores attachment metadata with the email in D1 and keeps the original bytes in private R2 object storage. The dashboard shows filename, type, size, and optional content ID without rendering the file.
/api/v1/emails/:id/attachmentsThe list uses { object: "list", has_more: false, data }. Each item includes a download_url that still requires the same API-key bearer authorization; it is not a public or shareable URL.
/api/v1/emails/:id/attachments/:attachmentIdno-store, nosniff, and sandbox response headers.Keep delivery history, expire sensitive content
Workspace owners choose a 1, 7, or 30 day retention window in Data privacy. When a message crosses that window, ForwardHello permanently removes its stored subject source, HTML and text bodies, custom headers, RFC Message-ID, provider event payloads and error text that could repeat content, attachment metadata, and private R2 files. Sender and recipient envelope, status, internal provider reference, tags, timestamps, and event types remain available for delivery support and analytics.
Changing the policy rechecks the current workspace owner and exact policy version in the transaction that stores both the new window and Activity event. A stale Dashboard cannot silently replace a newer privacy choice; it loads the latest setting for review instead. Starting workspace deletion also locks the policy before that transaction can commit.
Email list responses expose content_redacted_at; email details expose contentRedactedAt. A timestamp means content deletion finished—it is not a future estimate. The Dashboard shows the same provenance as a green Content expired badge. Redaction also closes any initial customer-webhook dispatch and cancels retries that still require the expired payload.
Authenticated Email API exchanges keep a separate, abuse-capped, privacy-minimized trace for up to 30 days containing only request ID, workspace and API-key label/prefix, normalized operation, HTTP status/error code, safe resource ID, duration, and timestamp. URL queries and email payloads are never stored in this trace.
scheduled() handler for hosts that preserve Worker triggers. Verify a successful production run rather than relying on the generated config alone. OpenAI Sites currently publishes the handler but does not register the archive's trigger, so a Sites deployment must call the authenticated POST /api/internal/maintenance endpoint every minute from an external scheduler and keep its bearer credential in that scheduler's secret store. Billing, provider-account access, provider delivery, Automation Runs, Broadcast delivery, customer webhooks, service-incident fan-out, workspace notification emails, attachment recovery, domain health, exports, deletion, and retention remain isolated so one failure cannot stop the others. One native event performs up to three bounded passes while has_more remains true and fails visibly if work still needs another minute. The provider-account check makes a read-only Resend domain request at most every five minutes, proving the key still has Full access before paid signup can remain open. Retention runs daily or continuously while a message-content or API-trace backlog remains; any failed or attention-required worker returns HTTP 503 for monitoring.Grab one clean CSV or take the whole workspace
In Data privacy, an Owner or Admin can search and filter Emails, Broadcasts, Contacts, Segments, Domains, API Logs, API Keys, or Suppressions, then immediately download the newest 1,000 matching rows. Email and Broadcast exports contain operational metadata but never message bodies. API-key exports contain labels and masked prefixes but never plaintext secrets, hashes, previous-secret material, or domain-verification tokens.
Every quick export is generated only after server-side membership, legal acceptance, Owner/Admin authority, same-origin, and active-workspace checks. The SQL begins with the active workspace ID, the response is no-store and nosniff, and the Activity record stores the resource, row count, truncation flag, and whether a search was used—not the search text itself.
ForwardHello emits UTF-8 CSV with a byte-order mark for spreadsheet compatibility, quotes every cell, and prefixes values that spreadsheet software could interpret as a formula. If more than 1,000 rows match, the Dashboard says the result contains the newest 1,000 rather than silently presenting it as complete.
The Billing page has a stricter Owner-only billing reconciliation CSV. It joins payment and invoice-job metadata but excludes provider references, operator identities, failure or void notes, tax IDs, carrier or donation codes, and invoice-recipient email. Every row is marked reconciliation_record_not_electronic_invoice; the file supports reconciliation and is not a statutory electronic invoice or tax document.
Read real outcomes, not vanity numbers
/api/v1/analytics?days=30The Dashboard and public Analytics API group recipients by their email's UTC send date. Delivery rate is calculated only from known terminal recipient outcomes: delivered ÷ (delivered + bounced + complained + failed). Queued, scheduled, and sent recipients remain “moving”; canceled schedules are reported separately. Unconfirmed unknown recipients need attention but stay out of the delivery-rate denominator until a provider event resolves them.
Open and click metrics are off by default for every domain. An Owner or Admin may enable either signal independently, choose one DNS label such as links, publish the provider's Tracking CNAME, and recheck DNS. Analytics explicitly says when no tracking hostname is active instead of presenting an unexplained zero. Open tracking inserts a tiny image and remains approximate; click tracking rewrites HTML links through the verified branded hostname. Delivery outcomes do not depend on either metric.
Open and click rates remain request-level because engagement events identify the email, not an individual inbox. ForwardHello deduplicates by email request and divides by requests with at least one delivered or complained recipient. Repeat opens and clicks remain visible as raw event counts for debugging, but they never inflate the unique rate. Top tracking tags connect these delivery numbers back to product categories, customers, or orders.
Grow contacts without losing their choices
Audience stores one contact per lowercase email address across the workspace. Each contact can keep a first and last name, up to 25 string, number, boolean, or null custom properties, several internal segments, explicit topic preferences, and a global marketing unsubscribe. The global unsubscribe always overrides a topic opt-in; it does not block account receipts, password resets, or other transactional messages.
Segments are internal groups for future Broadcast targeting and are never shown to recipients. Topics are preference choices with a public or private visibility and a permanent opt_in or opt_out default. ForwardHello stores the source and time of subscription changes so the current state has useful provenance.
Dashboard CSV import. Owners and admins can upload a valid UTF-8 CSV up to 1.5 MB, 50 columns, and 10,000 data rows. Review the email, name, and unsubscribe mapping before commit; unassigned columns can become up to 25 custom string properties. Duplicate email rows merge with the safest unsubscribe state, and the import can add one Segment and one Topic preference. You must confirm marketing permission. Existing global unsubscribes and explicit Topic opt-outs can never be turned back on by an import.
/api/v1/contacts/api/v1/contacts/:id/api/v1/segments/api/v1/topicsconst { data: contact } = await forward.contacts.create({
email: 'ari@example.com',
firstName: 'Ari',
properties: { plan: 'sprout', trialDays: 14 },
segments: ['seg_customers'],
topics: [{ id: 'top_product_news', subscription: 'opt_in' }]
});
await forward.contacts.update(contact.id, {
unsubscribed: true,
expectedUpdatedAt: Date.parse(contact.updated_at)
});Contact lists accept limit, after, q, status, and segment_id. Segment and topic lists return at most 100 current definitions. A contact update replaces the supplied segment and topic collections; omit either field to keep it unchanged.
403 domain_scoped_key_not_permitted because contacts are shared across the workspace, not owned by one sending domain.Authenticate a sending domain
ForwardHello uses two explicit gates. First, publish forward-verification=… at _forward.your-domain to prove workspace ownership. After that passes, ForwardHello creates the provider domain and shows every required SPF, DKIM, optional MX, and optional Tracking record with its live status. The domain moves from pending to configuring, and becomes verified—and sendable—only when the provider reports that email DNS is verified.
Before that provider setup, choose a bounce Return-Path label. ForwardHello defaults to send, producing send.your-domain; choose a different 1–63 character label such as outbound when another email service already uses that hostname. This identity carries SPF alignment and provider bounce handling, so it is stored with the domain, sent as custom_return_path, shown in every Domain response and export, and cannot be changed after DNS provisioning. Delete and re-add the domain to choose a different label.
DMARC is visible guidance, not a third send gate. After ownership, ForwardHello checks _dmarc on the exact sending domain and—using the public suffix list—the correct organizational domain. It follows inherited sp policy and pct rollout, then reports missing, invalid, monitoring, partial, enforced, or temporarily unavailable state. When no policy exists, the Dashboard offers v=DMARC1; p=none; as a non-enforcing starting point; add an aggregate-report mailbox and review every legitimate sender before moving gradually to quarantine or reject.
If Resend reports that the domain is already registered to another account, ForwardHello opens a guarded provider_claim workflow instead of returning an opaque dead end. The private operator queue records the exact claim TXT from the Resend Dashboard; the workspace publishes and verifies that record through the same Domain verify endpoint; and only a current ForwardHello operator can retry provider provisioning after the Resend transfer is complete. The API exposes pending_operator, waiting_for_dns, dns_verified, and completed without exposing operator credentials. Keep existing provider DNS in place until ForwardHello returns the replacement records, and warm up the new traffic path rather than moving full volume at once.
/api/v1/domains/api/v1/domains/:id/api/v1/domains/:id/receiving/api/v1/domains/:id/verifyconst { data: domain } = await forward.domains.create({
name: 'mail.example.com',
region: 'us-east-1',
customReturnPath: 'outbound',
receivingEnabled: true
});
console.log(domain.verification_record);
// Repeat after publishing each DNS stage; reconciliation is retry-safe.
const { data: verification } = await forward.domains.verify(domain.id);
console.log(verification.outcome, verification.domain.status);
// Operational kill switch; safe to retry after an uncertain response.
await forward.domains.updateReceiving(domain.id, { receivingEnabled: false });
await forward.domains.updateReceiving(domain.id, { receivingEnabled: true });List and resource responses expose the ownership TXT, immutable custom_return_path and full return_path_hostname, provider DNS records, receiving and tracking settings, the latest bounded DMARC observation and gentle suggested record, provider observation time, and current sendability without exposing another workspace. Tracking updates and verification read the provider result back before returning. A verification call may advance only one required gate, so repeat it after publishing the newly returned records; provider adoption and reconciliation make an uncertain retry safe.
Engagement configuration is provider-backed rather than a decorative Dashboard switch. ForwardHello reads the provider response back after every update, safely reconciles an uncertain retry, saves the resulting CNAME and settings with an Activity event, and observes later signed or scheduled domain state. After a tracking subdomain exists it is retained when metrics are disabled; changing it requires new DNS, and the previous CNAME should remain so links already sent keep working.
Choose one provider-backed sending region before creating the domain: North Virginia (us-east-1), Ireland (eu-west-1), São Paulo (sa-east-1), or Tokyo (ap-northeast-1). The selection controls outbound routing, not where ForwardHello stores account or message data. A provider response or signed event that reports a different region or discernible Return-Path locks sending instead of silently changing its delivery identity. To move regions or Return-Path, delete and re-add the domain with its new DNS records; use distinct subdomains when one product needs multiple regions.
Verification is not treated as permanent. ForwardHello accepts signed domain.created, domain.updated, and domain.deleted provider events for immediate state changes, then checks every verified domain again at least hourly. That scheduled pass refreshes DMARC independently; a DNS lookup outage is shown as unavailable but does not change SPF/DKIM sendability. Aggregate partially_verified or partially_failed state caused only by the optional Receiving MX does not lock sending while the provider still reports sending enabled and every SPF/DKIM record verified. A sending-record failure, temporary_failure, failed, deletion, or missing provider record locks new sends. A later healthy observation unlocks it automatically. Older webhook replays cannot overwrite a newer check.
A temporary provider API outage does not revoke an otherwise verified domain. ForwardHello keeps the last known sending state, retries the control-plane check after five minutes, marks the domain-monitor worker unhealthy, and pauses new paid signup until monitoring recovers.
Adding a domain rechecks the current Owner or Admin—or the exact unscoped Full access API key—exact workspace plan, available domain allowance, and platform-wide domain claim in the transaction that writes both the domain and its Activity event. One exact sending domain can belong to only one ForwardHello workspace at a time, including during concurrent requests. To move it, delete it from the current workspace first; the conflict response never reveals that workspace's identity or members.
Deleting a domain removes its delivery-provider configuration before freeing the ForwardHello plan slot. Revoke every active API key scoped to that domain, pause or reconnect every enabled Automation using it as a sender, and wait for queued provider deliveries first. A tracking subdomain is also removed at the provider, which can break links already sent unless the customer first supplies its own proxy. After provider cleanup, ForwardHello rechecks the current administrator—or exact unscoped Full access API key—and all three dependencies, then deletes the local domain and writes its Activity event together. If access or dependencies change during cleanup, the local record remains; an authorized caller can retry safely because provider deletion accepts an already-missing domain.
Manage signed endpoints from your deployment pipeline
Create and reconcile customer event endpoints from the Dashboard, public API, or official SDK. The collection supports stable cursor pagination, literal endpoint search, and enabled/disabled filtering; every resource or secret write requires the exact latest updated_at.
/api/v1/webhooks/api/v1/webhooks/:id/api/v1/webhooks/:id/secret/api/v1/webhooks/:id/deliveries/api/v1/webhooks/:id/test/api/v1/webhooks/:id/deliveries/:deliveryId/retryconst created = await forward.webhooks.create({
endpoint: 'https://hooks.paperkite.co/forward',
events: ['email.delivered', 'email.bounced']
});
// Save the active secret now; it is returned only once.
await saveSecret(created.data.signing_secret);
const pending = await forward.webhooks.rotateSecret(
created.data.id,
created.data.updated_at
);
// Deploy pending.data.signing_secret before promoting it.
await forward.webhooks.activateSecret(
pending.data.id,
pending.data.updated_at
);
const diagnostic = await forward.webhooks.sendTest(created.data.id);
console.log(diagnostic.data.status, diagnostic.data.response_code);
const failures = await forward.webhooks.listDeliveries(created.data.id, {
status: 'failed',
event: 'email.bounced',
limit: 25
});
if (failures.data.data[0]) {
await forward.webhooks.replayDelivery(
created.data.id,
failures.data.data[0].id
);
}Creation returns the active signing secret exactly once. A secret-rotation POST likewise returns its pending plaintext only after encrypted storage and Activity evidence commit; later list and resource responses expose transition timestamps but never secret material.
Updating a destination or event set starts a fresh delivery epoch, clears stale health state, and cancels retries bound to the prior configuration. Enabling rechecks the current plan allowance. Deletion permanently removes encrypted secret material, attempt history, and queued retries.
Delivery history accepts limit, a stable after cursor, succeeded/failed status, and exact event filters. Responses contain only safe attempt metadata—IDs, event type, status, response code, latency, attempt number, bounded error text, and time—never the event body, recipient address, signature, signing secret, or request headers.
Signed diagnostics and manual replays share a five-operation-per-minute workspace guard in addition to the ordinary API request limit. A diagnostic stays outside real event history and production health. A replay keeps the original event ID, event time, and JSON body, creates a fresh signature and attempt record, stops a matching automatic retry after success, and refuses more than 20 attempts for one logical event.
Every write rechecks the current administrator—or exact unscoped Full access key—and exact endpoint version in the same transaction that commits its Activity event. A revoked key, concurrent Dashboard edit, plan change, or newer secret transition wins instead of being silently overwritten.
Give every API key only what it needs
Keys are shown once and stored by ForwardHello only as SHA-256 hashes. Send them as Authorization: Bearer fw_live_…, keep them server-side, and use a unique Idempotency-Key when retrying a single or batch send.
/api/v1/api-keys/api/v1/api-keys/:idconst keys = await forward.apiKeys.list({ limit: 50 });
const oldKey = keys.data.data.find((key) => key.name === 'Production worker');
if (oldKey) {
await forward.apiKeys.update(oldKey.id, {
name: 'Production worker · restricted',
permission: 'sending_access',
domainId: 'dom_0123456789abcdef01234567',
expectedUpdatedAt: oldKey.updated_at
});
}
const replacement = await forward.apiKeys.create({
name: 'Production worker · 2026-07',
permission: 'sending_access',
domainId: 'dom_0123456789abcdef01234567'
});
// Store replacement.data.key now; Forward never returns it again.
await saveServerSecret('FORWARD_API_KEY', replacement.data.key);
await deployAndVerify();
if (oldKey) await forward.apiKeys.delete(oldKey.id);Bootstrap the first Full access key in the Dashboard. Trusted deployment automation can then list active credentials with stable limit/after pagination, create a replacement, store its one-time key, verify the new deployment, and revoke the old ID. List responses contain only ID, name, display prefix, permission, optional domain scope, last use, settings version, and creation time—never plaintext, hashes, member email addresses, or rotation material.
Change settings without changing the secret
Choose Edit—or call PATCH /api/v1/api-keys/:id—to rename a key, move it between Sending and Full access, add one domain scope, or remove that scope. Send the exact latest updated_at as expected_updated_at. ForwardHello commits the settings and Activity evidence together only while the Owner, Admin, or exact unscoped Full access caller still has authority. An identical retry returns the current key without a duplicate Activity entry; a stale conflicting edit returns 409 api_key_state_changed. Restricting the calling key itself succeeds once and takes effect immediately, so use another management key before attempting a later change.
Choose a permission boundary
Sending access is the safe default for application workers. It can create single and batch sends, including sends that reference a known template and a schedule supplied with the original request, but it cannot list or inspect sent email, read inbound mail, download attachments, reschedule, cancel, or manage API Keys, Domains, Templates, Audience, Broadcasts, Webhooks, Suppressions, or Observability. Those operations return 403 restricted_api_key before rate limits or customer data are touched.
Full access can use every public Email API operation plus API Keys, domains, templates, contacts, segments, topics, Broadcasts, Webhooks, Suppressions, Usage, Analytics, and API Logs. Use it only for trusted backend jobs that genuinely need message content, credential lifecycle, sending infrastructure, reusable content, audience profiles, signed event delivery, recipient safety data, or workspace-wide operational telemetry. Both permissions can be restricted to one verified domain for email operations; a domain-scoped key cannot access workspace-wide API Key, Domain, Template, Audience, Broadcast, Webhook, Suppression, or Observability resources. Existing keys from before this boundary retain their prior capabilities as Full access so an upgrade does not silently break production; create replacement Sending access keys to reduce privilege.
Rotate a key without downtime
Choose Rotate in the API Keys view and immediately save the one-time replacement secret. ForwardHello keeps the replacement and previous secret valid for 24 hours under the same key identity, permission, domain scope, and plan slot. Deploy the replacement, make one successful authenticated request, then choose Retire old now. ForwardHello refuses early retirement until the replacement has actually authenticated.
If deployment goes wrong, choose Roll back before the cutoff. The previous secret becomes current again and the replacement stops authenticating immediately. Starting, finishing, and rolling back each recheck the current Owner or Admin and exact transition state in the same transaction that writes its Activity event. After 24 hours the old secret is rejected on the authentication path even if maintenance has not run yet; minute-level maintenance permanently clears its hash and prefix.
A Dashboard-created key records its Owner or Admin creator. ForwardHello refuses to remove that person's workspace access while one of those keys is still active, so offboarding cannot silently leave an unowned machine credential. API-created keys record the exact calling key instead of exposing a member identity. Revoke or deliberately replace credentials before offboarding. Historical keys show a human creator only when the original audit event can be matched.
Creating, updating, or revoking a key writes the credential state and its Activity event in one transaction. The same commit rechecks the current Owner or Admin—or the exact active, workspace-wide Full access key; creation also rechecks the workspace plan and optional domain. A stale browser or automation request therefore cannot create, change, or revoke a machine credential after its authority changed, and a successful credential change cannot exist without its audit record. Authentication also rejects unknown permission values and rechecks the exact current or unexpired previous secret when recording use.
Request and recipient limits are scoped to the workspace, not an individual key, so splitting traffic across credentials never bypasses a guardrail. The API Keys view shows the current windows plus recent blocked requests.
Listen for delivery events
Subscribe to every event or select only the lifecycle, delivery, engagement, and suppression events your receiver needs. Every customer event includes the original email tags and a nullable RFC message_id; it stays null until an authenticated provider event supplies the identifier, and after content retention clears it. ForwardHello signs the exact raw ${timestamp}.${rawBody} bytes with HMAC SHA-256 and sends the result as forward-signature: v1=…. Reject stale forward-timestamp values and compare signatures in constant time; parsing and re-stringifying JSON before verification changes the signed bytes.
import {
verifyWebhook,
WebhookVerificationError
} from '@forward-email/sdk';
export async function POST(request: Request) {
const rawBody = await request.text();
try {
const event = await verifyWebhook({
payload: rawBody,
headers: request.headers,
secret: process.env.FORWARD_WEBHOOK_SECRET!
});
console.log(event.id, event.type, event.data.email_id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response('Invalid webhook', { status: 400 });
}
throw error;
}
}verifyWebhook authenticates the untouched bytes, accepts only a five-minute timestamp window by default, compares signatures without early byte exits, validates the event envelope, and checks that its signed ID matches forward-id.email.sent, email.delivered, email.bounced, email.complained, email.failed, or email.suppressed event for a multi-recipient message must identify the affected address through recipient, email, address, or a to/cc/bcc array. ForwardHello rejects ambiguous or mismatched events instead of guessing.Rotate a signing secret without downtime
Generate a pending secret from the Webhooks view and copy it immediately—ForwardHello stores only encrypted ciphertext. While the active secret continues signing forward-signature, the same live request includes forward-signature-pending signed with the pending secret. Call the same helper with signature: "pending" and the new secret to validate that header against the same timestamp and raw body, deploy support for it, then activate it.
After activation, forward-signature switches to the new secret. For 24 hours, ForwardHello also supplies the old signature in forward-signature-previous; verify it with signature: "previous" only until the Dashboard cutoff. Store that cutoff locally when you activate the rotation and remove the old secret on schedule. Never trust a webhook header to extend the deadline: anyone holding the old secret could forge ordinary headers.
Generating, activating, or canceling a rotation rechecks the current Owner or Admin and exact encrypted-secret version in the transaction that commits the ciphertext and Activity event. A stale request cannot replace a newer pending secret, and plaintext is returned only after durable storage succeeds.
Change or remove an endpoint
Admins can edit the public HTTPS URL and event subscription from the Webhooks view. Creating or re-enabling an endpoint rechecks the current administrator, plan, and enabled-endpoint allowance at commit time. Editing, pausing, and deleting require the expected current endpoint version. Each successful edit or re-enable starts a fresh delivery epoch and commits its Activity event plus retry cancellation together. An event that occurred under an older configuration is finalized without being redirected to the new URL or subscription, even if its initial failure record races with the change. Deleting an endpoint permanently removes its signing secret, attempt history, and queued retries, then frees the plan slot.
Automatic retries
Every committed email event first enters a durable dispatch outbox. ForwardHello attempts it immediately, and the minute-level worker reclaims an event if the first dispatch never started or its local completion write failed. A non-successful response or timeout then enters the durable retry queue. ForwardHello makes up to seven attempts: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and 10 hours. The Webhooks view shows the next scheduled attempt. Pausing an endpoint cancels its pending work, and a successful manual replay stops the matching automatic job.
ForwardHello marks the endpoint degraded only after all seven automatic attempts fail, then creates one critical workspace notification for that outage. More failed events do not repeat the alert while the endpoint stays degraded. The next successful real delivery or manual replay marks it healthy and creates one recovery update; a diagnostic test does not change production health.
Inspect and replay attempts
The Webhooks view and public Webhook Operations API record the response code, latency, bounded error, and attempt number for each delivery without returning stored event payloads. After fixing a receiver, replay an attempt from the Dashboard or official SDK: ForwardHello keeps the original event ID, event time, and JSON body, then creates a fresh request timestamp and signature.
id and return a successful response for duplicates. A retry is intentionally the same logical event, not a new event.Respect bounces and complaints
When a verified provider event reports email.bounced, email.complained, or email.suppressed, ForwardHello adds only the affected recipient to the workspace suppression list. Future sends fail with 422 recipient_suppressed before rate limits, monthly quota, or provider calls are consumed. Workspace owners and admins can also add or remove suppressions manually.
Every outbound email detail privately matches its To, CC, and BCC recipients against the active workspace suppression list. The bright Recipient Safety card distinguishes permanent bounce, spam complaint, provider suppression, and manual protection; shows whether this email produced the latest signal; and gives cause-specific repair guidance. Members can inspect the evidence, while an Owner or Admin must review and confirm a second time before reopening future sends.
Bring an existing provider blocklist first
Before moving production traffic, an Owner or Admin can open Suppressions and import a UTF-8 CSV containing an email, email_address, recipient, or address column. ForwardHello previews up to 10,000 rows or 1.5 MB, normalizes capitalization, merges duplicates, and refuses the entire import until every row is valid. The confirmed list commits atomically, so a partial blocklist cannot slip through during migration. The raw CSV is not retained; normalized suppressions and one count-only Activity summary remain.
Imported addresses use the manual reason because an external provider's cause was not verified by ForwardHello. That honest label still blocks every future send. Re-importing never replaces an active ForwardHello-verified bounce, complaint, or provider suppression; a previously removed address is restored as a manual safeguard.
/api/v1/suppressions/api/v1/suppressions/:idconst { data: blocked } = await forward.suppressions.create(
'bounced@example.com'
);
const { data: page } = await forward.suppressions.list({
reason: 'bounced',
search: '@example.com',
limit: 50
});
console.log(page.data, page.next_cursor);
// Use the exact latest timestamp so a newer provider event wins the race.
await forward.suppressions.delete(blocked.id, blocked.updated_at);The list accepts limit, after, literal email search through q, and a reason filter for bounced, complained, suppressed, or manual. Follow next_cursor while has_more is true to migrate or reconcile a large workspace without skipping equal timestamps.
Dashboard and API changes recheck the current administrator—or exact unscoped Full access key—and exact suppression version in the same transaction that commits the Activity event. Creating an address that is already blocked leaves its automatic bounce, complaint, or provider evidence intact. Deletion requires the latest updated_at; if a provider event arrives while stale automation is trying to allow sends, the stale removal is rejected instead of erasing the newer safety signal (409 suppression_changed).
Automatic reputation guardrails
ForwardHello measures accepted recipients and distinct bounce or complaint suppressions in a rolling 30-day window. Active sending pauses at a 4% bounce rate after at least 100 accepted recipients, a 0.08% complaint rate after at least 100 accepted recipients, or a severe 20% bounce rate after at least 25 accepted recipients. Lower-volume workspaces therefore are not suspended by one ordinary bounce, while a clearly unsafe list still stops early.
The Analytics view shows the exact observation window, counts, rates, and risk state. Production approval or operator reinstatement starts a clean baseline. Removing a suppression permits future sends only when that is legitimate; it does not remove the original signal from the current reputation window.
Share a workspace without sharing secrets
The owner invites a sign-in email and chooses Admin or Member. ForwardHello trims and lowercases identity-provider email addresses, rejects malformed identities, and matches invitations without case sensitivity. Admins manage domains, API keys, webhooks, template publishing, and recipient safety settings. Members can send and inspect email, preview templates, and view workspace results without changing shared configuration. Only the owner can change teammates or billing.
Creating an invitation rechecks the current Owner, plan, and seat limit at commit time, then writes the pending seat, activity event, and invitation notification in one transaction. That invitation is private to the intended sign-in email and its service email links back to the exact workspace's Members view. Other members cannot list or mark it read. The seat becomes active only when the invited identity signs in. Until then it cannot open workspace data, receive general workspace update emails or sandbox messages, or become owner. Removing the pending seat cancels queued or retrying invitation delivery. A message already accepted by the delivery provider may still arrive, but its workspace link still refuses access.
Changing a role or removing a teammate sends the exact membership version shown in the Dashboard. The transaction rechecks that version, the current Owner, workspace lifecycle, and active API-key ownership before it commits access and Activity together. A stale Members tab receives the latest teammate state instead of overwriting it, and an Owner who just transferred control cannot finish a queued access change.
Ownership can be transferred only to an active teammate. The handoff atomically promotes the new owner and keeps the previous owner as an Admin, so concurrent requests cannot leave the workspace ownerless or create two owners. The activity log records both people. Member limits include pending invitations and the owner: Free supports 1 seat, Sprout 3, and Bloom 10.
An Admin or Member can leave a workspace without waiting for its owner. ForwardHello removes identity access and records the departure in one transaction, then selects another accepted workspace or clears the workspace cookie. An owner must transfer ownership or delete the workspace first. Machine API keys are independent workspace credentials: leaving never copies or silently revokes them, so the remaining owner should rotate any credential an offboarded person may have stored.
A person who belongs to more than one ForwardHello workspace can switch safely in the dashboard; every request rechecks that signed-in identity's accepted membership before loading data.
Give every shared surface one current name
The workspace Owner can choose a 2–80 character Unicode display name in Workspace settings. The name appears in workspace switching, teammate invitations, service notifications, support context, owner exports, and permanent-deletion confirmation. Authorization, API keys, billing, and retained data remain attached to the immutable workspace ID, so renaming never moves or duplicates customer data.
Each change sends the exact name and profile version shown in the Dashboard. ForwardHello rechecks current ownership and that the workspace is not closing, then commits the new name, Activity event, and team notification together. If another session renamed it first, the stale page adopts the latest profile for review instead of overwriting it.
Keep help attached to the right workspace
The Support Center gives every signed-in member one workspace-scoped conversation history. Free includes documentation and live service status. Sprout can open tracked tickets, and Bloom tickets receive a priority snapshot when they are created so the operator queue handles them first. A workspace may keep up to five unresolved tickets open at once.
Add the req_… request ID returned in ForwardHello's API response whenever possible. Workspace members can first inspect that authenticated exchange in API Logs; the operator console can resolve a retained ID to the same workspace's normalized operation, status/error code, API-key label, safe resource ID, duration, and timestamp. Traces are abuse-capped and expire within 30 days; they never store URL queries, recipients, subjects, headers, or message bodies. Subjects and replies are stored as plain text and rendered as text in the Dashboard.
Any current member can reply to an unresolved ticket, including a ticket opened before a downgrade. The creator, Owner, or Admin may resolve it. Each reply and resolution carries the exact updatedAt shown in the conversation. ForwardHello rechecks current membership, workspace lifecycle, resolve permission, and that version in the D1 transaction that commits the message or status change; creation and resolution also commit their Activity record with that state. If another person replied first, the stale request returns the latest conversation for review instead of overwriting it.
Resolved transcripts remain in workspace history, are included in an owner's private workspace export, and are erased with permanent workspace deletion.
FORWARD_OPERATOR_EMAILS with exact trusted sign-in emails, then open /operator for the priority-first support inbox, sending-access review queue, and private Operations view. New paid support conversations, customer replies, production-sending requests, automatic reputation suspensions, and provider-specific tax incidents atomically queue one private email per roster identity through the same durable notification worker. Alerts contain safe queue context rather than support messages, recipient addresses, or credentials, link directly to the correct console view, and cancel before delivery if that address leaves the roster. They are excluded from customer notification lists and workspace exports. Operations lists any active provider tax incident with its affected workspace and subscription, then maps every durable worker to its last run, last success, processed count, remaining backlog, and truncated failure summary. A roster-authorized operator can run one bounded recovery pass from that view during private-preview setup or incident recovery; it refreshes the cards but cannot create the independent public-access attestation and never replaces the required minute-level companion scheduler. The public status page remains authoritative for configuration and freshness. Page rendering, queue reads, and writes each recheck the server-side allowlist; the browser never receives either maintenance or operator bearer secrets. Scripted operators may instead configure FORWARD_OPERATOR_SECRET and use GET/PATCH /api/internal/support. The console sends each ticket's current updatedAt as expected_updated_at; the reply, notification, status, and Activity event then commit together./operator?view=launch view turns the same production health result that gates Checkout into 24 ordered launch checks across platform foundation, delivery, commerce, and customer safety. It shows configuration field names and bounded recovery guidance, never their values. A missing configuration remains distinct from a configured component that needs recovery, and the view cannot claim paid signup is open unless every health component is operational.See important changes before they interrupt sending
The Dashboard notification bell keeps durable workspace updates for ForwardHello Support replies, sending-domain and customer-webhook failures and recoveries, public service incidents, production sending decisions, profile changes, billing changes, quota milestones, private teammate invitations, and the member's one-time real-inbox onboarding test. Each update links directly to the relevant Dashboard view or the public service-status timeline so the next action stays close to the event.
Every operator-published incident entry atomically creates an immutable fan-out job. Minute-level maintenance visits only workspaces that existed at publish time in bounded ID order, commits its cursor with each page, and uses a deterministic notification ID per workspace and update. A crash therefore resumes without skipping the remaining customers or duplicating notices already written. Opening the notice marks it read and moves to /status; resolving an incident creates a separate recovery notice. New paid signup remains paused while an incident is open or this fan-out has backlog.
ForwardHello creates one warning when daily or monthly usage reaches 80% of its current window, a separate paid-plan warning when the included allowance enters its no-charge continuity cushion, then a critical update if the hard limit is reached. Calendar months, UTC days, and signed provider billing cycles each have their own dedupe key, so dashboard polling cannot repeat the same alert and a new reset window can notify again. Billing-provider subscription changes target the current Owner: past-due, paused, unpaid, and canceled states explain the affected entitlement and link to Billing; a later active state confirms recovery. The subscription, workspace plan, and notification commit together, and a signed callback replay cannot send the same warning twice.
Workspace update emails are enabled for each active member by default and can be turned on or off independently from the notification bell. General notification rows atomically queue one email per enabled active member, while a pending seat receives only its private invitation. A stable provider idempotency key prevents duplicates, temporary failures back off to a daily retry, and these service messages never consume customer quota. The onboarding inbox test reuses that lane with deterministic member-and-workspace identity, fixed safe content, the exact signed-in recipient, and an immediate best-effort attempt before normal maintenance recovery. Turning email updates off cancels unfinished deliveries without changing another teammate's preference.
Unread state belongs to the signed-in member. Marking an update read never clears it for another teammate, while every lookup and mutation is scoped to the active workspace. Notifications contain safe operational summaries—not API keys, webhook secrets, authorization headers, passwords, or customer email message content.
Upgrade safely with PAYUNi
Taiwan-first workspaces start a PAYUNi-hosted recurring-payment page from Billing. ForwardHello opens new paid signup only while the live readiness check confirms the public origin, legal operator, fixed tax-inclusive TWD catalog, verified invoice workflow, provider capacity, PAYUNi live store and every recovery worker. A private-preview or degraded deployment returns 503 public_sales_unavailable before legal acceptance is recorded or PAYUNi is contacted. Browser return pages never grant paid limits.
FORWARD_BILLING_TAX_MODE=inclusive confirms that it is the customer total. A mismatch returns 503 billing_catalog_mismatch before legal acceptance or a provider request.FORWARD_BILLING_INVOICE_PROVIDER identifies the reviewed workflow and FORWARD_BILLING_INVOICE_READY=true may be set only after it has been tested end to end. The Owner saves an immutable recipient version before PAYUNi Checkout; a successful signed payment atomically creates a separate pending invoice job, and only a verified invoice-provider response may mark it issued. PAYUNi payment success never claims to be a tax invoice.FORWARD_PROVIDER_MONTHLY_CAPACITY no higher than the confirmed delivery ceiling. D1 counts open entitlements and recoverable subscriptions, then atomically reserves only the remaining increase before Checkout. Competing purchases cannot spend the same capacity.FORWARD_PUBLIC_ORIGIN supplies trusted Dashboard, Notify and return URLs. FORWARD_PUBLIC_SALES_ORIGIN is a separate exact launch confirmation after anonymous production testing; neither value is inferred from an incoming Host header.The optional Stripe adapter remains available for a future overseas entity. Its live Price, tax, Billing Portal, signed Subscription reconciliation, plan-change reservation and refund controls remain provider-specific; selecting Stripe does not weaken the same legal, capacity and fail-closed boundaries.
Downgrade without losing configuration
ForwardHello never deletes domains, active API keys, enabled webhooks, templates or members because a paid plan ends or becomes smaller. The Dashboard shows any resources above the current entitlement and pauses only new sends until the Owner upgrades or removes the excess. Work that already reserved quota remains eligible for durable delivery recovery.
Open paid signup only after the public hostname passes
Configure FORWARD_PUBLIC_ORIGIN as the customer-facing HTTPS origin. Verify the landing page, sign-in, Dashboard, Checkout cancellation and return, and public status page in an anonymous browser on that hostname. Only then set FORWARD_PUBLIC_SALES_ORIGIN to the exact same normalized origin. A missing, stale, or mismatched confirmation keeps Checkout closed with 503 public_sales_unavailable before legal evidence is recorded or the selected billing provider is contacted; changing the canonical hostname locks paid signup again.
/api/health anonymously. Checkout also requires that response to be a recognizable ForwardHello health result for the exact confirmed origin within the last ten minutes. A sign-in gate, redirect, network failure, invented response, stale observation, or old hostname fails closed and makes the scheduled run visible as failed.Keep payment, tax and invoice evidence distinct
Taiwan PAYUNi plans use the fixed tax-inclusive TWD totals shown before Checkout. ForwardHello does not treat a successful recurring charge, signed Notify callback or Dashboard payment-history row as an electronic invoice. Public paid signup stays closed until both FORWARD_BILLING_INVOICE_PROVIDER and FORWARD_BILLING_INVOICE_READY=true identify and confirm a separately tested Taiwan invoice or e-invoice workflow.
PAYUNi payment reconciliation and invoice issuance remain separate operational responsibilities. The payment history stores only the minimal Workspace-scoped reference, result, amount, currency, billing period and provider time needed for customer support and reconciliation; any statutory invoice is delivered through the independently verified invoice process disclosed at purchase.
Test signatures before live traffic
ForwardHello also verifies upstream delivery identity before creating customer events. Provider message IDs are globally unique, duplicate provider IDs inside a batch are rejected, and a callback that carries both a ForwardHello ID and provider ID must resolve to the same stored email before status, suppression, or quota state changes.
Outbound webhook safety is checked on every live delivery, diagnostic, manual replay, and automatic retry—not only when the endpoint is saved. ForwardHello resolves both A and AAAA records, follows bounded CNAME chains, refuses any non-public answer, and never follows an HTTP redirect.
Use Send test in the Dashboard or forward.webhooks.sendTest(id) from trusted automation to deliver a synthetic event from the endpoint's actual subscription with the real active signature and any pending or unexpired previous signature. ForwardHello returns the receiver's status code and latency immediately.
Broadcast without outrunning consent
Every Broadcast draft targets one internal segment and one recipient-facing topic. The Dashboard includes a safe Markdown editor with a live email preview, while the API and SDK accept either Markdown or raw HTML and text. Contact email, first name, last name, and custom-property variables support optional fallbacks. ForwardHello automatically adds a preference link when the template does not place {{{FORWARD_UNSUBSCRIBE_URL}}} itself.
/api/v1/broadcasts/api/v1/broadcasts/:id/api/v1/broadcasts/:id/sendconst { data: draft } = await forward.broadcasts.create({
name: 'July product garden',
segmentId: 'seg_customers',
topicId: 'top_product_news',
from: 'Mia <hello@paperkite.co>',
subject: 'A bright update for {{{contact.first_name|friend}}}',
contentMode: 'markdown',
markdown: '# Hello {{{contact.first_name|friend}}}! ✨\n\nA small update for you.'
});
await forward.broadcasts.send(draft.id, {
scheduledAt: '2026-07-20T10:00:00.000Z'
});Starting a send atomically snapshots contacts who still belong to the segment, are globally subscribed, are opted into the topic, and are not suppressed. ForwardHello reserves the full recipient quota before the first batch leaves. The delivery worker rechecks those conditions before every recipient enters the existing Email pipeline, where provider retries, logs, reputation, suppressions, and customer webhooks stay authoritative.
Every Broadcast list and detail response includes live counts for pending, sent, delivered, failed, canceled, bounced, complained, opened, clicked, and unsubscribed recipients. Opens and clicks count each recipient once even when the provider reports repeated events. Unsubscribes count each contact once when this Broadcast's signed preference link records either a global unsubscribe or a topic opt-out. The Dashboard turns the same workspace-scoped counts into delivered, open, click, and unsubscribe rates.
List-Unsubscribe and List-Unsubscribe-Post headers for RFC one-click handling. Topic changes and global unsubscribes append immutable preference events before current state changes.Turn replies into product workflows
Enable receiving when an Owner or Admin adds a domain, or change it later from Domains, forward.domains.updateReceiving(id, input), or PATCH /api/v1/domains/:id/receiving. ForwardHello reconciles the delivery-provider capability first, records the exact admin or Full access key in Activity, and shows the returned Receiving MX record beside SPF and DKIM. Enabling may temporarily make the provider's aggregate domain state partial; already-verified SPF and DKIM remain sendable while the inbound MX is pending.
When the provider sends a signed email.received event, ForwardHello accepts it only if every destination maps to exactly one provider-backed domain. A paused match is acknowledged without retention. Each new message for an enabled domain reserves one email from the same daily and monthly or billing-cycle allowance as outbound recipients. The stable provider email ID becomes one idempotent local rx_* resource and one durable customer-webhook event, so exact provider retries neither create a second message nor spend quota twice. Unmatched or ambiguous recipients are rejected instead of being assigned by guesswork.
const { data: page } = await forward.receiving.list({
search: 'reply',
limit: 20
});
const received = await forward.receiving.get(page.data[0].id);
if (received.data.content_available) {
console.log(received.data.from, received.data.text);
await forward.receiving.reply(received.data.id, {
text: 'Thanks for writing — we are on it!'
}, { idempotencyKey: crypto.randomUUID() });
await forward.receiving.forward(received.data.id, {
to: 'ops@example.com'
}, { idempotencyKey: crypto.randomUUID() });
}/api/v1/emails/receiving/api/v1/emails/receiving/:idList responses contain locally retained routing metadata and accept limit, after, before, and q. Full body, provider headers, and current attachment metadata are fetched only after ForwardHello confirms that the local resource belongs to the API key's workspace and optional domain scope. ForwardHello never uses the provider's account-wide received-email list to decide tenancy.
/api/v1/emails/receiving/:id/replyReply from the private Inbox or call forward.receiving.reply(id, input, options) with a Full access key. ForwardHello chooses the signed Reply-To addresses, permits from only from the original local recipients, adds In-Reply-To and a bounded References chain, then runs the normal verified-domain, sending-access, suppression, rate-limit, and quota checks. The inbound/outbound link and actor commit in the same transaction as the email, and each detail response returns its linked replies.
from and subject plus html or text. Recipients, CC/BCC, scheduling, attachments, and custom headers are deliberately unavailable on this endpoint. Use an Idempotency-Key so an uncertain network retry cannot send twice./api/v1/emails/receiving/:id/forwardForwardHello from the private Inbox or call forward.receiving.forward(id, input, options) with a Full access key. The required to can contain one or more destinations, while optional from must still be an original local recipient. Passthrough is the default: ForwardHello downloads the provider's short-lived raw source, parses it in the Worker, and resends the original subject, text/HTML, inline CIDs, and up to 10 attachments. Set passthrough: false with text or html to send a note and attach the untouched source as forwarded_message.eml.
Idempotency-Key.The private Inbox uses the same Preview, Plain text, and HTML viewer as outbound history. Received HTML stays inside the same no-referrer sandbox and cannot load remote pixels or scripts; CID files remain available through the authenticated attachment proxy.
/api/v1/emails/receiving/:id/attachmentsno-store, nosniff, and sandbox headers.