U
Uniport
OpenAPISign in →

API documentation

One POST and the ticket is filed.

Every contact form, bug report and support request across every product you run, landing in one inbox. No SDK to install, no webhooks to configure, no mailbox to babysit.

Start here

Quickstart

Three steps, about five minutes, and the first ticket shows up in Focus.
  1. 1

    Mint a project key

    In Uniport, go to Settings → Projects and create a project for the app you are wiring up. Each project gets its own key, prefixed upk_, and its tickets get short codes prefixed with the project slug.

    The key is shown once. It is hashed on write and cannot be recovered — if you lose it, mint another and revoke the old one.

  2. 2

    Store it as a server-side secret

    A project key can file tickets as your app. That makes it a backend secret, in the same drawer as your database URL — never in a client bundle, never in a public repo.

    .env.local
    UNIPORT_KEY=upk_Xh2Qa8vN3rLdT7pJmWcE1yZbQf5oR9kU

    On Vercel: printf '%s' "$KEY" | vercel env add UNIPORT_KEY production.

  3. 3

    Post the form

    Two required fields — email and message. Anything else you send is context that makes the ticket easier to answer.

    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" }
      }'
    HTTP/1.1 201 Created
    X-RateLimit-Limit: 20
    X-RateLimit-Remaining: 19
    
    {
      "ok": true,
      "ticket_id": "1f9a3c6e-88b2-4f1d-9a17-2c5e7b0d4a33",
      "short_code": "BOUNCY-A3F291E7",
      "status_url": "https://uniport.sh/t/BOUNCY-A3F291E7?k=6b1d9f0c2e4a7b83c5d1e0f9a2b3c4d5"
    }

    Show short_code in your success message so the customer can quote it. Send them status_url if you want them to be able to follow the thread — it carries a capability token, so treat it as private to that one customer.

Integrate

Three ways to wire it up

Pick whichever matches your stack. The contract is the same in all three; only the plumbing differs.

curl, or any HTTP client

The whole intake API is one endpoint. If your language can POST JSON, you are done — there is nothing else to learn.

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" }
  }'

Next.js server action

Copy this file into your project. It is the entire client: no dependency, no install, no version to keep in step with ours.

lib/uniport.ts
// lib/uniport.ts — the whole client. No dependency, no install.

export class UniportError extends Error {
  constructor(
    readonly code: string,
    readonly status: number,
    readonly retryAfterSeconds?: number,
    message?: string,
  ) {
    super(message ?? `Uniport ${status}: ${code}`);
    this.name = "UniportError";
  }
}

export interface UniportSubmission {
  email: string;
  message: string;
  name?: string;
  subject?: string;
  /** Free-form context stored on the ticket. Under 4 KB serialized. */
  source?: Record<string, unknown>;
  /** Blob URLs from /api/v1/upload. Nothing else is accepted. */
  attachments?: Array<{
    url: string;
    filename: string;
    contentType?: string;
    sizeBytes?: number;
  }>;
}

export interface UniportTicket {
  ticket_id: string;
  short_code: string;
  /** Public thread link, capability token included. For the customer only. */
  status_url: string;
}

export function createUniport({
  key,
  baseUrl = "https://uniport.sh",
}: {
  key: string;
  baseUrl?: string;
}) {
  return {
    async submit(input: UniportSubmission): Promise<UniportTicket> {
      const res = await fetch(`${baseUrl}/api/v1/intake`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${key}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(input),
        cache: "no-store",
      });

      // Errors are JSON too; a non-JSON body means something in front of the
      // API answered, so fall back to the status rather than throwing on parse.
      const body = await res.json().catch(() => null);

      if (!res.ok) {
        // Body first, header second. Number(null) is 0, so ?? would happily
        // hand you a zero-second backoff on a missing header — || is right here.
        const retryAfter =
          typeof body?.retry_after_seconds === "number"
            ? body.retry_after_seconds
            : Number(res.headers.get("retry-after")) || undefined;

        throw new UniportError(
          body?.error?.code ?? "unknown_error",
          res.status,
          retryAfter,
          body?.error?.message,
        );
      }

      return body as UniportTicket;
    },
  };
}

Then call it from a server action. Because the action runs on the server, the key stays there — the form component never imports it.

app/contact/actions.ts
// 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") ?? ""),
      subject: String(formData.get("subject") ?? ""),
      message: String(formData.get("message") ?? ""),
      source: { page: "/contact" },
    });
    return { ok: true as const, code: ticket.short_code };
  } catch (e) {
    if (e instanceof UniportError) {
      if (e.code === "rate_limited") {
        const wait = e.retryAfterSeconds ?? 60;
        return { ok: false as const, error: `Too many messages. Try again in ${wait}s.` };
      }
      if (e.code === "flagged_as_spam") {
        return { ok: false as const, error: "That message looked like spam to our filter." };
      }
      console.error("[contact] uniport", e.code, e.status);
    }
    return { ok: false as const, error: "Could not send that. Try again." };
  }
}

