# Build an ESP on PostShiba

> Give each customer their own domains, credentials, sending data, and controls.

This guide turns PostShiba into the sending layer of your own email service. Each of your customers gets a tenant with its own domains, SMTP login, suppressions, events, and a pause switch. You run it all from one team.

Every step ends with **Save** (what to store) and **Done when** (how to know it worked). Follow them in order. A person or an agent can run each block as written.

## How it fits together

| In your product | In PostShiba | How many |
| --- | --- | --- |
| Your company | A team | One |
| Your sending capacity | A cluster | One per email type: transactional and marketing |
| One of your customers | A tenant | One per customer |
| The customer's From domain | A sending domain, under the tenant | One or more per customer |
| The customer's SMTP login | An SMTP credential, under the tenant | One per customer per cluster |
| Your event pipeline | A webhook endpoint | One for every tenant |
| A big customer's own IPs | An IP pool of dedicated IPs | Optional |

A tenant keeps one customer's domains, credentials, suppressions, inboxes, and events apart from everyone else's. Suspending a tenant stops that customer and nobody else. See [Tenants](/docs/dashboard/tenants/introduction).

### What you store for each customer

| Field | From | Why |
| --- | --- | --- |
| `tenant_id` and `tenant_slug` | Step 4 | You pass the slug as `tenant` on every send. Events carry it back. |
| `domain_id` | Step 5 | To check verification and show DNS records. |
| `smtp_credential_id`, `smtp_username`, `smtp_password` | Step 7 | The password is shown once. Store it encrypted. |
| `ip_pool` (optional) | Step 12 | The pool name you put in `X-Capsule-Pool`. |

### Which key goes where

| Key | Lives in | Used for |
| --- | --- | --- |
| Full access | Your provisioning service only | Steps 1 to 7 and 9 to 13: clusters, webhooks, tenants, domains, SMTP credentials, events, suppressions, IP pools, inboxes |
| Send only | Your send path, one key per environment | Step 8 |

A Full access key can read every tenant's events, issue SMTP passwords, and change domains and webhooks, so keep it in the one service that onboards customers and off the servers that send. A Send only key returns `403` on everything but sending. It is not tied to a tenant, so a leaked one can still send as any tenant's verified domain. Delete it on **API keys** and create a new one. Separate keys per environment let you do that without touching the others.

## Before you start

- A Full access API key and your team id, both from [API keys](/docs/create-an-api-key). Run the shell blocks below with it.
- A Send only key for each environment that sends, such as staging and production.
- A payment method on the team. Without one, cluster create returns `403` `{error: payment_required}`.
- Production approval before you send to outside addresses. Until then, sends to anyone other than your team members, live inboxes, and inbox forward-to addresses return `403` `kyc_required`. Everything else in this guide works while you wait. See [Does PostShiba require production approval?](/docs/knowledge-base/does-capsule-require-production-approval).

Set these once in your shell. Every block below uses them.

```bash setup.sh
export CAPSULE_URL="https://app.postshiba.com"
export CAPSULE_API_KEY="YOUR_API_KEY"
export TEAM_ID="KjkAJW"
```

## Steps at a glance

| # | Step | Call | Run |
| --- | --- | --- | --- |
| 1 | Create a cluster | `POST /api/v1/teams/:team_id/clusters` | Once per email type |
| 2 | Create the delivery webhook | `POST /api/v1/teams/:team_id/webhook_endpoints` | Once |
| 3 | Add the API client | Code | Once |
| 4 | Create the customer's tenant | `POST /api/v1/teams/:team_id/tenants` | Per customer |
| 5 | Add the customer's domain | `POST /api/v1/teams/:team_id/sending_domains` | Per customer |
| 6 | Verify the domain | `POST /api/v1/sending_domains/:id/verify` | Per customer |
| 7 | Issue an SMTP credential | `POST /api/v1/teams/:team_id/clusters/:cluster_id/smtp_credentials` | Per customer |
| 8 | Send for the customer | `POST /api/v1/emails` | Every message |
| 9 | Handle delivery events | Your webhook | Every event |
| 10 | Manage suppressions | `/api/v1/teams/:team_id/suppressions` | As needed |
| 11 | Pause and resume a customer | `POST /api/v1/tenants/:id/suspend` | As needed |
| 12 | Give a customer its own IPs | `/api/v1/teams/:team_id/ip_pools` | Optional |
| 13 | Receive replies | `POST /api/v1/teams/:team_id/inboxes` | Optional |

