Most notification tooling stops at delivery. You wire up a provider, send the push, and then you're on your own for everything that matters: rendering something richer than plain text, collecting a real answer, and routing that answer back into your code.
Roundtrip is built around the whole loop. An app or an agent sends a notification, a card, or a form to a person's phone. The person responds with a tap, a form submission, or a message. The response comes back to your app through a webhook. One request out, one structured answer back: the round trip.
This post walks the entire path end to end. It takes a few minutes.
What you need
You need a Roundtrip workspace with at least one channel and a workspace-scoped API key (it begins with ak_). Both are managed in the Roundtrip console. A channel is the destination you publish to, and the people subscribed to it get the notification on their phones.
That's the whole setup. No carriers to configure, no Business API to apply for, no mobile app to build and ship through review.
Install the SDK
The SDK wraps the HTTP API with types, payload validation, and a webhook verifier.
pnpm add @roundtrip/sdkKeep your credentials in the environment, and never commit the key.
ROUNDTRIP_API_KEY=ak_xxxxxxxxxxxxxxxxxxxx
ROUNDTRIP_BASE_URL=https://api.roundtrip.shimport { RoundtripClient } from "@roundtrip/sdk";
export const roundtrip = new RoundtripClient({
apiKey: process.env.ROUNDTRIP_API_KEY!,
baseUrl: process.env.ROUNDTRIP_BASE_URL!,
});The constructor throws immediately if the key or base URL is missing, so a misconfiguration fails at startup rather than on your first publish.
Send a notification
The smallest useful request is a channel and a notification title. Publish to a channel, and its subscribers get notified on mobile.
import { roundtrip } from "./roundtrip";
await roundtrip.requests.create({
channel: "deploys",
notification: { title: "Build #1283 passed" },
});That's the whole API for a basic alert. Use it for build results, cron output, or anything you'd otherwise pipe to a logging channel. When something needs to break through, set a priority:
await roundtrip.requests.create({
channel: "alerts",
notification: { title: "Disk at 96% on db-primary", priority: "critical" },
});requests.create runs the same validator the server runs before anything leaves your process, so an invalid payload throws a descriptive error synchronously instead of coming back as a 400.
Ask for a decision
A notification that only informs is half the value. Add a content layer with structured sections and actions, and the notification becomes something a person can answer.
import { roundtrip } from "./roundtrip";
import { content, metric, text, approveReject } from "@roundtrip/sdk";
await roundtrip.requests.create({
channel: "ops",
notification: { title: "Acme Corp · Quote #0042", priority: "high" },
content: content({
title: "Acme Corp · Quote #0042",
status: { label: "Awaiting approval", tone: "warning" },
sections: [
metric("Total", "$48,200", { delta: "+12% vs last", tone: "info" }),
text("Net-30 terms. Quote expires in 7 days."),
],
actions: approveReject(),
}),
response: {
mode: "required",
webhook: "https://api.example.com/roundtrip/callbacks",
},
});The builders (content, metric, text, approveReject) give you autocomplete and stop you mistyping a block, so the shape is correct before it's sent. requests.create returns as soon as the request is published, and it does not block on the person. The answer arrives later, at the webhook you named.
Close the loop
When the person responds, Roundtrip POSTs a signed callback to your webhook. The signature is an HMAC-SHA256 of the raw body under your WEBHOOK_SECRET, sent in the X-Signature header. The SDK ships a verifier, so you never hand-roll the crypto.
import express from "express";
import { parseWebhook, WebhookVerificationError } from "@roundtrip/sdk";
const app = express();
app.post(
"/roundtrip/callbacks",
express.raw({ type: "application/json" }), // capture the RAW body
(req, res) => {
try {
const event = parseWebhook(
req.body, // the raw bytes, not re-serialized JSON
req.get("X-Signature"),
process.env.WEBHOOK_SECRET ?? ""
);
if (event.action === "approve") {
// ...proceed with the approved action
} else if (event.action === "reject") {
// ...handle the rejection
}
res.status(200).end();
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.status(401).end(); // signature mismatch, drop it
return;
}
throw err;
}
}
);Verify the raw bytes. The signature is computed over the exact body Roundtrip sent, so check it before any JSON re-serialization. A body parser that re-encodes the payload will break the signature. Capture the raw body and pass it straight to parseWebhook.
The callback carries the requestId from your original publish, which is your correlation key. It also carries the tapped action, any submitted form values, and the developer metadata you attached, echoed back verbatim.
Handle the edges
A non-2xx response throws a RoundtripApiError carrying the HTTP status, the server message, and a machine-readable code when one is present. The most common one to handle explicitly is a plan quota:
import { RoundtripApiError } from "@roundtrip/sdk";
try {
await roundtrip.requests.create(payload);
} catch (err) {
if (err instanceof RoundtripApiError && err.code === "QUOTA_EXCEEDED") {
// HTTP 402. The request was NOT sent.
console.error("Upgrade the workspace plan to send more.");
} else {
throw err;
}
}That's the round trip
You published a request, it rendered on a phone, a person answered, and you verified the signed result back in your own code. The same path works for build alerts, approvals, on-call escalations, and anything else where software needs a human in the loop.
From here, the Quickstart covers richer cards and form responses, and the CLI and MCP server let an AI agent ask for human approval the same way your code just did.