Use with Hono and Bun
Send SMS and receive verified webhooks from a Hono app or a plain Bun.serve server.
This guide adds routes that send SMS and receive provider webhooks to a Hono app or a plain Bun.serve server. Both hand you a web Request, which parseSmsWebhook() takes directly, so you need no glue code. The repository’s examples/webhooks has a framework-free handler that works the same way.
Hono
import { Hono } from "hono";
import { createSmsClient, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";
const sms = createSmsClient({
adapters: [
twilio({
accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
from: { messagingService: process.env.TWILIO_MESSAGING_SERVICE_SID ?? "" },
}),
],
});
const app = new Hono();
app.post("/reminders", async (c) => {
const body: unknown = await c.req.json();
const to = typeof body === "object" && body !== null && "to" in body ? body.to : undefined;
if (typeof to !== "string" || !isE164(to)) {
return c.json({ error: "to must be an E.164 number" }, 400);
}
try {
const result = await sms.send({ to, body: "Reminder: your appointment is tomorrow." });
return c.json({ providerId: result.providerId });
} catch (error) {
if (isSmsError(error)) return c.json(error.toJSON(), error.retrySafe ? 422 : 500);
throw error;
}
});
app.post("/webhooks/sms/twilio", async (c) => {
try {
const event = await parseSmsWebhook({
provider: "twilio",
request: c.req.raw,
publicUrl: "https://example.com/webhooks/sms/twilio",
credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
});
if (await db.webhookEvents.insertIfNew(event.dedupeKey)) {
if (event.type === "message.delivered" || event.type === "message.undelivered") {
await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
} else if (event.type === "recipient.opted_out") {
await db.suppressions.add(event.from);
}
}
return c.body("<Response></Response>", 200, { "content-type": "text/xml" });
} catch (error) {
if (error instanceof WebhookSignatureError) return c.text("Invalid signature", 401);
throw error;
}
});
export default app;
Run it with bun run server.ts. Bun serves the default export on port 3000.
Pass c.req.raw, the untouched web Request. Do not call c.req.json(), c.req.text(), or c.req.parseBody() on a webhook route first. The body can be read only once, and the signature covers the raw bytes.
The same app runs on Node.js with @hono/node-server and on any other Hono target that has fetch and Web Crypto. Only Node.js and Bun are tested.
Plain Bun.serve
import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";
Bun.serve({
port: 3000,
async fetch(request) {
const { pathname } = new URL(request.url);
if (request.method !== "POST" || pathname !== "/webhooks/sms/telnyx") {
return new Response("Not found", { status: 404 });
}
try {
const event = await parseSmsWebhook({
provider: "telnyx",
request,
credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
});
if (await db.webhookEvents.insertIfNew(event.dedupeKey)) {
console.log(event.type, event.dedupeKey);
}
return new Response(null, { status: 200 });
} catch (error) {
if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
throw error;
}
},
});
Bun loads .env automatically, so in development process.env.TELNYX_PUBLIC_KEY comes from your .env file.
Behind a proxy
Bun and Hono see the local listener’s URL, such as http://localhost:3000/webhooks/sms/twilio, but Twilio and Plivo sign the public one. For those two, pass publicUrl, or trustProxy: true when your own proxy sets X-Forwarded-Proto and X-Forwarded-Host. See Webhook security.