Plain HTML and fetch

A static site still needs one server-side hop, because the browser must not hold the key. Point the form at your own endpoint and let that endpoint carry the secret.

contact.html
<form id="contact">
  <input name="name" placeholder="Your name" />
  <input name="email" type="email" placeholder="you@example.com" required />
  <textarea name="message" placeholder="What went wrong?" required></textarea>
  <button type="submit">Send</button>
</form>

<script>
  document.getElementById("contact").addEventListener("submit", async (e) => {
    e.preventDefault();
    const form = e.target;
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(form))),
    });
    const body = await res.json();
    form.outerHTML = res.ok
      ? "<p>Got it. Your ticket is " + body.short_code + ".</p>"
      : "<p>Sorry, that did not send (" + body.error.code + ").</p>";
  });
</script>
your server
// /api/contact on YOUR server — any runtime, any framework.
// The browser never sees UNIPORT_KEY: a upk_ key in a bundle lets anyone
// on the internet file tickets as your app.
export async function POST(req) {
  const input = await req.json();

  const res = await fetch("https://uniport.sh/api/v1/intake", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UNIPORT_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: input.name,
      email: input.email,
      message: input.message,
      source: { page: req.headers.get("referer") },
    }),
  });

  // Pass the envelope straight through: the codes are already stable and
  // safe to show, and re-inventing them costs you the retry semantics.
  return new Response(await res.text(), {
    status: res.status,
    headers: { "Content-Type": "application/json" },
  });
}
No backend at all? Then the honest options are a serverless function (Vercel, Cloudflare Workers, Netlify — all free at this volume) or a form service that can hold the secret for you. There is no safe way to put upk_ in a page anyone can view-source.

Files

Attachments

Screenshots are most of what makes a bug report actionable. Files go straight to blob storage and the ticket references them, so nothing large ever passes through the intake function.
Step 1 — handshake
# 1. Your server asks for a short-lived upload token.
curl -X POST https://uniport.sh/api/v1/upload \
  -H "Authorization: Bearer $UNIPORT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "blob.generate-client-token",
    "payload": {
      "pathname": "screenshot.png",
      "multipart": false,
      "clientPayload": null
    }
  }'

# → { "type": "blob.generate-client-token", "clientToken": "vercel_blob_client_…" }
Step 2 — the file itself
// 2. The file goes straight to Vercel Blob with that token — it never
//    passes through Uniport, so the 4.5 MB serverless body limit is moot.
import { put } from "@vercel/blob/client";

const blob = await put(file.name, file, {
  access: "public",
  token: clientToken, // from step 1, relayed by your own route
});

// blob.url → https://<store>.public.blob.vercel-storage.com/screenshot-9f2c.png
Step 3 — file the ticket
// 3. File the ticket, referencing the blob.
{
  "email": "jane@example.com",
  "message": "Screenshot attached.",
  "attachments": [
    {
      "url": "https://abc123.public.blob.vercel-storage.com/screenshot-9f2c.png",
      "filename": "screenshot.png",
      "contentType": "image/png",
      "sizeBytes": 184320
    }
  ]
}
Intake accepts attachment URLs only on https://*.public.blob.vercel-storage.com. An arbitrary URL is rejected — otherwise a ticket would be a vector for rendering whatever a submitter wanted your support agents to open.
  • Limits: 25 MB per file, 10 attachments per ticket.
  • Types: PNG, JPEG, WebP, GIF, HEIC, PDF, plain text, ZIP.
  • Durability: blobs are not covered by the database backups. A ticket outlives its attachments.

When it fails

Error codes

One envelope, everywhere. Switch on error.code — it is the stable part of the contract. error.message is written for humans and may be reworded without notice.

{
  "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…" },
  "retry_after_seconds": 47
}
CodeHTTPWhat to do
missing_authorization401No Bearer header reached us. Add Authorization: Bearer upk_… — check your proxy is forwarding headers.
invalid_key401Revoked, expired, or the wrong population: intake wants a project key (upk_), the management API wants a personal key (usk_). Mint a fresh one and swap the env var.
rate_limited429Wait retry_after_seconds (in the body, and in the Retry-After header) and retry. Windows are fixed, so the wait is bounded. Do not tight-loop.
invalid_json400The body was not parseable JSON — usually a missing Content-Type or a value that was never serialized. Intake and upload only; the management API reports the same failure as invalid_request.
invalid_request400Validation failed; error.details names the offending field. Retrying the same body will fail identically — fix it or surface it to the user.
flagged_as_spam422The filter rejected the message and nothing was created. Show a soft failure, never a retry loop.
project_not_found404The key is valid but its project was deleted. Mint a key on a live project.
not_found404Management API: no such ticket, project or key — or it belongs to someone else. The two cases are deliberately indistinguishable.
unsupported_content_type415Upload only. Either the request was not application/json, or the file type is not on the accepted list.
file_too_large413Upload only. 25 MB is the ceiling per file.
upload_failed400Upload only. No token could be created for that file. Check the pathname has an extension.
upload_not_configured500Blob storage is unavailable on our side. File the ticket without the attachment rather than dropping the report.
internal_error500Our fault, and nothing was written. Retry with backoff — a 5xx from intake never means a half-created ticket.
Retry rules in one line: retry 429 after the stated wait and 5xx with backoff. Never retry a 4xx unchanged — nothing about the second attempt will differ.

