U
Uniport
DocsCLISign in →

One inbox for every SaaS

Every product you ship comes with another inbox.

A contact form here, a forwarding address there, a Gmail tab you open when the guilt gets loud. Uniport takes one POST from every app you run. You get an inbox with a bottom.

Star the CLI on GitHub →

Accounts are closed while this runs as one operator’s instance. The API, the spec, the docs and the CLI are open, and the form at the foot of this page files a real ticket.

curl https://uniport.sh/api/v1/ping
{"ok":true,"service":"uniport","version":"1","time":"…"}

No key, no signup, no sales call. That endpoint answers you now.

The problem

Email was never a ticket system. It just got there first.

Twelve messages a week is not volume. It does not justify a helpdesk seat, it does not need a workflow engine, and it will still ruin a Sunday — because those twelve arrive in nine places and no two of them agree on what has been answered.

  • A thread four replies deep with no status on it. Answered? Read the whole thing again and decide.
  • The customer replies to no-reply@ and concludes you ignored them.
  • You mark a message unread as a to-do, and meet it again at one in the morning.
  • The screenshot that made the bug reproducible, quoted three levels down in a forwarded chain.
  • A form on the app you shipped last year, posting to an alias nobody has owned since you changed hosts.
  • Six products, six contact addresses, one person, no queue.

None of this is a discipline problem. A mail client is built to hold conversations, not work: nothing in an inbox knows what open means, so you keep the list in your head and pay for it at night.

A support email is a string in a folder. It has no status, no code the customer can quote back, no schema, and no way for a program to tell an answered question from an ignored one. You can search it. That is the whole API.

The fix is not more inboxes with better filters. It is one endpoint, one queue, and one status per ticket.

How it works

One POST and the ticket is filed.

Mint a key, set an env var, paste a form handler. No SDK, no webhook, no mailbox to babysit. Three moves, one commit.

Project
One of your apps. Its name prefixes every ticket code it produces, and its members are the people allowed to answer.
Key · upk_
Submit-only, scoped to one project. Shown once, stored as a SHA-256 hash, revocable without touching a single ticket it filed.
Ticket
A customer, a thread, a status, a code. Everything else here is a way of moving one from open to resolved.
  1. 1

    Add a project

    One per app you run. Its key files tickets as that app, and its prefix rides on every code: BOUNCY-A3F291E7 tells you which product it came from before you open it.

  2. 2

    Point the form at intake

    Two required fields: email and message. name, subject and source are optional context — plan, page, build — whatever spares you the follow-up email asking which button.

  3. 3

    Answer from Focus

    One ticket at a time: reply, resolve, next. The customer gets an email and a link to the thread. Nothing lands in your mail unless you ask for it.

POST /api/v1/intake
curl -X POST https://uniport.sh/api/v1/intake \
  -H "Authorization: Bearer $UNIPORT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name":    "Jane Doe",
    "email":   "jane@example.com",
    "subject": "Export is broken",
    "message": "The export button does nothing on Safari 18.",
    "source":  { "page": "/pricing", "plan": "pro" }
  }'
201 Created
{
  "ok": true,
  "ticket_id": "1f9a3c6e-88b2-4f1d-9a17-2c5e7b0d4a33",
  "short_code": "BOUNCY-A3F291E7",
  "status_url": "https://uniport.sh/t/BOUNCY-A3F291E7?k=6b1d9f0c2e4a7b83c5d1e0f9a2b3c4d5"
}

From a framework it is the same call with the key kept on the server:

app/contact/actions.ts
"use server";

import { createUniport, UniportError } from "@/lib/uniport";

// Module scope: the key never crosses into a client bundle.
const uniport = createUniport({ key: process.env.UNIPORT_KEY! });

export async function submitContact(formData: FormData) {
  try {
    const ticket = await uniport.submit({
      name:    String(formData.get("name") ?? ""),
      email:   String(formData.get("email") ?? ""),
      message: String(formData.get("message") ?? ""),
      source:  { page: "/contact" },
    });
    return { ok: true as const, code: ticket.short_code };
  } catch (e) {
    if (e instanceof UniportError && e.code === "rate_limited") {
      return { ok: false as const, error: `Try again in ${e.retryAfterSeconds ?? 60}s.` };
    }
    return { ok: false as const, error: "Could not send that. Try again." };
  }
}

createUniport is about forty lines of fetch and one error class. Copy it out of the docs into your own repo — there is no package to install and no version of ours to stay in step with. The integration guide → covers curl, the Next.js route and the plain-HTML path, with attachments and the full error table.

The contract

What the endpoint promises when things go wrong.

An intake endpoint is an unauthenticated write path with a stranger on the other end. It is built like one.

5xx

A failure wrote nothing.

The ticket, its first message and its attachments go in as one transaction, with the short code retried on collision. A 5xx means nothing was written — there is no half-created ticket to clean up, so retrying with backoff is safe.

429

The limit tells you how long to wait.

Fixed windows kept in Postgres, not in a process that forgets on redeploy: 120 requests per IP per ten minutes before auth is even checked, 20 per key by default. The body carries retry_after_seconds and the header carries Retry-After.

error envelope

One shape, everywhere.

Switch on code — it is the stable half of the contract. message is written for humans and may be reworded without notice; details names the offending field.

422

Spam is an answer, not silence.

flagged_as_spam means the filter rejected the message and nothing was created. Show the sender a soft failure. Retrying the same body fails identically, by design.

attachments

Files never pass through the API.

POST /api/v1/upload mints a short-lived token, the file goes straight to blob storage, and the ticket references the URL. 25 MB per file, 10 per ticket, and only blob-storage URLs are accepted — a ticket is not a way to make your support screen open an arbitrary link.

