Create a key to copy examples filled with your team.

Build an email support desk on PostShiba

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

PostShiba gives you the mail plumbing for a support desk. Your application owns tickets, assignment, search, and the agent interface. PostShiba receives the message, parses it, stores it for a configurable retention window, and sends a signed webhook to your application. The default window is 168 hours.

This guide builds a desk with:

  • help@inbound.support.example.com for incoming mail
  • help@support.example.com for replies
  • plus-addresses such as help+ticket_123@inbound.support.example.com to keep later replies attached to a ticket
  • delivery webhooks for the messages your agents send

PostShiba puts custom-domain inboxes on inbound.{sending_domain}. It does not take over the MX records for support.example.com. That keeps existing mailboxes on the root domain alone.

The shape of the system

Keep four records in your own database:

  1. An account or workspace that maps to a PostShiba tenant when you serve more than one company.
  2. A mailbox that stores the PostShiba inbox ID, address, and webhook secret.
  3. A ticket keyed by your own ticket ID.
  4. A message keyed by the PostShiba inbound message ID or outbound message_id.

Use PostShiba IDs for API calls, but keep your own IDs in URLs and product logic. You can put those IDs in unique_args so delivery events find their way back to the right ticket.

Prepare the shared cluster

PostShiba must approve the team before you can create a cluster or inbox. Both requests return 403 {"error":"kyc_required"} until then.

Create an active shared-IP cluster before you rely on any inbox. PostShiba's shared edge only loads inboxes for approved teams with an active shared cluster.

create-cluster.sh
1 export CAPSULE_URL="https://postshiba.com"
2 export CAPSULE_API_KEY="YOUR_API_KEY"
3 export TEAM_ID="1"
4
5 curl -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 }
16 }'

Wait until sending_ready is true. Hosted inboxes skip DNS, but they still need this cluster to reach the shared edge.

1. Prepare the sending domain

Create and verify support.example.com before you create a domain-bound inbox. The same domain lets your agents reply from help@support.example.com.

create-domain.sh
1 curl -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 }'

The shell examples share these variables. Run the exports in your current shell, or put them in setup.sh and load that file with source setup.sh.

Publish the returned DKIM CNAME and return-path CNAME. SPF is optional. Call the verify action after DNS is visible:

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

Wait for dkim_status and return_path_status to become verified.

If your support product hosts several companies in one PostShiba team, create one tenant per company and pass its tenant_id when you create the domain. That keeps domains, inboxes, credentials, and suppressions separate.

2. Create the inbox

Set sending_domain_id to receive on your domain. Use the JSON webhook format unless you already have a SendGrid Inbound Parse handler.

create-inbox.sh
1 curl -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": 8,
11 "webhook_url": "https://desk.example.com/webhooks/capsule/inbound",
12 "webhook_format": "json"
13 }
14 }'

PostShiba returns the address, MX record, and webhook secret:

inbox-response.json
1 {
2 "id": 3,
3 "name": "Customer support",
4 "address": "help@inbound.support.example.com",
5 "local_part": "help",
6 "host": "inbound.support.example.com",
7 "tenant_id": 12,
8 "sending_domain_id": 8,
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 "retention_hours": 168
16 }

Save webhook_secret now. PostShiba includes it on create and show, but not when you list inboxes.

Publish the returned MX record on inbound.support.example.com. Do not replace the MX record for support.example.com or example.com.

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

Wait for mx_status to become verified.

3. Accept the inbound webhook

PostShiba signs the raw request body. Read it before parsing JSON. If your framework parses the body first, configure that route to retain the original bytes.

This handler uses the standard Request and Response APIs. Pass in your own database and queue functions.

inbound-webhook.js
1 import { createHmac, timingSafeEqual } from "node:crypto";
2
3 function 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
18 export 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 // Upsert by message.id so a webhook retry cannot create a duplicate.
31 await saveMessage({
32 capsuleId: message.id,
33 inboxId: message.inbox_id,
34 from: message.from,
35 to: message.to,
36 subject: message.subject,
37 text: message.text,
38 html: message.html,
39 threadId: message.thread_id,
40 inReplyTo: message.in_reply_to,
41 headers: message.headers,
42 attachments: message.attachments,
43 expiresAt: message.expires_at,
44 });
45
46 // Always retry the queue step. A failed first publish must not get
47 // skipped just because the message was already saved. The key makes
48 // a successful publish idempotent.
49 await enqueueOnce(`support-inbound:${message.id}`, {
50 topic: "support-inbound",
51 messageId: message.id,
52 });
53 return new Response(null, { status: 204 });
54 };
55 }

saveMessage must upsert on the PostShiba message ID. enqueueOnce must use its key as a durable deduplication key. If queue publication fails, let the request fail so PostShiba retries both idempotent steps.

The five-minute limit is your application's replay window. PostShiba signs the timestamp, but your receiver must reject requests that are too old.

Save the message to durable storage before returning 2xx; otherwise, you may acknowledge mail you have not stored. PostShiba retries failed webhooks after five minutes, 30 minutes, two hours, eight hours, and 24 hours. After the last failed attempt, it marks the delivery dead.

4. Turn messages into tickets

The JSON body contains parsed text, HTML, headers, envelope data, and attachment metadata. Attachment rows on the JSON webhook also include content_base64.