## 1. Create a cluster

A cluster is your sending capacity. Customer clusters send on shared IPs. `plan` sets the hourly and daily cap; omit it for `nano`. `classification` is `transactional` or `bulk` (marketing) and cannot change later, so create one cluster per type if your customers send both.

```bash create-cluster.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "cluster": {
      "name": "customer-mail",
      "plan": "nano",
      "classification": "transactional"
    }
  }'
```

```json cluster-response.json
{
  "id": "NmQpXr",
  "name": "customer-mail",
  "plan": "nano",
  "classification": "transactional",
  "ip_assignment": "shared",
  "emails_per_hour": 100,
  "emails_per_day": 600,
  "status": "active",
  "sending_ready": true
}
```

The full object also has `smtp_endpoint`, `http_endpoint`, `sending_ips`, and `ip_pools`. A team waiting for approval can create one cluster; a second returns `403` `{error: kyc_required}`.

**Save:** the cluster `id` as `CLUSTER_ID`.

**Done when:** `sending_ready` is `true`. If it is `false`, `GET /api/v1/clusters/NmQpXr` until it flips.

## 2. Create the delivery webhook

One endpoint can receive events for every tenant. Leave out `cluster_id` and `tenant_id` to get them all.

```bash create-webhook.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/webhook_endpoints" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "webhook_endpoint": {
      "url": "https://esp.example.com/webhooks/capsule",
      "event_types": ["processed", "delivered", "deferred", "bounce", "dropped", "spamreport", "unsubscribe"]
    }
  }'
```

```json webhook-response.json
{
  "id": "CdFgHj",
  "url": "https://esp.example.com/webhooks/capsule",
  "enabled": true,
  "cluster_id": null,
  "tenant_id": null,
  "event_types": ["processed", "delivered", "deferred", "bounce", "dropped", "spamreport", "unsubscribe"],
  "secret": "hex-secret"
}
```

**Save:** `secret` as `WEBHOOK_SECRET`. It is only returned on create and show, never in lists.

**Done when:** the response has `"enabled": true`. Step 9 shows how to verify each delivery.

## 3. Add the API client

The steps below use this small client. It sends your key, parses JSON, and throws with the status and body when PostShiba returns an error.

```javascript postshiba-client.js
export class PostShibaClient {
  constructor({ baseUrl, apiKey, teamId, clusterId }) {
    this.baseUrl = baseUrl
    this.apiKey = apiKey
    this.teamId = teamId
    this.clusterId = clusterId
  }

  async request(path, { method = "GET", body } = {}) {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    if (!response.ok) {
      const error = new Error(`PostShiba ${method} ${path}: ${response.status}`)
      error.status = response.status
      error.body = await response.json().catch(() => null)
      throw error
    }

    return response.status === 204 ? null : response.json()
  }
}
```

Create one instance with the Full access key in your provisioning service, for steps 4 to 7. Create another with the environment's Send only key in your send path, for step 8.

**Done when:** your onboarding code can import it.

## 4. Create the customer's tenant

Run this when a customer signs up. The slug takes lowercase letters, numbers, and hyphens.

```javascript create-tenant.js
const tenant = await postshiba.request(`/api/v1/teams/${postshiba.teamId}/tenants`, {
  method: "POST",
  body: { tenant: { name: "Acme Florist", slug: "acme-florist" } },
})
```

```json tenant-response.json
{
  "id": "WbLcFd",
  "name": "Acme Florist",
  "slug": "acme-florist",
  "suspended": false
}
```

**Save:** `tenant_id` (`WbLcFd`) and `tenant_slug` (`acme-florist`) on the customer's record.

**Done when:** the response has the slug you sent. A slug you already used returns `422`.

## 5. Add the customer's domain

