Sign in to fill these examples with your own keys. Sign in Start free trial

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.

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. 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?.

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

setup.sh
1export CAPSULE_URL="https://app.postshiba.com" 2export CAPSULE_API_KEY="YOUR_API_KEY" 3export 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.

create-cluster.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "cluster": { 8 "name": "customer-mail", 9 "plan": "nano", 10 "classification": "transactional" 11 } 12 }'
cluster-response.json
1{ 2 "id": "NmQpXr", 3 "name": "customer-mail", 4 "plan": "nano", 5 "classification": "transactional", 6 "ip_assignment": "shared", 7 "emails_per_hour": 100, 8 "emails_per_day": 600, 9 "status": "active", 10 "sending_ready": true 11}

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.

create-webhook.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/webhook_endpoints" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "webhook_endpoint": { 8 "url": "https://esp.example.com/webhooks/capsule", 9 "event_types": ["processed", "delivered", "deferred", "bounce", "dropped", "spamreport", "unsubscribe"] 10 } 11 }'
webhook-response.json
1{ 2 "id": "CdFgHj", 3 "url": "https://esp.example.com/webhooks/capsule", 4 "enabled": true, 5 "cluster_id": null, 6 "tenant_id": null, 7 "event_types": ["processed", "delivered", "deferred", "bounce", "dropped", "spamreport", "unsubscribe"], 8 "secret": "hex-secret" 9}

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.

postshiba-client.js
1export class PostShibaClient { 2 constructor({ baseUrl, apiKey, teamId, clusterId }) { 3 this.baseUrl = baseUrl 4 this.apiKey = apiKey 5 this.teamId = teamId 6 this.clusterId = clusterId 7 } 8 9 async request(path, { method = "GET", body } = {}) { 10 const response = await fetch(`${this.baseUrl}${path}`, { 11 method, 12 headers: { 13 Authorization: `Bearer ${this.apiKey}`, 14 ...(body ? { "Content-Type": "application/json" } : {}), 15 }, 16 body: body ? JSON.stringify(body) : undefined, 17 }) 18 19 if (!response.ok) { 20 const error = new Error(`PostShiba ${method} ${path}: ${response.status}`) 21 error.status = response.status 22 error.body = await response.json().catch(() => null) 23 throw error 24 } 25 26 return response.status === 204 ? null : response.json() 27 } 28}

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.

create-tenant.js
1const tenant = await postshiba.request(`/api/v1/teams/${postshiba.teamId}/tenants`, { 2 method: "POST", 3 body: { tenant: { name: "Acme Florist", slug: "acme-florist" } }, 4})
tenant-response.json
1{ 2 "id": "WbLcFd", 3 "name": "Acme Florist", 4 "slug": "acme-florist", 5 "suspended": false 6}

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.

create-domain.js
1const domain = await postshiba.request(`/api/v1/teams/${postshiba.teamId}/sending_domains`, { 2 method: "POST", 3 body: { sending_domain: { name: "acme.example", tenant_id: tenant.id } }, 4}) 5 6const dnsRecords = [ 7 domain.dkim_manual 8 ? { type: "TXT", host: domain.dkim_cname_host, value: domain.dkim_txt } 9 : { type: "CNAME", host: domain.dkim_cname_host, value: domain.dkim_cname_target }, 10 { type: "CNAME", host: domain.return_path_host, value: domain.return_path_target }, 11 { type: "TXT", host: domain.dmarc_host, value: domain.dmarc_record, optional: true }, 12]

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.

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.

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

Then read the domain.

get-domain.sh
1curl -sS \ 2 --url "$CAPSULE_URL/api/v1/sending_domains/HsVtYk" \ 3 --header "Authorization: Bearer $CAPSULE_API_KEY"
domain-ready.json
1{ 2 "id": "HsVtYk", 3 "name": "acme.example", 4 "tenant_id": "WbLcFd", 5 "tenant": "acme-florist", 6 "dkim_status": "verified", 7 "return_path_status": "verified", 8 "last_verification_error": null, 9 "sending_allowed": true 10}

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?.

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.

create-credential.js
1const credential = await postshiba.request( 2 `/api/v1/teams/${postshiba.teamId}/clusters/${postshiba.clusterId}/smtp_credentials`, 3 { method: "POST", body: { smtp_credential: { tenant_id: tenant.id } } }, 4)
credential-response.json
1{ 2 "id": "RvWsXq", 3 "username": "smtp_9", 4 "password": "SHOWN_ONCE", 5 "tenant_id": "WbLcFd", 6 "tenant": "acme-florist", 7 "cluster_id": "NmQpXr" 8}

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.

