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

Set up IP pools and warm up dedicated IPs

Choose shared or dedicated IPs, group them into pools, and warm new IPs without losing mail.

This guide helps you decide which mail should leave from dedicated IPs, puts those IPs in IP pools, and walks a new IP from its first message to full volume. It works the same for one product sending its own mail and for a platform sending for its customers.

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

Shared or dedicated

Every cluster starts on shared IPs. PostShiba keeps them warm with steady traffic from many senders, so a new sender gets good delivery from the first message. Dedicated IPs give you a reputation of your own, which helps only if you send enough steady mail to earn it.

Setup Use it when Watch out for
Shared IPs You send under about 50,000 messages a month, or your volume comes in bursts. This is right for most senders. Nothing to set up. Your domain's reputation still matters.
A stream pool One stream, such as newsletters, should never affect another, such as password resets. Transactional and marketing always go on separate clusters and pools.
A tier pool You run a platform and several steady, well-behaved customers can share a reputation, for example high-volume. One bad customer hurts everyone in the tier. Move them out quickly.
A customer pool One customer sends a lot, wants a reputation of their own, or needs a fixed IP their recipients can allowlist. Each IP needs that customer's steady daily volume to stay warm.

Some rules of thumb:

  • Stay shared until volume is steady. A dedicated IP that sends a few hundred messages one week and nothing the next looks like a spammer to mailbox providers.
  • Put two IPs in a pool you depend on. If one IP has trouble at a provider, the other keeps the pool sending.
  • Don't spread thin. Each IP should carry real daily volume. Add IPs as volume grows, not before.
  • Keep risky mail apart. Put a new customer, or a list you haven't mailed in months, on shared IPs or in its own pool, never in a pool other mail depends on.
  • Use a pool for more than one IP. A cluster can hold one dedicated IP of its own. To send on more, put them in a pool.

How pools work

An IP pool is a named set of your dedicated IPs. Mail opts in one message at a time with the X-Capsule-Pool header. Mail without the header keeps using the cluster's own IPs.

  • A pool is transactional or bulk. That can't change, and it only takes IPs and clusters of the same type.
  • Each IP has a weight from 1 to 100. Weights are relative: two IPs at 100 and 50 take two thirds and one third of the pool's mail.
  • One pool can be attached to several of your shared clusters, and one IP can sit in several pools.
  • A pool name that doesn't exist, isn't attached to the cluster, or has no live IPs is refused. That mail never falls back to the shared IPs.

Before you start

  • A Full access API key and your team id, both from API keys. Changing Network in the dashboard needs the Team Administrator role.
  • A shared cluster of the right type, transactional or bulk. See Clusters.
  • A payment method on the team. Dedicated IPs are $50 a month each.
  • Your suppression list imported. Every bounce from an address you already knew was bad counts against a new IP. See How to manage suppressions.

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" 4export CLUSTER_ID="NmQpXr"

Steps at a glance

# Step Call Run
1 Buy dedicated IPs POST /api/v1/teams/:team_id/network Once per IP
2 Create the pool POST /api/v1/teams/:team_id/ip_pools Once per pool
3 Add the IPs POST /api/v1/ip_pools/:id/add_ip Once per IP
4 Attach the pool POST /api/v1/ip_pools/:id/attach Once per cluster
5 Send through the pool X-Capsule-Pool header Every pooled message
6 Ramp up next to your current sender Your code Every message, for 4 to 6 weeks
7 Watch delivery GET /api/v1/teams/:team_id/analytics Daily during warm-up
8 Add or retire IPs add_ip, remove_ip As volume changes

1. Buy dedicated IPs

List the IPs you can buy for the type of mail you are moving.

list-ips.sh
1curl -sS \ 2 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/network?classification=transactional" \ 3 --header "Authorization: Bearer $CAPSULE_API_KEY"

Pick rows with "kind": "dedicated" and "available": true, then buy each one. Leave out cluster_id so the IP stays free for a pool.

buy-ip.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/network" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{"ip_address_id": "QwErTy"}'
buy-ip-response.json
1{ 2 "id": "QwErTy", 3 "address": "198.51.100.10", 4 "status": "reserved", 5 "kind": "dedicated", 6 "classification": "transactional", 7 "owned": true, 8 "cluster_ids": [], 9 "ip_pool_ids": [] 10}

In the dashboard, open Network and select Add IP, then pick Leave unassigned. Buying from a pool page with Get a dedicated IP adds the IP to that pool for you.

Save: each IP's id.

Done when: each IP shows "owned": true. Its 14-day warm-up has not started yet. It starts when the IP joins a pool in step 3.

2. Create the pool

The name is the value you will send in X-Capsule-Pool. Use lowercase letters, numbers, dashes, and underscores. For a customer pool, the customer's tenant slug works well.