The domain goes under the tenant, so only that customer can send from it.

```javascript create-domain.js
const domain = await postshiba.request(`/api/v1/teams/${postshiba.teamId}/sending_domains`, {
  method: "POST",
  body: { sending_domain: { name: "acme.example", tenant_id: tenant.id } },
})

const dnsRecords = [
  domain.dkim_manual
    ? { type: "TXT", host: domain.dkim_cname_host, value: domain.dkim_txt }
    : { type: "CNAME", host: domain.dkim_cname_host, value: domain.dkim_cname_target },
  { type: "CNAME", host: domain.return_path_host, value: domain.return_path_target },
  { type: "TXT", host: domain.dmarc_host, value: domain.dmarc_record, optional: true },
]
```

Show `dnsRecords` to the customer in your dashboard. Copy each host and value exactly. These records add new names and leave the customer's existing MX, SPF, and mailboxes alone, so PostShiba can send alongside the provider they already use. See [Add and verify a domain](/docs/add-a-domain).

**Save:** `domain_id` (`HsVtYk`).

**Done when:** the customer sees the two required records. DMARC is optional; skip it if they already publish one.

## 6. Verify the domain

When the customer says the records are in, ask PostShiba to check them.

```bash verify-domain.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/sending_domains/HsVtYk/verify" \
  --header "Authorization: Bearer $CAPSULE_API_KEY"
```

Then read the domain.

```bash get-domain.sh
curl -sS \
  --url "$CAPSULE_URL/api/v1/sending_domains/HsVtYk" \
  --header "Authorization: Bearer $CAPSULE_API_KEY"
```

```json domain-ready.json
{
  "id": "HsVtYk",
  "name": "acme.example",
  "tenant_id": "WbLcFd",
  "tenant": "acme-florist",
  "dkim_status": "verified",
  "return_path_status": "verified",
  "last_verification_error": null,
  "sending_allowed": true
}
```

**Done when:** `sending_allowed` is `true`. That needs a verified DKIM record and a verified return-path, unless the domain uses its own DKIM key (`dkim_manual`). If it stays `false`, show the customer `last_verification_error`; DNS can take a few minutes to appear. See [What if my domain is not verifying?](/docs/knowledge-base/what-if-my-domain-is-not-verifying).

## 7. Issue an SMTP credential

Every tenant needs a live credential on the cluster, even if it only sends over REST: PostShiba picks the tenant's cluster from its credentials.

```javascript create-credential.js
const credential = await postshiba.request(
  `/api/v1/teams/${postshiba.teamId}/clusters/${postshiba.clusterId}/smtp_credentials`,
  { method: "POST", body: { smtp_credential: { tenant_id: tenant.id } } },
)
```

```json credential-response.json
{
  "id": "RvWsXq",
  "username": "smtp_9",
  "password": "SHOWN_ONCE",
  "tenant_id": "WbLcFd",
  "tenant": "acme-florist",
  "cluster_id": "NmQpXr"
}
```

**Save:** `id`, `username`, and `password`, encrypted. The password is never returned again. If you lose it, disable this credential and issue a new one.

**Done when:** the response includes `password`. Customers who send over SMTP use it with the cluster's `smtp_endpoint` from step 1. A credential can only send as its tenant's domains.

## 8. Send for the customer

Look up the customer on your server and pass their slug as `tenant`. Pass the client that holds the Send only key. PostShiba checks the From domain belongs to that tenant and applies that tenant's suppression list.

```javascript send-for-customer.js
export async function sendForCustomer(postshiba, customer, message) {
  if (!customer.tenantSlug) throw new Error("Customer has no PostShiba tenant")

  return postshiba.request("/api/v1/emails", {
    method: "POST",
    body: {
      tenant: customer.tenantSlug,
      from: message.from,
      to: message.to,
      subject: message.subject,
      html: message.html,
      text: message.text,
      headers: customer.ipPool ? { "X-Capsule-Pool": customer.ipPool } : {},
      unique_args: { customer_id: customer.id, transmission_id: message.id },
    },
  })
}
```