inbound-message.json
1 {
2 "id": 21,
3 "inbox_id": 3,
4 "to": "help@inbound.support.example.com",
5 "from": "customer@example.net",
6 "subject": "I need help with order 123",
7 "text": "My parcel has not arrived.",
8 "html": "<p>My parcel has not arrived.</p>",
9 "thread_id": "customer-message-id@example.net",
10 "headers": {
11 "message-id": "customer-message-id@example.net"
12 },
13 "envelope": {
14 "mail_from": "customer@example.net",
15 "rcpt_to": "help@inbound.support.example.com"
16 },
17 "attachments": [],
18 "expires_at": "2026-09-06T12:00:00Z"
19 }

Prefer text for search and agent previews. Keep HTML untrusted. Sanitize it before rendering. Scan and limit attachments before an agent opens them.

For a new message to the base inbox, create a ticket in your database. For later messages, look for your ticket ID in thread_id. PostShiba sets that field from the plus tag first, then falls back to standard reply headers and Message-ID.

5. Send an agent reply

Create a shared cluster and an SMTP credential before using /sends. The transactional email guide shows both calls. If you use a tenant for this company, create the credential for that tenant and pass the tenant slug on every send.

Set three headers when you reply:

  • reply_to uses a plus-address with your ticket ID
  • In-Reply-To points at the customer's Message-ID
  • References carries the existing thread references
send-reply.js
1 export async function sendReply({
2 apiKey,
3 teamId,
4 clusterId,
5 ticket,
6 inboundMessage,
7 agentText,
8 }) {
9 const messageId = inboundMessage.headers["message-id"];
10 const priorReferences = inboundMessage.headers.references;
11 const references = [priorReferences, messageId].filter(Boolean).join(" ");
12 const originalSubject = inboundMessage.subject || "Your support request";
13
14 const response = await fetch(
15 `https://postshiba.com/api/v1/teams/${teamId}/clusters/${clusterId}/sends`,
16 {
17 method: "POST",
18 headers: {
19 Authorization: `Bearer ${apiKey}`,
20 "Content-Type": "application/json",
21 },
22 body: JSON.stringify({
23 send: {
24 from: "Acme Support <help@support.example.com>",
25 to: [inboundMessage.from],
26 reply_to: `help+${ticket.id}@inbound.support.example.com`,
27 subject: originalSubject.toLowerCase().startsWith("re:")
28 ? originalSubject
29 : `Re: ${originalSubject}`,
30 text: agentText,
31 headers: {
32 "In-Reply-To": messageId,
33 References: references,
34 },
35 unique_args: {
36 ticket_id: ticket.id,
37 support_message_id: ticket.nextMessageId,
38 },
39 tenant: ticket.capsuleTenantSlug,
40 },
41 }),
42 },
43 );
44
45 const body = await response.json();
46 if (!response.ok || body.queued !== true) {
47 throw new Error(`PostShiba send failed: ${response.status} ${JSON.stringify(body)}`);
48 }
49
50 return body;
51 }

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.

queued: true means at least one recipient was accepted by the injector. This guide sends one recipient per request so each support reply gets a clear queue result.

6. Track the reply

The inbox webhook handles incoming mail. Delivery events for your outgoing replies use a separate webhook endpoint.

Create one endpoint with processed, delivered, deferred, bounce, dropped, and spamreport. Match its ticket_id and support_message_id unique args to your database. A delivered event means the destination accepted the reply. It does not prove the customer read it.

Keep a polling fallback

PostShiba retains inbound messages even when you use a webhook. The default is 168 hours. Read retention_hours from the inbox and expires_at from each message instead of assuming the default. That gives you a recovery path:

There is no replay action for a dead inbound inbox delivery. Poll the message API before expires_at. Replay in the dashboard only applies to outbound delivery-event webhooks.

list-inbound.sh
1 curl -sS \
2 --url "$CAPSULE_URL/api/v1/inboxes/3/inbound_messages" \
3 --header "Authorization: Bearer $CAPSULE_API_KEY"

The list returns the latest 200 live messages. Fetch one message to include raw MIME and attachment bytes:

get-inbound.sh
1 curl -sS \
2 --url "$CAPSULE_URL/api/v1/inboxes/3/inbound_messages/21" \
3 --header "Authorization: Bearer $CAPSULE_API_KEY"

Copy mail into your own long-term storage. PostShiba's inbox is a short recovery window, not your ticket archive.

Before you launch the support desk

  • Keep the platform application token on your server. It has access to the whole PostShiba team.
  • Use one tenant per customer when several companies share your support product.
  • Use one inbox per public support address. Use plus tags for tickets, not as an access-control boundary.
  • Keep ticket IDs in plus-addresses lowercase and email-safe.
  • Deduplicate inbound webhooks on message.id.
  • Return 2xx after the durable write, then process routing, spam checks, and automation in a queue.
  • Sanitize HTML and treat message text as untrusted input.
  • Disable an inbox with DELETE /api/v1/inboxes/:id when you retire an address.

The API cannot update an inbox's webhook URL or format. Change those settings in the PostShiba dashboard. The dashboard is also where you replay dead outbound delivery-event webhooks.

About

PostShiba is the transactional email platform that powers Bento behind the scenes. You can build your own products, like Bento, on top of it.

© 2026 PostShiba by Backpack Internet Pty. Ltd. All rights reserved.

The same policies that govern Bento are applied to PostShiba Privacy | Terms | Security