For CLIs and agents

Management API

Reading and answering tickets from outside the dashboard. This surface uses a personal key, prefixed usk_, minted at Settings → Tokens. It acts as you across every project you are a member of.

The two key types never cross over: a project key is rejected here, a personal key is rejected by intake. Both are accepted by /whoami, which is the quickest way to find out which one you are holding. Every response carries X-RateLimit-* headers; the default budget is 240 requests per 10 minutes.

get/api/v1/pingno auth

Is this URL a Uniport API, and which version? Needs no auth and touches no database, so it stays up when Postgres does not.

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

Which key is this, what does it reach, and what limits apply to it. Accepts both key types.

curl https://uniport.sh/api/v1/whoami \
  -H "Authorization: Bearer $UNIPORT_TOKEN"
get/api/v1/ticketspersonal key

Tickets across all your projects, newest first. Filter with status (open, resolved, all) and project (slug). Keyset paginated: feed next_cursor back as cursor until it comes back null.

curl "https://uniport.sh/api/v1/tickets?status=open&limit=20" \
  -H "Authorization: Bearer $UNIPORT_TOKEN"
get/api/v1/tickets/{code}personal key

One ticket with its full thread, oldest message first, each with its attachments.

curl https://uniport.sh/api/v1/tickets/BOUNCY-A3F291E7 \
  -H "Authorization: Bearer $UNIPORT_TOKEN"
post/api/v1/tickets/{code}/replypersonal key

Append an agent reply and email the customer. The message is durable before the email is attempted, so email_sent: false means delivery failed, not that the reply was lost — resending would double-send. Pass resolve: true to answer and close in one call.

curl -X POST https://uniport.sh/api/v1/tickets/BOUNCY-A3F291E7/reply \
  -H "Authorization: Bearer $UNIPORT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Fixed in 2026.7.4 — can you retry?","resolve":true}'
patch/api/v1/tickets/{code}personal key

Open or resolve without sending the customer anything.

curl -X PATCH https://uniport.sh/api/v1/tickets/BOUNCY-A3F291E7 \
  -H "Authorization: Bearer $UNIPORT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"resolved"}'
get/api/v1/projectspersonal key

Your projects with their open-ticket counts.

curl https://uniport.sh/api/v1/projects \
  -H "Authorization: Bearer $UNIPORT_TOKEN"
get/api/v1/projects/{id}/keyspersonal key

Intake keys for one project: usage counts, last request, enabled state. Never the key material — that is stored as a hash.

curl https://uniport.sh/api/v1/projects/$PROJECT_ID/keys \
  -H "Authorization: Bearer $UNIPORT_TOKEN"
post/api/v1/projects/{id}/keyspersonal key

Mint an intake key. The raw upk_ value comes back exactly once — capture it before you close the response.

curl -X POST https://uniport.sh/api/v1/projects/$PROJECT_ID/keys \
  -H "Authorization: Bearer $UNIPORT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"marketing-site"}'
delete/api/v1/keys/{id}personal key

Revoke a key. Soft delete: the row survives for audit and the key stops authenticating immediately. Works on your project keys and on your own personal keys.

curl -X DELETE https://uniport.sh/api/v1/keys/$KEY_ID \
  -H "Authorization: Bearer $UNIPORT_TOKEN"

Paginating properly

# Walk every open ticket, one page at a time.
cursor=""
while :; do
  page=$(curl -s "https://uniport.sh/api/v1/tickets?limit=100&cursor=$cursor" \
    -H "Authorization: Bearer $UNIPORT_TOKEN")
  echo "$page" | jq -r '.tickets[] | "\(.short_code)\t\(.customer_email)"'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done

Machine readable

Spec and agent pointers

Everything above, in a form a code generator or an agent can consume without reading prose.
  • /openapi.json

    OpenAPI 3.1 for the whole v1 surface: intake, upload and every management endpoint, with the error envelope and both security schemes. Feed it to a client generator, or open it in any spec viewer.

  • /llms.txt

    The short version for coding agents: base URL, the two key types, and the handful of calls worth knowing. If you are asking an assistant to wire up a contact form, point it here first.

  • /api/health

    Liveness for uptime monitors. 200 means the function booted and can reach the database; 503 means intake would fail right now.

Something here wrong or missing? Sign in and file it against your own project — we read our own inbox.