```json send-response.json
{
  "queued": true,
  "message_id": "abc@postshiba.com",
  "from": "Acme <hello@acme.example>",
  "to": ["you@example.com"],
  "subject": "Your order",
  "suppressed": [],
  "unique_args": { "customer_id": "cust_1", "transmission_id": "tx_1" }
}
```

**Done when:** `queued` is `true`. Addresses on the tenant's suppression list are skipped and listed in `suppressed`.

`unique_args` come back on every event for the message, which is how you match events to your own records in step 9.

### Retry safely

`POST /api/v1/emails` does not accept `Idempotency-Key`. To retry without sending twice, send to `POST /api/v1/teams/:team_id/clusters/:cluster_id/sends` with the same body and an `Idempotency-Key` header. The same key with the same body replays the first `201` for 24 hours. A reused key with a different body returns 409 `idempotency_conflict`.

For high volume, customers can send over SMTP with their credential instead. See [Sending](/docs/api-reference/sending).

## 9. Handle delivery events

Each webhook request is a JSON array of SendGrid-style events, signed with your `WEBHOOK_SECRET`. Verify the raw body before you parse it. `tenant_id` on each event is the tenant slug, and your `unique_args` are copied onto it.

```javascript delivery-webhook.js
import { createHmac, timingSafeEqual } from "node:crypto"

function verify({ body, timestamp, signature, secret }) {
  if (!timestamp || !signature?.startsWith("sha256=")) return false

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false

  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest()
  const received = Buffer.from(signature.slice(7), "hex")
  return expected.length === received.length && timingSafeEqual(expected, received)
}

export function createDeliveryHandler({ secret, recordEvent }) {
  return async function handle(request) {
    const body = await request.text()
    const ok = verify({
      body,
      timestamp: request.headers.get("X-Capsule-Timestamp"),
      signature: request.headers.get("X-Capsule-Signature"),
      secret,
    })
    if (!ok) return new Response("invalid signature", { status: 401 })

    for (const event of JSON.parse(body)) {
      await recordEvent({
        tenantSlug: event.tenant_id,
        transmissionId: event.transmission_id,
        eventId: event.sg_event_id,
        type: event.event,
        recipient: event.email,
        occurredAt: new Date(event.timestamp * 1000),
        reason: event.reason,
      })
    }
    return new Response(null, { status: 204 })
  }
}
```

**Done when:** a test send produces `processed` then `delivered` in your handler. Return a 2xx quickly; anything else is retried with backoff. Use `sg_event_id` to ignore a repeat. The 300-second age check is your own replay guard. See [Verify webhook requests](/docs/webhooks/verify-webhooks-requests) and [Retries and replays](/docs/webhooks/retries-and-replays).

To show a customer their history, list events with `GET /api/v1/teams/:team_id/message_events?tenant_id=acme-florist`, or totals per customer with `GET /api/v1/teams/:team_id/analytics?dimension=tenant_id`.

## 10. Manage suppressions

Each tenant has its own suppression list. Permanent bounces, spam reports, and unsubscribes add to it automatically. Add an address when the customer asks you not to send to it.

```bash add-suppression.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/suppressions" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"suppression": {"email": "do-not-send@example.com", "tenant_id": "WbLcFd"}}'
```

```json suppression-response.json
{
  "id": "YtReWq",
  "email": "do-not-send@example.com",
  "reason": "manual",
  "tenant_id": "WbLcFd",
  "tenant": "acme-florist",
  "created_at": "2026-10-09T03:00:00Z"
}
```

When you move a customer from another provider, import their list in one call.

```bash import-suppressions.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/suppressions/import" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"emails": ["do-not-send@example.com", "old@example.com"], "tenant_id": "WbLcFd"}'
```

```json import-response.json
{ "imported": 1, "existing": 1, "invalid": 0 }
```

Remove one with `DELETE /api/v1/suppressions/YtReWq`, which returns `204`. The list endpoint, `GET /api/v1/teams/:team_id/suppressions`, takes `email`, `after`, and `limit` (default 100, max 1000) but not a tenant filter, so filter rows by `tenant_id` yourself.

