---
title: Providers
description: Configure sending channels (resend, aliyun), sending domains, and ESP bounce/complaint webhooks.
---

Email in Retidal is organized around **channels** (called `providers` in the API) — a channel is one sending
configuration: a driver (`resend`, `aliyun`, or the `log` no-op), a `fromAddress`, and rate limits. A project
can have more than one channel; templates and triggers pick a channel by `providerId` (or let the project's
[routing rules](#routing) choose one).

<Warning title="Only two drivers actually send mail">
  The `kind` enum accepts six values — `log`, `resend`, `sendgrid`, `mailgun`, `aliyun`, `cf_email` — but
  only **`resend`** and **`aliyun`** have a real delivery driver behind them. `sendgrid`, `mailgun`, and
  `cf_email` are accepted by validation and silently fall back to the `log` driver (records the send,
  delivers nothing) at send time. `log` is the intentional no-op safety net, not a placeholder for
  the others. There is no SMTP driver.
</Warning>

## Authentication

Every endpoint on this page is a **console (Management API) route** — authenticated with the
`trackly_session` session cookie from a logged-in console user, not a project API key. See
[Authentication](/docs/authentication) for the full session-vs-key model. Write operations (create/update a
channel, set a webhook secret, rotate a webhook ID) require project role EDITOR+; reads require any project
role.

## List and create channels

```bash
GET  https://retidal.com/api/projects/{projectId}/email/providers
POST https://retidal.com/api/projects/{projectId}/email/providers
```

`GET` never returns secret material — the response has a `configConfigured` boolean instead of the actual
`config` object.

<CodeGroup>
```json title="Create a resend channel"
{
  "name": "Primary channel",
  "kind": "resend",
  "fromAddress": "noreply@yourdomain.com",
  "replyToAddress": "support@yourdomain.com",
  "config": { "apiKey": "re_..." },
  "rateLimitPerSecond": 10,
  "dailyLimit": 10000
}
```
```json title="Create an aliyun channel"
{
  "name": "Aliyun channel",
  "kind": "aliyun",
  "fromAddress": "noreply@yourdomain.com",
  "config": {
    "accessKeyId": "...",
    "accessKeySecret": "...",
    "accountName": "...",
    "regionId": "cn-hangzhou"
  }
}
```
</CodeGroup>

<ParamField name="name" type="string" required>
  1–120 characters.
</ParamField>
<ParamField name="kind" type="string" required>
  `resend` | `aliyun` | `log` for a real configuration. `sendgrid` | `mailgun` | `cf_email` validate but
  send nothing (see warning above).
</ParamField>
<ParamField name="fromAddress" type="string" required>
  Must be a valid email address.
</ParamField>
<ParamField name="replyToAddress" type="string">
  Optional, nullable.
</ParamField>
<ParamField name="config" type="object">
  Driver-specific credential map. `resend` → `{ apiKey }`. `aliyun` → `{ accessKeyId, accessKeySecret, accountName, regionId }`.
  `log` → `{}`. Secret fields are encrypted at rest (AES-256-GCM) and never echoed back.
</ParamField>
<ParamField name="status" type="string">
  `active` | `paused` | `disabled`. Default `active`.
</ParamField>
<ParamField name="rateLimitPerSecond" type="integer">
  1–100. Default 10. This is the only real send-rate limiter — there is no per-project daily quota beyond
  what you set here.
</ParamField>
<ParamField name="dailyLimit" type="integer">
  Optional cap on total sends per day, nullable.
</ParamField>
<ParamField name="transactionalReservePercent" type="integer">
  0–100, default 20. Percentage of the channel's throughput reserved for transactional mail so a marketing
  campaign burst can't starve password resets and receipts.
</ParamField>
<ParamField name="marketingDailyLimit" type="integer">
  Optional separate daily cap that applies only to `marketing`-category sends on this channel, nullable.
</ParamField>

`PATCH /api/projects/{projectId}/email/providers/{id}` does a partial update. **Changing `kind` requires resubmitting the full
`config`** for the new driver — a partial `config` on a `kind` change is rejected with `400`.

## Quota snapshot

```
GET /api/projects/{projectId}/email/providers/{id}/quota
```

Read-only usage snapshot for the channel's rate budget, plus a 24-hour aggregate of trigger sends skipped
for quota reasons (`recentQuotaSkips`). The snapshot can be `null` if the underlying rate-budget store is
unavailable — that's a degraded response, not an error.

## Sending domains

```
GET  /api/projects/{projectId}/email/domains
POST /api/projects/{projectId}/email/domains          { "domain": "mail.yourdomain.com" }
GET  /api/projects/{projectId}/email/domains/{domainId}/overview
POST /api/projects/{projectId}/email/domains/{id}/check
```

<Note title="Domain verification is not automated yet">
  `POST /api/projects/{projectId}/email/domains/{id}/check` currently always returns `409` — there is no automatic SPF/DKIM
  verification. Check your sending domain's DNS records directly in your ESP's dashboard (Resend or Aliyun
  console) instead of relying on this endpoint to confirm anything.
</Note>

## ESP bounce/complaint webhooks

Each channel exposes a webhook URL, keyed by a per-channel `webhookId`:

```
https://mhook.withretide.com/webhook/{resend|aliyun}/{webhookId}
```

Register this URL in your ESP's dashboard (Resend or Aliyun), then give Retidal the signing secret the ESP
issues for it:

```
POST /api/projects/{projectId}/email/providers/{id}/webhook-secret   { "webhookSecret": "whsec_..." }
```

`POST /api/projects/{projectId}/email/providers/{id}/webhook-secret` only accepts `webhookSecret` (1–500 characters) and only works
for `resend`/`aliyun` channels — any other `kind` returns `400`. It stores the secret without requiring you
to resend the send credentials.

To rotate the URL itself (for example, if you suspect the webhook URL leaked), rotate the `webhookId`:

```
POST /api/projects/{projectId}/email/providers/{id}/webhook/rotate  →  { "webhookId": "new-id" }
```

<Warning title="Rotating the webhookId does not update your ESP">
  The old `webhookId` stops routing immediately. You must manually update the callback URL in your ESP's
  dashboard after rotating — there's no automated push to Resend/Aliyun.
</Warning>

Once the secret is set, inbound bounce/complaint events are verified (Svix signature check) and
automatically written to the [suppression list](/docs/email/campaigns#suppressions). Until the secret is
set, the `mhook.withretide.com` webhook receiver returns a server error (not an auth error) for every
incoming callback — a deliberate choice so the ESP's own retry logic keeps trying instead of giving up on
what looks like an auth failure.

## Provider overview

```
GET /api/projects/{projectId}/email/providers/{providerId}/overview
```

A read-only dashboard payload: topology, recent send/delivery time series, and an approximate deliverability
rate. The deliverability numbers here are aggregated across every sending domain attached to the channel, so
if you share a domain across channels the rate is not channel-exclusive.

## Routing

```
GET  /api/projects/{projectId}/email/routing
POST /api/projects/{projectId}/email/routing
```

Routing rules pick which channel handles a send by category (`transactional` vs `marketing`) when a
template or trigger doesn't pin a `providerId` explicitly.

## Next steps

<CardGroup cols={2}>
  <Card title="Templates" href="/docs/email/templates">
    Build the HTML/text content and `{{variable}}` placeholders a channel will send.
  </Card>
  <Card title="Event triggers" href="/docs/email/triggers">
    Fire a template automatically when an event matches — with rate limits, quiet hours, and dedup.
  </Card>
</CardGroup>
