reverbin
Menu

API reference

The concrete REST surface for inboxes, threads, replies, webhooks, deliveries, audit logs, and approval decisions.

Jump straight to an endpoint.

Start with the routes most agent workflows need.

Programmable email inboxes for autonomous agents.

Reverbin gives agents a small email control plane: create an inbox, receive real mail, read the thread, reply through policy controls, and subscribe the agent runtime to signed webhook events.

Base URL

https://api.reverbin.com

For local development:

http://127.0.0.1:8797

SDK runtime contract

The public @builtbyecho/reverbin package targets Node.js 20+ ESM and has no runtime dependencies. Install it with npm install @builtbyecho/reverbin. Browser runtimes are not supported.

The client timeout defaults to 30 seconds, allows a bounded timeoutMs override and caller AbortSignal on every method, and does not retry requests automatically. ReverbinApiError exposes credential-redacted status, code, requestId, retryAfterSeconds, quota, and details. ReverbinResponseError identifies malformed JSON, and ReverbinTimeoutError identifies the SDK timeout.

Automated signup, Stripe Checkout/Portal, API-key rotation, and webhook-secret rotation require an explicit caller-owned idempotency_key. Reuse the same key only with the identical body after a lost response. A signup replay prevents duplicate provisioning and returns only safe resource identifiers; it never replays the one-time API token or webhook secret, so sign in and rotate credentials if the original response was lost. Compose, reply, forward, approval decisions, account mutations, webhook creation/revocation, inbox creation, and API-key creation/revocation are not automatically retried and must not be blindly resubmitted after an ambiguous response.

Cursor pagination

All collection reads are bounded and return the existing data array plus next_cursor and has_more. This applies to inboxes, inbox threads, thread messages, approvals, webhooks, webhook deliveries, API keys, audit logs, and signup requests.

Use limit from 1 through 100 (default 50). When has_more is true, pass next_cursor unchanged as cursor on the same route and with the same authenticated tenant:

GET /v1/inboxes?limit=50
GET /v1/inboxes?limit=50&cursor=eyJ2IjoxLC4uLn0.signature
GET /v1/threads/thr_123/messages?limit=25

Cursors are opaque signed values over the created_at,id seek position. They are bound to the tenant, collection type, and parent resource. A modified cursor, an inbox cursor used for threads, a thread-A message cursor used for thread B, or any cross-tenant reuse returns 400 invalid_cursor. Do not decode cursors or persist them as durable object identifiers.

TypeScript SDK list methods accept { limit, cursor }:

const first = await reverbin.inboxes.list({ limit: 25 });
const second = first.has_more
  ? await reverbin.inboxes.list({ limit: 25, cursor: first.next_cursor! })
  : null;

Receiving domains

Public examples use the live root-domain address shape, such as user@reverbin.com. During beta, create inboxes on the domain included with your API key when testing real mail.

Authentication

POST /v1/agent-signups is the public self-serve provisioning route. It requires Idempotency-Key and returns a tenant-scoped API key once. A retry with the same key and body returns only safe resource identifiers from the encrypted replay receipt without provisioning another account; it does not return credentials again.

