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
403kyc_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.
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.
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.
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.
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.
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.
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.
Then read the domain.
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.
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.
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.
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.
When you move a customer from another provider, import their list in one call.
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.
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.
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.
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.
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.
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.
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.