send-for-customer.js
1export async function sendForCustomer(postshiba, customer, message) { 2 if (!customer.tenantSlug) throw new Error("Customer has no PostShiba tenant") 3 4 return postshiba.request("/api/v1/emails", { 5 method: "POST", 6 body: { 7 tenant: customer.tenantSlug, 8 from: message.from, 9 to: message.to, 10 subject: message.subject, 11 html: message.html, 12 text: message.text, 13 headers: customer.ipPool ? { "X-Capsule-Pool": customer.ipPool } : {}, 14 unique_args: { customer_id: customer.id, transmission_id: message.id }, 15 }, 16 }) 17}
send-response.json
1{ 2 "queued": true, 3 "message_id": "abc@postshiba.com", 4 "from": "Acme <hello@acme.example>", 5 "to": ["you@example.com"], 6 "subject": "Your order", 7 "suppressed": [], 8 "unique_args": { "customer_id": "cust_1", "transmission_id": "tx_1" } 9}

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.

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.

delivery-webhook.js
1import { createHmac, timingSafeEqual } from "node:crypto" 2 3function verify({ body, timestamp, signature, secret }) { 4 if (!timestamp || !signature?.startsWith("sha256=")) return false 5 6 const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) 7 if (!Number.isFinite(age) || age > 300) return false 8 9 const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest() 10 const received = Buffer.from(signature.slice(7), "hex") 11 return expected.length === received.length && timingSafeEqual(expected, received) 12} 13 14export function createDeliveryHandler({ secret, recordEvent }) { 15 return async function handle(request) { 16 const body = await request.text() 17 const ok = verify({ 18 body, 19 timestamp: request.headers.get("X-Capsule-Timestamp"), 20 signature: request.headers.get("X-Capsule-Signature"), 21 secret, 22 }) 23 if (!ok) return new Response("invalid signature", { status: 401 }) 24 25 for (const event of JSON.parse(body)) { 26 await recordEvent({ 27 tenantSlug: event.tenant_id, 28 transmissionId: event.transmission_id, 29 eventId: event.sg_event_id, 30 type: event.event, 31 recipient: event.email, 32 occurredAt: new Date(event.timestamp * 1000), 33 reason: event.reason, 34 }) 35 } 36 return new Response(null, { status: 204 }) 37 } 38}

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 and 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.

add-suppression.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/suppressions" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{"suppression": {"email": "do-not-send@example.com", "tenant_id": "WbLcFd"}}'
suppression-response.json
1{ 2 "id": "YtReWq", 3 "email": "do-not-send@example.com", 4 "reason": "manual", 5 "tenant_id": "WbLcFd", 6 "tenant": "acme-florist", 7 "created_at": "2026-10-09T03:00:00Z" 8}

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

import-suppressions.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/suppressions/import" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{"emails": ["do-not-send@example.com", "old@example.com"], "tenant_id": "WbLcFd"}'
import-response.json
1{ "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.

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.

suspend-tenant.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/tenants/WbLcFd/suspend" \ 4 --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.

disable-credential.sh
1curl -sS \ 2 --request DELETE \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters/NmQpXr/smtp_credentials/RvWsXq" \ 4 --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.

claim-dedicated-ip.sh
1curl -sS \ 2 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/network?classification=transactional" \ 3 --header "Authorization: Bearer $CAPSULE_API_KEY" 4 5curl -sS \ 6 --request POST \ 7 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/network" \ 8 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 9 --header "Content-Type: application/json" \ 10 --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.

create-pool.sh
1POOL_ID=$(curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/ip_pools" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{"name": "acme-florist", "classification": "transactional"}' | jq -r .id) 7 8curl -sS --request POST \ 9 --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/add_ip" \ 10 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 11 --header "Content-Type: application/json" \ 12 --data '{"ip_address_id": "IP_ID_FROM_THE_LIST"}' 13 14curl -sS --request POST \ 15 --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/attach" \ 16 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 17 --header "Content-Type: application/json" \ 18 --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? and the IP pools API.

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.

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

Set up IP pools and warm up dedicated IPs 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.

create-inbox.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/inboxes" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "inbox": { 8 "name": "Acme replies", 9 "local_part": "reply", 10 "sending_domain_id": "HsVtYk", 11 "webhook_url": "https://esp.example.com/webhooks/capsule/inbound", 12 "webhook_format": "json" 13 } 14 }'
inbox-response.json
1{ 2 "id": "PqRzMn", 3 "address": "reply@inbound.acme.example", 4 "tenant_id": "WbLcFd", 5 "webhook_secret": "SAVE_THIS_SECRET", 6 "mx_status": "pending", 7 "mx_record": "inbound.acme.example MX 10 inbound.postshiba.com" 8}

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? and the support desk guide 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.

Next steps