**Done when:** a send to that address comes back with it in `suppressed`. If every recipient is suppressed, the send returns `403` `suppressed`. See [How to manage suppressions](/docs/dashboard/emails/email-suppressions).

## 11. Pause and resume a customer

Suspend a tenant to stop every domain on it at once, for example while you look into a complaint. Your other customers keep sending.

```bash suspend-tenant.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/tenants/WbLcFd/suspend" \
  --header "Authorization: Bearer $CAPSULE_API_KEY"
```

Resume with `POST /api/v1/tenants/WbLcFd/resume`. If resume returns `403` `operator_hold`, PostShiba paused the tenant; open a support ticket.

If a credential leaks, disable just that credential, then issue a new one with step 7.

```bash disable-credential.sh
curl -sS \
  --request DELETE \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters/NmQpXr/smtp_credentials/RvWsXq" \
  --header "Authorization: Bearer $CAPSULE_API_KEY"
```

**Done when:** the tenant shows `"suspended": true`, and sends for it are refused.

## 12. Give a customer its own IPs

By default every customer shares the cluster's IPs. When one customer's volume or reputation should stand apart, put dedicated IPs in an IP pool and send that customer's mail through it. Pools are picked per message, so you decide which customers use one.

### Choose a layout

| Layout | Who goes in it |
| --- | --- |
| The cluster's shared IPs | Most customers, and every new one. Shared IPs are the right default below about 50,000 messages a month or when volume is spiky. |
| A tier pool, such as `high-volume` | Several steady, well-behaved customers sharing two or more dedicated IPs. |
| A customer pool, named after the tenant slug | One big sender, or a customer who wants its own reputation or an IP its recipients can allowlist. |

Keep transactional and marketing mail on separate clusters and separate pools. Move a customer out of a tier pool as soon as its bounces or spam reports climb, so it can't hurt the others. A customer pool needs that customer's steady daily volume to stay warm; if it only sends now and then, keep it shared.

### Set up the pool

First, list what you can buy and claim one dedicated IP for the pool. Dedicated IPs are $50 a month. A new one warms up over 14 days, starting when it joins the pool.

```bash claim-dedicated-ip.sh
curl -sS \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/network?classification=transactional" \
  --header "Authorization: Bearer $CAPSULE_API_KEY"

curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/network" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"ip_address_id": "IP_ID_FROM_THE_LIST"}'
```

Pick a row with `"kind": "dedicated"` and `"available": true`. Leaving out `cluster_id` keeps the IP free for a pool.

Then create the pool, add the IP, and attach the pool to the cluster. The pool's classification must match the cluster's.

```bash create-pool.sh
POOL_ID=$(curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/ip_pools" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"name": "acme-florist", "classification": "transactional"}' | jq -r .id)

curl -sS --request POST \
  --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/add_ip" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"ip_address_id": "IP_ID_FROM_THE_LIST"}'

curl -sS --request POST \
  --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/attach" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"cluster_id": "NmQpXr"}'
```

The pool response lists its `ips` (each with `status` and `warmup_ends_at`) and `cluster_ids`.

Now send that customer's mail with the header `X-Capsule-Pool: acme-florist`. On `POST /api/v1/emails` it goes in the `headers` object, as in step 8. Over SMTP it is a normal message header. Mail without the header keeps using the cluster's own IPs.

**Save:** `ip_pool` = `acme-florist` on the customer's record.

**Done when:** the pool response shows the IP and the cluster id. A send with the header is queued, and its events show the pool IP as `sending_ip`.

A pool name that doesn't exist, isn't attached to the cluster, or has no live IPs is refused rather than sent from the shared IPs. Over REST that surfaces as `502` `inject_failed`. You can also do all of this from **Network** in the dashboard. See [How do dedicated IPs work?](/docs/knowledge-base/how-do-dedicated-ips-work) and the [IP pools API](/docs/api-reference/ip-pools).

### Warm up before you move a customer

Buy the IPs and build the pool about two weeks before you move the customer. For its first 14 days a new IP takes about 100 messages a day. While every IP in the pool is still warming up, the rest of the pool's mail goes out on the cluster's own IPs, so the customer never waits. Leave `warmup_overflow` on for that.