create-pool.sh
1curl -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": "receipts", "classification": "transactional", "warmup_overflow": true}'
pool-response.json
1{ 2 "id": "PlMnBv", 3 "name": "receipts", 4 "classification": "transactional", 5 "warmup_overflow": true, 6 "ips": [], 7 "cluster_ids": [], 8 "created_at": "2026-10-12T00:00:00Z" 9}

Keep warmup_overflow on. While every IP in the pool is warming, the mail they can't take yet goes out on the cluster's shared IPs, so nothing waits. Turn it off only if every pooled message must leave from the pool's IPs and you can accept delays during the first two weeks.

In the dashboard, open Network, select New pool, and fill in Name and Email type. Use the cluster's IPs while warming up is the same setting.

Save: the pool id as POOL_ID, and its name.

Done when: the response has the name you sent. A name you already use returns 422 name_taken.

3. Add the IPs

Add each IP you bought. New IPs that warm together should have the same weight.

add-ip.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/add_ip" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{"ip_address_id": "QwErTy", "weight": 100}'
pool-with-ip.json
1{ 2 "id": "PlMnBv", 3 "name": "receipts", 4 "warmup_overflow": true, 5 "ips": [ 6 { 7 "id": "QwErTy", 8 "address": "198.51.100.10", 9 "status": "warming", 10 "warmup_ends_at": "2026-10-26T00:00:00Z", 11 "weight": 100 12 } 13 ], 14 "cluster_ids": [] 15}

Done when: each IP shows "status": "warming" and a warmup_ends_at 14 days out.

4. Attach the pool

Attach the pool to every shared cluster that should send through it. The cluster's type must match the pool's.

