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

Build an email support desk on PostShiba

Receive support mail, keep conversations together, and send replies.

This guide gives help@inbound.support.example.com an inbox, replies from help@support.example.com, keeps later mail on plus-addresses such as help+ticket_123@inbound.support.example.com, and watches those replies on a delivery webhook. You keep a mailbox, a ticket, and a message in your own database.

Before you start

  • A Full access key from API keys for the setup steps. Run them from your shell, not the desk.
  • A Send only key for the desk's reply path in step 5.
  • DNS for support.example.com. You will publish DKIM, return-path, and MX on inbound.support.example.com. Leave the sending domain MX alone.
  • A payment method on the team. Cluster create returns 403 {error: payment_required} when billing is on and there is no card.

1. Create the cluster

create-cluster.sh
1export CAPSULE_URL="https://app.postshiba.com" 2export CAPSULE_API_KEY="YOUR_API_KEY" 3export TEAM_ID="KjkAJW" 4 5curl -sS \ 6 --request POST \ 7 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/clusters" \ 8 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 9 --header "Content-Type: application/json" \ 10 --data '{ 11 "cluster": { 12 "name": "support-mail", 13 "size": "small", 14 "region": "manual", 15 "plan": "nano", 16 "classification": "transactional" 17 } 18 }'
cluster-response.json
1{ 2 "id": "NmQpXr", 3 "name": "support-mail", 4 "region": "manual", 5 "size": "small", 6 "plan": "nano", 7 "ip_assignment": "shared", 8 "desired_status": "active", 9 "status": "active", 10 "sending_ready": true 11}

If sending_ready is still false, GET /api/v1/clusters/NmQpXr again. If the response is 403 kyc_required, the team is still pending. While the team is pending, you send only to member emails, live inbox addresses, and inbox forward-to addresses, and anything else returns 403 kyc_required. See Does PostShiba require production approval?.

2. Add the sending domain

Create and verify support.example.com before the inbox. The same domain lets agents reply from help@support.example.com.

create-domain.sh
1curl -sS \ 2 --request POST \ 3 --url "$CAPSULE_URL/api/v1/teams/$TEAM_ID/sending_domains" \ 4 --header "Authorization: Bearer $CAPSULE_API_KEY" \ 5 --header "Content-Type: application/json" \ 6 --data '{ 7 "sending_domain": { 8 "name": "support.example.com" 9 } 10 }'
domain-response.json
1{ 2 "id": "HsVtYk", 3 "name": "support.example.com", 4 "tenant_id": "WbLcFd", 5 "tenant": "default", 6 "dkim_status": "pending", 7 "return_path_status": "pending", 8 "dmarc_status": "pending", 9 "sending_allowed": false, 10 "dkim_selector": "ps1", 11 "dkim_manual": false, 12 "dkim_cname_host": "ps1._domainkey.support.example.com", 13 "dkim_cname_target": "HsVtYk.customers.postshiba.com", 14 "return_path_host": "rp.support.example.com", 15 "return_path_target": "rp.postshiba.com" 16}

Publish the DKIM CNAME and return-path CNAME exactly as returned. Then verify.

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"

Wait until sending_allowed is true. See Add and verify a domain.

3. Create the inbox

Set sending_domain_id and webhook_format json. Default host is inbound.support.example.com.

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": "Customer support", 9 "local_part": "help", 10 "sending_domain_id": "HsVtYk", 11 "webhook_url": "https://desk.example.com/webhooks/capsule/inbound", 12 "webhook_format": "json" 13 } 14 }'
inbox-response.json
1{ 2 "id": "PqRzMn", 3 "name": "Customer support", 4 "address": "help@inbound.support.example.com", 5 "local_part": "help", 6 "host": "inbound.support.example.com", 7 "tenant_id": "WbLcFd", 8 "sending_domain_id": "HsVtYk", 9 "webhook_url": "https://desk.example.com/webhooks/capsule/inbound", 10 "webhook_format": "json", 11 "webhook_secret": "SAVE_THIS_SECRET", 12 "mx_status": "pending", 13 "mx_record": "inbound.support.example.com MX 10 inbound.postshiba.com", 14 "mx_target": "inbound.postshiba.com" 15}

Save webhook_secret now. Lists omit it. Publish the MX record on inbound.support.example.com. Leave support.example.com MX alone. See How do I avoid MX conflicts?.

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

Verify queues a check. Poll the inbox until mx_status is verified.

inbox-ready.json
1{ 2 "id": "PqRzMn", 3 "address": "help@inbound.support.example.com", 4 "mx_status": "verified" 5}

If mx_status stays pending or becomes failed, check the MX host and value.

4. Accept the inbound webhook

Read the raw body before parsing JSON. Pass in your own database and queue functions. Inbox webhooks sign with webhook_secret.