From day 15, move the customer's mail over in steps: send a growing share of it with the pool header, and check deferrals, bounces, and spam reports at Gmail, Yahoo, and Outlook before each step. To add an IP to a pool that is already warm, add it with a low weight and raise the weight once it has warmed. Calling `add_ip` again on an IP in the pool only changes its weight.

```bash raise-ip-weight.sh
curl -sS --request POST \
  --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/add_ip" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"ip_address_id": "IP_ID_FROM_THE_LIST", "weight": 25}'
```

[Set up IP pools and warm up dedicated IPs](/docs/guides/set-up-ip-pools-and-warm-up) has the day-by-day schedule and the numbers to check.

## 13. Receive replies

Give a customer an inbox when you want replies to come back to your product. The inbox lives on the customer's verified domain, at `inbound.{domain}` by default, and inherits the domain's tenant.

```bash create-inbox.sh
curl -sS \
  --request POST \
  --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/inboxes" \
  --header "Authorization: Bearer $CAPSULE_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "inbox": {
      "name": "Acme replies",
      "local_part": "reply",
      "sending_domain_id": "HsVtYk",
      "webhook_url": "https://esp.example.com/webhooks/capsule/inbound",
      "webhook_format": "json"
    }
  }'
```

```json inbox-response.json
{
  "id": "PqRzMn",
  "address": "reply@inbound.acme.example",
  "tenant_id": "WbLcFd",
  "webhook_secret": "SAVE_THIS_SECRET",
  "mx_status": "pending",
  "mx_record": "inbound.acme.example MX 10 inbound.postshiba.com"
}
```

**Save:** `id` and `webhook_secret`. The secret is only returned on create and show.

Ask the customer to publish `mx_record`. It sits on `inbound.acme.example`, so their main MX is untouched. Then call `POST /api/v1/inboxes/PqRzMn/verify`.

**Done when:** `GET /api/v1/inboxes/PqRzMn` shows `"mx_status": "verified"`. See [How do I avoid MX conflicts?](/docs/knowledge-base/how-do-i-avoid-conflicting-with-my-mx-records) and the [support desk guide](/docs/guides/build-email-support-desk) for threading replies.

## Errors you will see

| Status and error | When | What to do |
| --- | --- | --- |
| `403` `payment_required` | Cluster create with no card on the team | Add a payment method. |
| `403` `kyc_required` | A second cluster, or a send to outside addresses, before approval | Wait for production approval. |
| `422` `invalid` with `field: "tenant"` | `tenant` on a send is unknown | Check the slug you stored in step 4. |
| `403` `cluster_not_ready` | No sending-ready cluster for the tenant | Issue the tenant a credential (step 7). |
| `403` `domain_unverified` | The From domain is not verified for the tenant | Finish step 6. |
| `403` `suppressed` | Every recipient is suppressed | Expected. Show it to the customer. |
| 409 `idempotency_conflict` | An `Idempotency-Key` reused with a different body | Use a new key per message. |
| `502` `inject_failed` | The cluster refused the message, for example a bad `X-Capsule-Pool` | Check the pool name and that it is attached. |
| `403` `operator_hold` | Resuming a tenant PostShiba paused | Open a support ticket. |
| `403` `forbidden` | A Send only key on anything but a send | Make the call from your provisioning service with the Full access key. |

Send errors also include `field` and `message`. The full list is on [Errors](/docs/api-reference/errors).

## Next steps

- [Setting up PostShiba for multi-tenant apps](/docs/knowledge-base/setting-up-capsule-for-multi-tenants)
- [Tenants](/docs/dashboard/tenants/introduction)
- [How do dedicated IPs work?](/docs/knowledge-base/how-do-dedicated-ips-work)
- [Set up IP pools and warm up dedicated IPs](/docs/guides/set-up-ip-pools-and-warm-up)
- [Unique args](/docs/dashboard/emails/unique-args)
- [Verify webhook requests](/docs/webhooks/verify-webhooks-requests)
- [Errors](/docs/api-reference/errors)