rendering

Every submitted field is escaped.

Dashboard, customer page, outbound email: the same escaping on all three, because a support form is a box where strangers type HTML at you. Data lives in Neon Postgres; /api/health returns 503 rather than 200 when intake could not write, so your uptime monitor learns about it before your customers do.

every error, everywhere
{
  "error": {
    "code": "invalid_request",
    "message": "Request body failed validation.",
    "details": { "fieldErrors": { "email": ["Invalid email address"] } }
  }
}

// 429 adds retry_after_seconds at the top level, mirroring Retry-After:
{
  "error": { "code": "rate_limited", "message": "Too many submissions for this project key." },
  "retry_after_seconds": 47
}

Thirteen error codes, each with the fix, are tabulated on /docs.

Triage

One ticket. Then the next one.

Focus serves the oldest open ticket across every project you run, and nothing else. Read it, write the reply, pick one of three: skip, send, or send and resolve. The queue advances on its own and the next customer is already on screen. When the queue empties the screen says so, and that is the entire reward.

One button lifts the customer’s message to your clipboard for whatever assistant you write with — paste the answer back, send, move on. Uniport does not write your replies and does not pretend to. When you want the wide view, /all splits into what needs you and what is already resolved, each row tagged with the project it came from, and /tickets/CODE holds the full thread with reply, resolve and reopen.

The Focus screen, at rest.

The other end

Your customer gets a page, not a no-reply.

Every ticket comes back with a status_url. The link is the credential — a high-entropy token, private to the person who wrote in — so they open the thread, read your answer and write back without an account, a password, or a create-a-login screen.

https://uniport.sh/t/BOUNCY-A3F291E7?k=6b1d9f0c2e4a7b83c5d1e0f9a2b3c4d5

A short code without its token is a 404. Rate limited before the ticket is even looked up, so a throttled response tells an attacker nothing about which codes exist. Never indexed.

Their reply reopens the ticket, drops it back in your queue and tells you it happened. Yours goes out as a branded email under your project’s name — DKIM-signed, DMARC-aligned — and every send is written to a delivery log you can read. The reply is durable before delivery is attempted: if the mail fails, the message still exists and you are told, rather than hearing about it from the customer a week later.

Then once a day, at the hour and in the timezone you pick, a digest of what is still open. It skips the days when there is nothing to say, because a daily email that is usually empty is a daily email you stop reading. The point of a digest is that it replaces the notifications, not that it joins them.

Two switches on your side: an instant email when a ticket lands or a customer writes back, and the digest. The email your customer gets when you reply is not optional — that is the point.

For terminals and agents

Everything you can click, a program can call.

uniport is one Rust binary. In a terminal it prints tables; piped, or with --json, it prints an envelope. An empty list comes back as no_results rather than an error, so a script never has to guess. The question is not whether Uniport can be automated. It is which of you is doing the triage tonight.

$ uniport submit --email ada@example.com --message 'The export button 500s'
opened ticket BOUNCY-A3F291E7
  https://uniport.sh/t/BOUNCY-A3F291E7?k=6b1d9f0c2e4a7b83c5d1e0f9a2b3c4d5

$ uniport tickets list --status open --json | jq -r '.data.tickets[].short_code'
BOUNCY-A3F291E7
HEALTR-5C0E7710

$ uniport tickets reply BOUNCY-A3F291E7 --message 'Fixed in 2026.7.4' --resolve
replied to BOUNCY-A3F291E7 (resolved)
ExitMeaningWhat to do
0SuccessRead .data from the envelope.
1Transient — network, 5xx, or a route this server has not deployed yetRetry.
2Configuration — key missing, wrong type, or rejectedRun uniport doctor and do what it prints.
3Bad input — arguments, unknown ticket, spam-flagged, missing --confirmFix the arguments.
4Rate limitedThe message carries retry_after_seconds. Wait that long.

uniport agent-info prints the whole surface as JSON, so an agent with the binary on PATH needs no docs, no MCP server and no skill file. uniport doctor names the exact key that is missing or wrong and prints the literal command that fixes it. Keys are never accepted as flags, because argv is readable by anything else on the machine — and the prefix is checked locally, so a swapped key fails with “wrong key type” before a request goes out.

submit and reply hold a duplicate-run lock keyed on the payload, so a retried agent cannot open the same ticket twice or send the same customer the same email twice.

The CLI is HTTP underneath: list and read tickets, reply, resolve, reopen, manage projects, mint and revoke project keys. Everything the dashboard can do, a personal usk_ token can do.

  • /openapi.json

    OpenAPI 3.1 for the whole v1 surface, both security schemes and the error envelope included. Feed it to a generator.

  • /llms.txt

    The short version for coding agents: base URL, the two key types, the three calls that matter.

  • /api/v1/ping

    No auth and no database, so an agent can confirm a base URL before it goes hunting for a credential.

Ask us

This form is Uniport.

It makes the same POST to /api/v1/intake as every integration above, with a upk_ key held on the server. Your message becomes a ticket in our own Focus queue, and the short code and thread link come back to you. Ask what it does, what it refuses to do, or whether the CLI will do the specific thing you need.

Twenty submissions per ten minutes, the same limit every project key gets. Nothing here is a special case.

Where this stands

Signup is closed. On purpose.

Uniport runs as a single-operator instance holding other people’s customers’ email addresses, so public accounts are not on today. Everything else is open: the docs, the OpenAPI spec, the CLI source, the health endpoint, and the queue behind the form above. If you want an instance, say so in that form — it is the same queue, and it is read.

Built by Boris Djordjevic at Paperfoot AI, who ships several products and got tired of answering all of them in five different tabs.