inbound-webhook.js
1import { createHmac, timingSafeEqual } from "node:crypto" 2 3function validSignature({ 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) 10 .update(`${timestamp}.${body}`) 11 .digest() 12 const received = Buffer.from(signature.slice("sha256=".length), "hex") 13 14 return received.length === expected.length && 15 timingSafeEqual(received, expected) 16} 17 18export function createInboundHandler({ secret, saveMessage, enqueueOnce }) { 19 return async function handleInbound(request) { 20 const body = await request.text() 21 const timestamp = request.headers.get("X-Capsule-Timestamp") 22 const signature = request.headers.get("X-Capsule-Signature") 23 24 if (!validSignature({ body, timestamp, signature, secret })) { 25 return new Response("invalid signature", { status: 401 }) 26 } 27 28 const message = JSON.parse(body) 29 30 await saveMessage({ 31 capsuleId: message.id, 32 inboxId: message.inbox_id, 33 from: message.from, 34 to: message.to, 35 subject: message.subject, 36 text: message.text, 37 html: message.html, 38 threadId: message.thread_id, 39 inReplyTo: message.in_reply_to, 40 headers: message.headers, 41 attachments: message.attachments, 42 expiresAt: message.expires_at, 43 }) 44 45 await enqueueOnce(`support-inbound:${message.id}`, { 46 topic: "support-inbound", 47 messageId: message.id, 48 }) 49 return new Response(null, { status: 204 }) 50 } 51}

A 2xx response marks the delivery as sent. Return 204 on success. A bad signature should return 401. saveMessage must upsert on the PostShiba message ID. If queue publication fails, let the request fail so PostShiba retries. See Verify webhook requests and Inbound messages.

For a new message to the base inbox, create a ticket. For later mail, read thread_id. PostShiba sets that field from the plus tag first, then In-Reply-To, then References, then Message-ID. See Get email content.

5. Send an agent reply

Cluster create already issues a default SMTP credential. REST sends use the Bearer token. Create another credential only when you need the password for SMTP.

Pass the Send only key as apiKey. The desk gets inbound mail from the webhook in step 4, so it never needs to read messages through the API. If you later fetch messages or events from the desk, that call needs a Full access key. Keep both keys on your server, never in the agent's browser.

Set reply_to to a plus-address with your ticket ID. Set In-Reply-To and References from the inbound headers.

send-reply.js
1import { PostShiba } from "@postshiba/node" 2 3export async function sendReply({ 4 apiKey, 5 teamId, 6 ticket, 7 inboundMessage, 8 agentText, 9}) { 10 const postshiba = new PostShiba(apiKey, { teamId }) 11 const messageId = inboundMessage.headers["message-id"] 12 const priorReferences = inboundMessage.headers.references 13 const references = [priorReferences, messageId].filter(Boolean).join(" ") 14 const originalSubject = inboundMessage.subject || "Your support request" 15 16 const body = await postshiba.emails.send({ 17 from: "Acme Support <help@support.example.com>", 18 to: [inboundMessage.from], 19 reply_to: `help+${ticket.id}@inbound.support.example.com`, 20 subject: originalSubject.toLowerCase().startsWith("re:") 21 ? originalSubject 22 : `Re: ${originalSubject}`, 23 text: agentText, 24 headers: { 25 "In-Reply-To": messageId, 26 References: references, 27 }, 28 unique_args: { 29 ticket_id: ticket.id, 30 support_message_id: ticket.nextMessageId, 31 }, 32 }) 33 34 if (body.queued !== true) { 35 throw new Error(`PostShiba send failed: ${JSON.stringify(body)}`) 36 } 37 38 return body 39}
send-response.json
1{ 2 "queued": true, 3 "message_id": "abc@postshiba.com", 4 "from": "Acme Support <help@support.example.com>", 5 "to": ["you@example.com"], 6 "subject": "Re: I need help with order 123", 7 "unique_args": { 8 "ticket_id": "ticket_123", 9 "support_message_id": "msg_1" 10 } 11}

When the customer replies, mail goes to help+ticket_123@inbound.support.example.com. PostShiba matches it to the help inbox and sets thread_id to ticket_123. If inject queued nobody, the response is 502 {error: inject_failed}.

6. Track the reply

Create a separate webhook endpoint for outgoing delivery events.

create-delivery-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://desk.example.com/webhooks/capsule/delivery", 9 "event_types": [ 10 "processed", 11 "delivered", 12 "deferred", 13 "bounce", 14 "dropped", 15 "spamreport" 16 ] 17 } 18 }'
delivery-webhook-response.json
1{ 2 "id": "CdFgHj", 3 "url": "https://desk.example.com/webhooks/capsule/delivery", 4 "enabled": true, 5 "cluster_id": null, 6 "tenant_id": null, 7 "event_types": [ 8 "processed", 9 "delivered", 10 "deferred", 11 "bounce", 12 "dropped", 13 "spamreport" 14 ], 15 "secret": "hex-secret" 16}

Match ticket_id and support_message_id from the flattened unique args. A delivered event means the destination accepted the reply. See Delivered but not in the inbox.

You now have a verified support.example.com, a domain-bound inbox, a signed inbound handler, a plus-address reply, and a delivery webhook.

When to move to dedicated IPs

Support replies are low volume and conversational, so the cluster's shared IPs are usually the right call. Consider a dedicated IP in a transactional pool when you send a steady tens of thousands of messages a month, need a fixed IP a customer can allowlist, or want reputation apart from other senders. Never put replies in a bulk pool. A new dedicated IP warms up for 14 days; overflow keeps replies flowing on shared IPs meanwhile. When you opt in, add "X-Capsule-Pool": "support" to the headers object in send-reply.js. See Set up IP pools and warm up dedicated IPs.

Next steps