All other /v1/* routes require bearer auth:

-H "Authorization: Bearer $REVERBIN_API_KEY"

Do not put API keys in source code. Pass them through environment variables or an agent secret store. Self-serve API keys are scoped to the tenant created during signup, so one account cannot list another builder's inboxes, threads, webhooks, deliveries, or audit logs.

Tenant API keys are API-only and never create browser sessions. Browser access uses passwordless signup-email codes; only the server's configured deployment-operator token may use the advanced token login.

Every private route also requires a known scope. Missing, empty, or unknown scopes fail closed. The deployment operator credential is the only * bypass; tenant API keys cannot use *.

ScopeAccess
inboxes:read, inboxes:writeRead or create inboxes.
threads:read, threads:replyRead thread/message pages or compose, reply, and forward.
webhooks:read, webhooks:writeList, create, rotate, or revoke webhooks.
webhook-deliveries:readInspect delivery attempts.
approvals:read, approvals:writeList or decide approvals.
billing:read, billing:writeList plans or open hosted billing flows.
audit:readRead tenant audit rows.
account:read, account:writeRead/export or change account lifecycle state and signup requests.
api-keys:read, api-keys:writeList or manage tenant API keys.

Beta quota: self-serve accounts get 2 inboxes. Signup creates the first inbox. The generated API key can create one more with POST /v1/inboxes. Further inbox creation returns 403 inbox_quota_exceeded. Quota errors use stable limit, used, and reset_at fields; inbox and webhook quotas do not reset, so reset_at is null. Operators use the same customer quota path.

Health routes

These do not require API-key auth.

GET /health
GET /readyz

Example:

curl https://api.reverbin.com/health

Response conventions

List routes return:

{
  "data": [],
  "next_cursor": null,
  "has_more": false
}

The SDK represents this as { data, next_cursor, has_more } and preserves cursors as opaque strings.

Errors return JSON:

{
  "error": "validation_error"
}

Common statuses:

StatusMeaning
200Success.
201Created.
202Accepted/pending approval.
400Invalid input.
401Missing, malformed, expired, or revoked authentication.
403Scope, policy, suppression, or non-resetting resource quota denied the action.
404The tenant-scoped resource is not visible or does not exist.
409Resource state, idempotency body, version, or uniqueness conflict.
429Rate or plan quota exceeded; inspect Retry-After or quota metadata.
500Server error.
502The outbound provider rejected or failed a synchronous send.
503A required integration is not configured or is temporarily unavailable.

Five-minute agent flow

  • POST /v1/agent-signups self-serves a tenant, root-domain inbox, API key, and optional webhook.
  • Store the returned api_key.token and webhook secret immediately; secrets are returned once.
  • Send mail to the returned inbox.email_address.
  • GET /v1/inboxes/:id/threads lists threads for that tenant-scoped API key.
  • GET /v1/threads/:id fetches the thread and messages.
  • POST /v1/messages composes, or POST /v1/threads/:id/reply replies.
  • Reverbin emits email.received, email.sent, or approval.required to registered webhooks.
  • GET /v1/webhook-deliveries and GET /v1/audit-logs support operations.

Self-serve inbox signup

Provision an inbox

POST /v1/agent-signups

This route is public and automated. It creates a tenant, agent, root-domain inbox, tenant-scoped provisional API key, default send policy, signup audit row, and optional webhook endpoint. The API key token and webhook secret are returned once; store them directly in the agent's secret store. Reverbin sends a six-digit operator verification code to requester_email. That address must identify the responsible human and must be independent of the newly created Reverbin inbox.

Request:

curl -X POST https://api.reverbin.com/v1/agent-signups \
  -H "content-type: application/json" \
  -H "Idempotency-Key: signup-support-agent-2026-07-09" \
  -d '{
    "requester_email": "builder@example.com",
    "agent_name": "Support Agent",
    "agent_use_case": "Handle customer support replies and escalate unusual requests.",
    "preferred_inbox_name": "support-agent",
    "webhook_url": "https://agent.example.com/reverbin/webhook"
  }'

Response:

{
  "status": "provisioned",
  "credentials_returned": true,
  "signup_request_id": "sgr_...",
  "tenant_id": "ten_...",
  "agent": {
    "id": "agt_...",
    "name": "Support Agent"
  },
  "inbox": {
    "id": "inb_...",
    "email_address": "support-agent@reverbin.com",
    "display_name": "Support Agent",
    "status": "active"
  },
  "api_key": {
    "id": "key_...",
    "token": "rvb_live_...",
    "version": 1,
    "scopes": ["inboxes:read", "inboxes:write", "threads:read", "threads:reply", "webhooks:read", "webhooks:write", "webhook-deliveries:read", "approvals:read", "approvals:write", "billing:read", "billing:write", "audit:read", "account:read", "account:write", "api-keys:read", "api-keys:write"],
    "returned_once": true
  },
  "operator_verification": {
    "status": "pending",
    "delivery_status": "sent",
    "operator_email": "builder@example.com",
    "verification_endpoint": "/v1/agent-signups/verify",
    "resend_endpoint": "/v1/agent-signups/verification/resend",
    "expires_in_seconds": 600,
    "provisional_permissions": "Read inbox data and send only to the operator email until verified."
  },
  "webhook": {
    "id": "wh_...",
    "url": "https://agent.example.com/reverbin/webhook",
    "events": ["email.received", "email.sent", "approval.required"],
    "version": 1,
    "secret": "rvb_whsec_...",
    "secret_returned_once": true,
    "status": "disabled"
  },
  "quickstart": {
    "base_url": "https://api.reverbin.com",
    "inbox_email": "support-agent@reverbin.com",
    "next_steps": [
      "Store the API key in your agent secret store.",
      "Ask the operator for the six-digit code sent by email.",
      "Verify with POST /v1/agent-signups/verify.",
      "Confirm access with GET /v1/inboxes/:id/threads."
    ]
  }
}

Verify the responsible operator

The provisional key may read its own inbox and send only to the operator email. Other consequential routes return 403 operator_verification_required until verification. Any webhook requested during signup remains disabled and is activated atomically with successful verification.

POST /v1/agent-signups/verify
POST /v1/agent-signups/verification/resend
curl -X POST https://api.reverbin.com/v1/agent-signups/verify \
  -H "Authorization: Bearer $REVERBIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"otp_code":"<six-digit-code>"}'

A valid code returns {"status":"verified","full_permissions_unlocked":true}. Codes expire after 10 minutes. Only the newest code is valid, one successful use consumes it, and five invalid attempts consume it. OTP values are stored only as HMAC digests and are never returned by the API.

To replace and resend a code:

curl -X POST https://api.reverbin.com/v1/agent-signups/verification/resend \
  -H "Authorization: Bearer $REVERBIN_API_KEY"

Resend invalidates the previous code. Both verification routes are authenticated and rate-limited. Signing into the human dashboard through the emailed passwordless code also verifies the operator for the tenant.

Public conflict and abuse responses do not disclose whether an email or IP was seen before. Reuse the same idempotency key after a lost response; changing the body for a used key returns 409 idempotency_key_conflict. A completed replay confirms the existing tenant, agent, and inbox identifiers but intentionally omits the one-time API token and webhook secret:

{
  "status": "provisioned",
  "credentials_returned": false,
  "signup_request_id": "sgr_...",
  "tenant_id": "ten_...",
  "agent": { "id": "agt_...", "name": "Support Agent" },
  "inbox": {
    "id": "inb_...",
    "email_address": "support-agent@reverbin.com",
    "display_name": "Support Agent",
    "status": "active"
  },
  "message": "This signup was already provisioned. API and webhook credentials are not replayed; use the authenticated dashboard to rotate them."
}

A bounded abuse window returns 429 signup_temporarily_unavailable with retry_after_seconds.

Signup abuse windows use durable keyed hashes of normalized requester email, domain, and Fastify's trusted client IP. Raw client IPs are never stored. Configure SIGNUP_ABUSE_PEPPER, the bounded SIGNUP_* limits, and TRUST_PROXY with only known proxy IPs/CIDRs. WEBHOOK_SECRET_ENCRYPTION_KEY also protects the private replay receipt at rest even though that receipt excludes one-time credentials.

Legacy signup-request workflow

Self-serve integrations should use POST /v1/agent-signups. The authenticated signup-request routes remain available for existing operator-assisted workflows and are tenant-scoped under account:read or account:write:

POST /v1/signup-requests
GET /v1/signup-requests
PATCH /v1/signup-requests/:id

The SDK mirrors these as reverbin.signupRequests.create, reverbin.signupRequests.list, and reverbin.signupRequests.update. New integrations normally do not need them.

Billing and plan upgrades

Reverbin uses hosted Stripe Checkout for paid subscriptions. Stripe displays Link inside Checkout when Link is enabled for the Stripe account, so Reverbin never collects card numbers, CVCs, or expiry fields.

PlanBest forValue addPriceInboxesEmails/monthWebhooks
FreeBest for testing one agent workflowStart with a real inbox, root-domain addresses, and one webhook before committing to paid volume.$0/month22,0001
DeveloperFor solo builders shipping real agentsBuild production agent email flows with more mailboxes, higher volume, multiple webhooks, API keys, and audit logs.$19/month1010,0003
Startup BetaFor startups running multiple agentsScale a team or beta product with 100 mailboxes, higher monthly volume, more webhook destinations, and priority support.$149/month100100,00010
EnterpriseFor larger agent fleetsCustom scale, domains, deployment, support, and compliance needs.CustomCustomCustomCustom

List plans

GET /v1/billing/plans
curl https://api.reverbin.com/v1/billing/plans \
  -H "Authorization: Bearer $REVERBIN_API_KEY"

Create hosted Checkout session

POST /v1/billing/checkout

Request:

curl -X POST https://api.reverbin.com/v1/billing/checkout \
  -H "Authorization: Bearer $REVERBIN_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: checkout-developer-2026-07-09" \
  -d '{
    "plan": "developer",
    "success_url": "https://reverbin.com/docs/api?billing=success",
    "cancel_url": "https://reverbin.com/#pricing"
  }'

Response:

{
  "id": "cs_...",
  "url": "https://checkout.stripe.com/c/pay/cs_...",
  "plan": "developer",
  "provider": "stripe_checkout",
  "link_enabled_by_stripe": true
}

Redirect the human buyer to url. Idempotency-Key is required and is forwarded to Stripe through a stable derived key; a replay returns the same encrypted-receipt session. checkout.session.completed records validated customer/subscription linkage as checkout_pending but does not grant paid quotas. Paid entitlements begin only after a newer active or trialing subscription event whose tenant metadata and configured price mapping agree.

If Stripe is not configured yet, this route fails closed:

{
  "error": "stripe_checkout_not_configured",
  "missing": ["STRIPE_DEVELOPER_PRICE_ID"]
}

Open billing portal

POST /v1/billing/portal

Creates a hosted Stripe Customer Portal session for card updates, cancellation, and subscription management.

This mutation also requires Idempotency-Key; replay returns the original Portal session and a reused key with a different request returns 409 idempotency_key_conflict.

Stripe webhook events

Reverbin handles these Stripe events at POST /internal/stripe/webhook:

  • checkout.session.completed records pending, tenant-validated linkage without granting paid entitlements.
  • customer.subscription.created grants the mapped plan only for active or trialing status.
  • customer.subscription.updated syncs plan/status/customer/subscription state.
  • customer.subscription.deleted downgrades the tenant to Free/canceled.
  • invoice.payment_failed atomically downgrades entitlement to Free and marks billing status as past_due.

Stripe event ids are claimed exactly once. Subscription and invoice changes compare event.created to the tenant billing cursor; duplicates and stale events cannot overwrite newer state. Cross-tenant customer, subscription, metadata, or client-reference conflicts fail closed.

Inboxes

Create inbox

POST /v1/inboxes

Request:

curl -X POST https://api.reverbin.com/v1/inboxes \
  -H "Authorization: Bearer $REVERBIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "email_address": "user@reverbin.com",
    "display_name": "Support Agent"
  }'

Response:

{
  "id": "inb_...",
  "email_address": "user@reverbin.com",
  "display_name": "Support Agent",
  "status": "active",
  "policy": {
    "reply_only": false,
    "require_approval_for_new_recipients": false,
    "require_approval_for_external_domains": false,
    "max_outbound_per_hour": 10,
    "max_outbound_per_day": 50,
    "allow_attachments": false,
    "allow_links": true,
    "risk_threshold": "medium"
  }
}

Optional policy override:

{
  "email_address": "restricted@reverbin.com",
  "display_name": "Restricted Agent",
  "policy": {
    "require_approval_for_new_recipients": true,
    "allowed_domains": ["example.com"],
    "blocked_domains": ["competitor.example"],
    "allow_links": false
  }
}

List inboxes

GET /v1/inboxes
curl https://api.reverbin.com/v1/inboxes \
  -H "Authorization: Bearer $REVERBIN_API_KEY"

Get inbox

GET /v1/inboxes/:id

List inbox threads

GET /v1/inboxes/:id/threads

Response:

{
  "data": [
    {
      "id": "thr_...",
      "inbox_id": "inb_...",
      "subject": "Hello",
      "last_message_at": "2026-07-06T17:42:02.085Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Messages, threads, replies, and forwards

Compose a new thread

POST /v1/messages
const result = await reverbin.messages.compose({
  inbox_id: 'inb_123',
  to: ['person@example.com'],
  subject: 'Hello',
  text: 'Starting a new conversation.',
});

The body requires inbox_id, at least one to recipient, subject, and text; html is optional. The response uses the sent/queued/pending/failed contract below and includes thread_id for the new thread.

Get thread with messages

GET /v1/threads/:id

Response:

{
  "id": "thr_...",
  "inbox_id": "inb_...",
  "subject": "Hello",
  "messages": [
    {
      "id": "msg_...",
      "direction": "inbound",
      "from_email": "sender@example.com",
      "to_json": ["user@reverbin.com"],
      "subject": "Hello",
      "text_body": "Can you help?"
    }
  ],
  "messages_next_cursor": null,
  "messages_has_more": false
}

For later message pages, call GET /v1/threads/:id/messages?cursor=... or reverbin.threads.messages(id, { cursor }). Pass messages_next_cursor unchanged.

Reply to thread

POST /v1/threads/:id/reply

Minimal reply:

curl -X POST https://api.reverbin.com/v1/threads/thr_123/reply \
  -H "Authorization: Bearer $REVERBIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "text": "Thanks — I can help with that."
  }'

Explicit recipient/subject/html:

{
  "to": ["sender@example.com"],
  "subject": "Re: Hello",
  "text": "Thanks — I can help with that.",
  "html": "<p>Thanks — I can help with that.</p>"
}

Sent response:

{
  "message_id": "msg_...",
  "send_intent_id": "osi_...",
  "status": "sent",
  "provider_result": {
    "provider_message_id": "..."
  }
}

Pending approval response:

{
  "approval_id": "apr_...",
  "status": "pending",
  "decision": "require_approval",
  "risk_flags": ["first_time_recipient"]
}

Blocked response:

{
  "error": "policy blocked send",
  "decision": "block",
  "risk_flags": ["blocked_domain"]
}

Forward a thread

POST /v1/threads/:id/forward
const result = await reverbin.threads.forward('thr_123', {
  to: ['operator@example.com'],
  subject: 'Fwd: Hello',
  text: 'Forwarding for review.',
});

Forward requires explicit recipients, subject, and text. It uses the same policy, suppression, quota, approval, and durable-outbox outcomes as compose and reply.

Human mail console actions

Forward, Delete, and Delete selected are available in /mail for authenticated human operators. These actions are tenant-scoped and use soft deletes for cleanup: selected threads get threads.deleted_at while messages and audit history remain stored.

The public API exposes tenant-scoped compose, reply, and forward operations. Delete and bulk cleanup remain human-only mail-console controls; there is no public thread-delete endpoint.

Attachment and image storage

Outbound attachments are not supported in the launch API or SDK. Compose, reply, and forward requests that include a non-empty attachments array fail validation instead of silently dropping files. The stored allow_attachments policy field does not enable outbound file sending in this release. The attachment behavior below applies to inbound email and the authenticated human mail console.

Inbound attachment bytes are not stored directly in Postgres. Reverbin stores attachment metadata in message_attachments and writes file bytes to the configured attachment storage root. The authenticated mail console serves attachments through GET /mail/attachments/:id; there are no public unauthenticated attachment URLs.

Remote attachment downloads are DNS-validated, redirect-free, timeout-bounded, streamed to staged files, and limited per file, per message, and by attachment count. Recorded sizes and SHA-256 hashes come from measured bytes rather than provider claims.

Reverbin stages and fully validates every attachment before opening the message persistence transaction. Provider event, thread, message, and attachment metadata commit together with final file publication; a missing content source, download, cap, rename, or database failure rolls back the message and removes staged and final files.

Only PNG, JPEG, GIF, and WebP files whose declared type matches their file signature may render inline. HTML, SVG, XML, PDF, unknown, and mismatched content is served as application/octet-stream with attachment disposition, nosniff, and a restrictive sandbox CSP.

Webhooks

Create webhook endpoint

POST /v1/webhooks

Request:

curl -X POST https://api.reverbin.com/v1/webhooks \
  -H "Authorization: Bearer $REVERBIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://agent.example.com/reverbin/webhook",
    "events": ["email.received", "email.sent", "approval.required"]
  }'

Response:

{
  "id": "wh_...",
  "url": "https://agent.example.com/reverbin/webhook",
  "events": ["email.received", "email.sent", "approval.required"],
  "status": "active",
  "version": 1,
  "secret": "rvb_whsec_...",
  "secret_returned_once": true
}

Monthly plan quota response (429):

{
  "error": "monthly_email_quota_exceeded",
  "limit": 2000,
  "used": 2000,
  "reset_at": "2026-08-01T00:00:00.000Z"
}

Queued sends reserve monthly quota exactly once. A successful provider result moves the reservation to sent usage; retryable failures retain it, and terminal failure releases it exactly once. The same rules apply to compose, reply, forward, approval, and reconciler sends while hourly/daily policy limits remain additional safeguards.

If the provider attempt fails transiently, Reverbin returns 202 with the durable IDs and "status": "queued". Reuse those IDs when inspecting state; do not create another reply. The outbox retries with one stable provider idempotency key, and the message moves through queued, sending, then sent or terminal failed.

Replies carry a stable Reverbin Message-ID plus the tenant/inbox-scoped parent In-Reply-To and ordered References. Inbound threading checks In-Reply-To before References and never treats a provider email ID as an RFC message ID.

Reverbin generates the signing secret. Store the creation response immediately; webhook list responses never return endpoint secrets.

List webhooks

GET /v1/webhooks

Rotate or revoke a webhook

POST   /v1/webhooks/:id/rotate-secret
DELETE /v1/webhooks/:id

Webhook list, create, and rotate responses include an integer version (creation starts at 1). Rotation requires an idempotency header and version compare-and-set:

curl -X POST "$REVERBIN_BASE_URL/v1/webhooks/wh_.../rotate-secret" \
  -H "Authorization: Bearer $REVERBIN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webhook-rotation-2026-07-09" \
  -d '{"expected_version":1}'

Rotation returns the new encrypted-at-rest signing secret once with secret_returned_once: true and the incremented version. A dropped-response retry with the same idempotency key and expected version decrypts an encrypted receipt and returns the same secret. A different request that races with a stale expected version returns 409 with version_conflict and current_version.

For 15 minutes by default (configurable with bounded CREDENTIAL_ROTATION_GRACE_SECONDS), Reverbin retains the previous encrypted secret and signs deliveries with both versions. Delete is tenant-scoped, advances the endpoint generation, waits for bounded in-flight network delivery locks, cancels pending stale-generation deliveries, and never exposes stored secrets.

Inspect webhook deliveries

GET /v1/webhook-deliveries

Use this for operational debugging. Delivery rows include event type, status, attempts, last error, creation time, and delivered time.

Webhook signing

All webhook requests are JSON POSTs. The stable header names retain the historical x-echo-email-* prefix for wire compatibility; integrations should use them exactly as documented.

Headers:

x-echo-email-event: email.received
x-echo-email-delivery: whd_...
x-echo-email-signature: sha256=<hmac_hex_digest>
x-echo-email-signature-previous: sha256=<previous_secret_hmac_during_grace>

x-echo-email-signature is an HMAC-SHA256 signature over the raw JSON request body using the current endpoint secret. During rotation grace, consumers still holding the old secret may verify x-echo-email-signature-previous; after grace that header disappears. Delivery is never unsigned.

After signature verification succeeds, atomically claim the unique x-echo-email-delivery value before performing side effects, using a durable processing lease and completed state. Commit transactional business changes and the completed state together. For external side effects, enqueue a transactional outbox operation keyed by the delivery ID. A completed claim returns success without re-executing; an expired processing lease may be retried. This deduplicates concurrent and repeated deliveries without losing retryability.

Node SDK verification:

import { verifyWebhookSignature } from '@builtbyecho/reverbin/webhook-signatures';

const valid = verifyWebhookSignature(rawBody, signatureHeader, webhookSecret);

The helper validates the exact lowercase sha256=<64 hex> contract and compares fixed-length digest bytes with timingSafeEqual. During rotation grace, call it separately for the current header/secret and the previous header/secret.

Webhook events

email.received

{
  "type": "email.received",
  "created_at": "2026-07-06T17:42:02.085Z",
  "data": {
    "inbox_id": "inb_...",
    "thread_id": "thr_...",
    "message_id": "msg_...",
    "from": "sender@example.com",
    "subject": "Hello"
  }
}

email.sent

{
  "type": "email.sent",
  "created_at": "2026-07-06T17:42:28.874Z",
  "data": {
    "inbox_id": "inb_...",
    "thread_id": "thr_...",
    "message_id": "msg_...",
    "to": ["sender@example.com"],
    "subject": "Re: Hello",
    "risk_flags": ["first_time_recipient"]
  }
}

email.failed

Emitted only after the durable outbound intent exhausts its bounded attempts. Its data includes inbox_id, thread_id, message_id, optional approval_id, and a bounded error string.

Delivery reputation events

email.delivery_delayed, email.delivered, email.bounced, and email.complained are emitted from signed Resend events after Reverbin resolves the provider email id to exactly one tenant-owned outbound message. Delay is non-terminal and delivery may advance it. Bounce and complaint are terminal; stale delivery cannot overwrite them.

Hard bounce and complaint create a tenant-scoped recipient suppression in the same transaction as message state, audit, and webhook intents. Later compose, reply, forward, and approval sends to a suppressed recipient fail with 403 recipient_suppressed before an outbox intent or monthly quota reservation is created.

approval.required

{
  "type": "approval.required",
  "created_at": "2026-07-06T17:43:00.000Z",
  "data": {
    "approval_id": "apr_...",
    "inbox_id": "inb_...",
    "thread_id": "thr_...",
    "to": ["new@example.com"],
    "subject": "Re: Hello",
    "risk_flags": ["first_time_recipient"],
    "reasons": ["first time recipient requires approval"]
  }
}

approval.rejected

{
  "type": "approval.rejected",
  "created_at": "2026-07-06T17:44:00.000Z",
  "data": {
    "approval_id": "apr_..."
  }
}

Approvals

Approvals are optional. The default policy is frictionless for normal replies.

List approvals

GET /v1/approvals

Approve and send

POST /v1/approvals/:id/approve

Reject

POST /v1/approvals/:id/reject

SDK methods mirror the three routes:

const approvals = await reverbin.approvals.list({ limit: 25 });
await reverbin.approvals.approve(approvals.data[0].id);
await reverbin.approvals.reject('apr_...');

Audit logs

GET /v1/audit-logs

Audit rows help operators inspect what happened without exposing provider secrets.

API key lifecycle

GET  /v1/api-keys
POST /v1/api-keys
POST /v1/api-keys/:id/rotate
POST /v1/api-keys/:id/revoke

List, create, and rotate responses include an integer version (creation starts at 1). List responses otherwise contain metadata only: id, name, scopes, creation/expiry, last-use, rotation, and revocation timestamps. Create accepts name, optional known scopes, and optional future expires_at.

Rotate with Idempotency-Key: <stable retry key> and JSON { "expected_version": 1 }. Rotation atomically compares and increments the version, returns the new rvb_live_... token with token_returned_once: true, and retains only the previous token's SHA-256 hash for the bounded grace window. Idempotency receipts are AES-256-GCM encrypted with WEBHOOK_SECRET_ENCRYPTION_KEY; plaintext tokens exist only in response memory. The same idempotency key returns the same token, while a stale competing expected version returns 409 version_conflict. A tenant may have at most 10 usable keys, and the last usable key cannot be revoked.

Existing six-scope keys keep exactly their stored grants; migrations do not broaden legacy scope arrays.

Account export and deletion state

GET  /v1/account/export
POST /v1/account/deletion-request
POST /v1/account/deletion-request/cancel

The export is tenant-scoped, audited, and bounded to 1,000 rows per collection. It omits API-key, login-code, and session hashes; webhook secrets; attachment storage paths; and stored content hashes.

Request deletion with the exact JSON body { "confirmation": "DELETE" }. This preserves the first deletion_requested_at for the pending request and sets deletion_due_at 30 days later. While pending, API access is restricted to GET /v1/account/export and POST /v1/account/deletion-request/cancel; dashboard and mail sessions may only log out. Cancel restores active and clears the pending window. Both changes are audited.

Operators can run npm run process:account-deletions as a one-shot bounded batch. It safely removes the tenant's contained attachment directory and then deletes due tenant rows. Re-running is idempotent. Tenants with a live Stripe subscription are skipped and reported as live_stripe_subscription; billing cancellation remains a prerequisite. No production timer is installed yet.

TypeScript SDK quickstart

This is Node.js 20+ ESM. Browser use is not supported.

import { ReverbinClient } from '@builtbyecho/reverbin';

const reverbin = new ReverbinClient({
  baseUrl: process.env.REVERBIN_BASE_URL ?? 'https://api.reverbin.com',
  apiKey: process.env.REVERBIN_API_KEY,
  timeoutMs: 30_000,
});

const inbox = await reverbin.inboxes.create({
  email_address: 'support@reverbin.com',
  display_name: 'Support Agent',
});

const threads = await reverbin.inboxes.threads(inbox.id);
if (threads.data[0]) {
  await reverbin.threads.reply(threads.data[0].id, {
    text: 'Thanks — I can help with that.',
  });
}

const nextThreads = threads.has_more
  ? await reverbin.inboxes.threads(inbox.id, { cursor: threads.next_cursor! })
  : null;

const rotated = await reverbin.apiKeys.rotate('key_...', {
  expected_version: 1,
  idempotency_key: crypto.randomUUID(),
});
// Store rotated.token now; it is returned once.

All list calls return { data, next_cursor, has_more }. Per-call request options accept { signal: AbortSignal, timeoutMs }. Catch ReverbinApiError for status/code/request ID/retry/quota metadata. The SDK performs no automatic retries, including for non-idempotent calls.

Worker mode

The API can deliver webhooks synchronously for simple deployments or enqueue jobs for a Redis/BullMQ worker.

WEBHOOK_DELIVERY_MODE=sync   # direct POST in the API process
WEBHOOK_DELIVERY_MODE=queue  # enqueue jobs for npm run worker:webhooks
WEBHOOK_SECRET_ENCRYPTION_KEY=<base64-encoded 32-byte key>

Email state, audit rows, domain events, and endpoint delivery intents commit together. Startup and periodic bounded reconciliation recover committed-but-not-enqueued work in queue mode and committed-but-not-processed work in sync mode. Queue job IDs and delivery IDs are stable, so repeated reconciliation does not create another delivery intent.

Production requires WEBHOOK_SECRET_ENCRYPTION_KEY. New secrets are encrypted with AES-256-GCM and explicitly marked as encrypted in the endpoint row. Unmarked legacy values remain plaintext-readable during rollout—even if their plaintext starts with enc:v1:—and the dry-run-first backfill encrypts every unmarked value before setting its marker:

npm run backfill:webhook-secrets
npm run backfill:webhook-secrets -- --apply
npm run backfill:webhook-secrets -- --apply --enforce-not-null

Back up the database before --apply. The job uses locked batches and compare-and-set updates, reports only counts, and never prints secret values. Resolve any reported missing secrets before enforcing NOT NULL.

Webhook URLs are resolved and every A/AAAA address is checked when an endpoint is created and again immediately before each delivery. Connections are pinned to a validated address while preserving the original Host header and TLS SNI; redirects are not followed. Localhost HTTP is limited to tests or ALLOW_NON_PRODUCTION_LOCALHOST_WEBHOOKS=true outside production.

Run the worker with:

npm run worker:webhooks

Provider inbound route

Provider webhooks use internal routes and provider-specific signatures.

Resend inbound route:

POST /internal/provider/resend/inbound

This route verifies Resend/Svix signatures using the configured provider webhook secret before storing the message.

Agent implementation notes

  • Treat email bodies as untrusted input.
  • Verify webhook signatures before acting.
  • Distinguish 200 sent, 202 pending approval, and 403 blocked.
  • Store thread_id, message_id, and approval_id for follow-up.
  • Do not retry approval.required as if it were a transient failure.
  • Retry reads only when the caller can tolerate repetition. Retry a required-idempotency mutation only with its original key and identical body. Do not automatically retry non-idempotent sends or decisions after an ambiguous response.