attach-pool.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/ip_pools/$POOL_ID/attach" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data "{\"cluster_id\": \"$CLUSTER_ID\"}"

On the pool page in the dashboard, the three steps Add dedicated IPs, Attach to a cluster, and Send through the pool walk you through the same setup. The pool shows Ready when it has an IP and a cluster.

Done when: cluster_ids includes your cluster.

5. Send through the pool

Add the header to every message that should use the pool. On POST /api/v1/emails and POST /sends it goes in the headers object. Over SMTP and HTTP inject it is a normal message header. The name is not case-sensitive, and the header is removed before the message leaves.

send-pooled.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/emails" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "from": "Acme Receipts <receipts@mail.example.com>", 8 "to": ["you@example.com"], 9 "subject": "Receipt ord_123", 10 "text": "Thanks for your order.", 11 "headers": {"X-Capsule-Pool": "receipts"} 12 }'

Done when: the message's events show a pool IP as sending_ip. In the first two weeks, some pooled mail shows a shared IP instead. That is overflow doing its job.

6. Ramp up next to your current sender

A new IP has no history, so mailbox providers watch it closely. Start small, send the mail people want most, and grow while the numbers stay clean.

What PostShiba does for you

  • Holds each new IP back for 14 days. A warming IP takes about 100 messages a day. With overflow on, the rest goes out on the cluster's shared IPs, so your volume never waits on the warm-up.
  • Slows down when a provider pushes back. When a mailbox provider defers mail because a sending IP is moving too fast, PostShiba slows delivery to that provider and keeps retrying. At Yahoo, if one tenant keeps hitting the limit, PostShiba pauses that tenant's Yahoo mail for an hour, and everyone else keeps sending.
  • Stops sending to bad addresses. Permanent bounces, spam reports, and unsubscribes go on the suppression list automatically.

After day 14 the IP sends at full rate. From then on, the share of mail you send with the pool header decides how fast it grows.

What you control

Keep your current provider, or the shared IPs, as the main path, and send a growing share of mail through the pool. Decide once per message and record the choice, so a retry never goes out twice.

route-message.js
1import { createHash } from "node:crypto" 2 3// Share of each mailbox provider's mail that goes through the pool today. 4export const poolShare = { gmail: 0.05, yahoo: 0.05, outlook: 0.05, other: 0.05 } 5 6function mailboxProvider(address) { 7 const domain = address.split("@").pop().toLowerCase() 8 if (["gmail.com", "googlemail.com"].includes(domain)) return "gmail" 9 if (["yahoo.com", "aol.com", "ymail.com"].includes(domain)) return "yahoo" 10 if (["outlook.com", "hotmail.com", "live.com", "msn.com"].includes(domain)) return "outlook" 11 return "other" 12} 13 14export function usePool(message) { 15 const hash = createHash("sha256").update(`${message.id}:${message.to}`).digest() 16 const bucket = hash.readUInt32BE(0) / 2 ** 32 17 return bucket < poolShare[mailboxProvider(message.to)] 18}

Store the result with the message. If usePool is true, send with X-Capsule-Pool. Otherwise send the way you do today.

Raise each provider's share on its own, following this schedule. The numbers are messages per day to each large provider through the pool.

Days Per provider per day What to send IP state
1 to 2 50 to 100 Your most engaged recipients: receipts, replies, people who signed in this week Warming. Overflow carries extra mail.
3 to 4 About 200 The same Warming
5 to 7 About 400 Add recipients active in the last 60 days Warming
8 to 10 600 to 800 Add recipients active in the last 90 days Warming
11 to 14 1,000 to 1,500 Grow 30 to 50% a day, no longer doubling Warming
15 to 17 2,000 to 3,000 Your regular mix Active. The pool takes everything you send it.
18 to 21 4,000 to 5,000 Your regular mix Active
22 to 25 7,500 to 10,000 Grow 20 to 30% a day Active
26 to 30 Full volume Everything for this stream Active

No schedule fits every sender. Only move to the next row when step 7 looks healthy, and go slower with a provider that pushes back. Most senders reach full volume in 3 to 6 weeks.

Save: today's poolShare and the day of the ramp.

Done when: poolShare is 1 for every provider, and step 7 has stayed healthy for a week.

7. Watch delivery

Check each mailbox provider before you raise its share. PostShiba does not track opens or clicks, so watch what providers do with your mail: deferrals, bounces, and spam reports.

analytics-by-provider.sh
1curl -sS \ 2 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/analytics?dimension=mx_provider" \ 3 --header "Authorization: Bearer $CAPSULE_API_KEY"
analytics-response.json
1{ 2 "dimension": "mx_provider", 3 "rows": [ 4 { "key": "gmail", "processed": 1200, "delivered": 1188, "deferred": 9, "bounce": 3, "spamreport": 0 }, 5 { "key": "yahoo", "processed": 400, "delivered": 380, "deferred": 31, "bounce": 2, "spamreport": 1 } 6 ] 7}

Divide each count by processed, provider by provider.

Signal Healthy If it rises
Bounces Under 2% Hold the ramp. Check where the addresses came from and import any suppressions you missed.
Spam reports Under 0.1%. Gmail and Yahoo act at 0.3%. Cut that provider's share 25 to 30%. Find the stream or customer causing them and pause it.
Deferrals About the same as the day before Cut that provider's share 25 to 30% until it settles, then grow more slowly.

In the example, Yahoo's deferrals are almost 8%, so hold Yahoo where it is while Gmail keeps growing.

To see one IP on its own, use dimension=sending_ip, or list its events with GET /api/v1/teams/:team_id/message_events?sending_ip=198.51.100.10. In the dashboard, Analytics groups by the same dimensions.

Done when: you check this every day of the ramp, before you change poolShare.

8. Add or retire IPs

Add capacity to a warm pool. Buy an IP and add it with a low weight. While it warms, it takes its small daily share and the warm IPs carry the rest. After day 14, raise its weight a step every few days, checking step 7 each time.

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

Calling add_ip again on an IP already in the pool only changes its weight. A path such as 10, 25, 50, then 100 works for most pools.

Retire an IP. Remove it from the pool with POST /api/v1/ip_pools/:id/remove_ip. Queued mail pinned to it goes out on the rest of the pool. You still own it and pay for it until you cancel it in Network. A cancelled IP rests for 30 days before another team can buy it, and the next owner warms it up from the start.

Keep IPs warm. An IP that stops sending loses its reputation over a few weeks. If a stream shrinks, take IPs out of the pool rather than spreading less mail over the same IPs.

Troubleshooting

What you see Why What to do
Pooled mail leaves on a shared IP Every pool IP is still warming and overflow is on Expected for the first 14 days.
Pooled mail is slow in the first two weeks Overflow is off, so extra mail waits for the warming IPs Turn Use the cluster's IPs while warming up back on, or send less through the pool.
502 inject_failed on a pooled send The pool name is wrong, the pool isn't attached to this cluster, or it has no live IPs Check the name and cluster_ids.
Deferrals climb at one provider The IP is growing faster than that provider trusts it Cut that provider's share 25 to 30%, wait a day, then grow more slowly.
Bounces mention a blocklist The IP or domain was listed Stop raising volume and contact support. Once delisted, restart the ramp lower.

Errors you will see

Status and error When What to do
403 payment_required Buying an IP with no card on the team Add a payment method.
422 cooling_down The IP was cancelled less than 30 days ago Pick another IP.
422 name_taken Creating or renaming to a name you already use Pick another name.
422 class_mismatch The IP or cluster is a different type from the pool Use a transactional IP and cluster for a transactional pool, and the same for bulk.
422 in_default_pool The IP already sends for a cluster Unassign it from the cluster first.
422 weight The weight isn't a whole number from 1 to 100 Fix the weight.
422 empty_pool Attaching a pool with no live IPs Add an IP first.
422 dedicated_cluster Attaching to a cluster that is not shared Attach to a shared cluster.
422 last_ip Removing the last IP of an attached pool Detach the pool first.
422 pool_attached Deleting a pool that is still attached Detach every cluster first.

Next steps