Event notifications
Receive signed message notifications and queue reliable work
A webhook tells your service which message arrived. Verify the notification, persist a metadata job, and acknowledge it. A separate worker retrieves that exact message with selected-number API access.
Configure a public HTTPS receiver
Add a webhook in workspace automation settings and explicitly select its lines. For an existing subscription with no selections, open Configure selected lines, select the intended destinations and save. It receives new events after that selection; receipts from the dormant interval are available through authorized message reads. You can also manage subscriptions with an authorized webhooks:manage credential through API, SDK, CLI or MCP. Its destination must be a public HTTPS URL on port 443; private-network destinations are rejected. Store the generated signing secret in your service's secret manager as PHONEKIT_WEBHOOK_SECRET.
For this Next.js example, install zod and postgres in your own app. Use a server environment DATABASE_URL for your application's metadata-job database. Put the receiver at app/api/phonekit/route.ts and configure its deployed URL in Phonekit.
The receiver uses a signing secret to authenticate events. Its worker uses a separate PHONEKIT_API_KEY, explicitly scoped to the numbers it must read. The two credentials serve different purposes.
The message.received event
The event ID is the message ID. The notification carries identity and receipt time; it contains no sender, body, detected code, or invitation link.
{
"id": "8ALpC7nK2dQ4sE6rT9vX1Z",
"type": "message.received",
"data": {
"messageId": "8ALpC7nK2dQ4sE6rT9vX1Z",
"numberId": "4JfW8bN2mR6pT1qV9xK3cH",
"receivedAt": "2026-09-30T12:00:00.000Z"
}
}Use data.messageId to fetch the exact retained message. Looking up the latest inbox item could return a different receipt by the time the worker runs.
Verify the raw bytes before parsing JSON
phonekit-timestamp is Unix time in seconds. phonekit-signature is the lowercase hexadecimal HMAC-SHA256 digest of the timestamp, a period, and the exact request body. The HMAC key is your generated signing secret. Reserializing JSON changes signed bytes.
This receiver checks a five-minute timestamp window as a local replay policy, compares digests in constant time, limits metadata bodies to 16 KiB, and validates the event with Zod. Copy it alongside the Route Handler as receiver.ts:
import { createHmac, timingSafeEqual } from "node:crypto"
import { z } from "zod"
const eventSchema = z
.object({
id: z.string().min(1).max(128),
type: z.literal("message.received"),
data: z
.object({
messageId: z.string().min(1).max(128),
numberId: z.string().min(1).max(128),
receivedAt: z.iso.datetime(),
})
.strict(),
})
.strict()
.refine((event) => event.id === event.data.messageId)
export type MessageReceivedEvent = z.infer<typeof eventSchema>
export interface ReceiverOptions {
signingSecret: string
/** Resolve only after an atomic, durable enqueue/dedup transaction commits. */
enqueueOnce(event: MessageReceivedEvent): Promise<"queued" | "duplicate">
}
function validTimestamp(timestamp: string): boolean {
return (
/^\d{1,12}$/.test(timestamp) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
)
}
export function verifySignature(
raw: Buffer,
timestamp: string,
signature: string,
secret: string
): boolean {
if (!validTimestamp(timestamp) || !/^[a-f0-9]{64}$/.test(signature))
return false
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(raw)
.digest()
return timingSafeEqual(expected, Buffer.from(signature, "hex"))
}
class BodyTooLarge extends Error {}
async function readRawBody(request: Request): Promise<Buffer> {
const limit = 16_384
const declared = Number(request.headers.get("content-length"))
if (Number.isFinite(declared) && declared > limit) throw new BodyTooLarge()
const reader = request.body?.getReader()
if (!reader) return Buffer.alloc(0)
const parts: Buffer[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) return Buffer.concat(parts, total)
total += value.byteLength
if (total > limit) {
await reader.cancel()
throw new BodyTooLarge()
}
parts.push(Buffer.from(value))
}
} finally {
reader.releaseLock()
}
}
/** Mount the returned POST function in a Next.js Node-runtime Route Handler. */
export function createPhonekitReceiver(
options: ReceiverOptions
): (request: Request) => Promise<Response> {
if (!options.signingSecret) throw new Error("Set PHONEKIT_WEBHOOK_SECRET")
return async function POST(request: Request): Promise<Response> {
const timestamp = request.headers.get("phonekit-timestamp") ?? ""
const signature = request.headers.get("phonekit-signature") ?? ""
if (!validTimestamp(timestamp) || !/^[a-f0-9]{64}$/.test(signature))
return new Response(null, { status: 401 })
let raw: Buffer
try {
raw = await readRawBody(request)
} catch (error) {
return new Response(null, {
status: error instanceof BodyTooLarge ? 413 : 400,
})
}
if (!verifySignature(raw, timestamp, signature, options.signingSecret))
return new Response(null, { status: 401 })
let payload: unknown
try {
payload = JSON.parse(raw.toString("utf8")) as unknown
} catch {
return new Response(null, { status: 400 })
}
const parsed = eventSchema.safeParse(payload)
if (!parsed.success) return new Response(null, { status: 400 })
try {
// Await persistence before acknowledging. No detached promise after response.
await options.enqueueOnce(parsed.data)
return new Response(null, { status: 204 })
} catch {
// Let the sender retry when the job could not be committed.
return new Response(null, { status: 503 })
}
}
}Mount the returned POST function using the Node runtime, which supplies Node crypto and the database client:
// Copy to app/api/phonekit/route.ts, keeping receiver.ts and jobs.ts alongside it.
import { createPhonekitReceiver } from "./receiver"
import { enqueueOnce } from "./jobs"
export const runtime = "nodejs"
const signingSecret = process.env["PHONEKIT_WEBHOOK_SECRET"]
if (!signingSecret) throw new Error("Set PHONEKIT_WEBHOOK_SECRET")
export const POST = createPhonekitReceiver({ signingSecret, enqueueOnce })Next.js App Router handlers use the Web Request body directly. Preserve it for signature verification; do not call request.json() first. Save the receiver code above in your own project and implement its durable enqueueOnce contract as shown below.
Persist the job before acknowledging
enqueueOnce() must commit both deduplication and job creation before it resolves. A database uniqueness check followed by a separate queue publish can lose work if the process crashes between them.
This small Postgres adapter makes the metadata row itself the durable queue entry. Run the schema in your application's own database:
-- Run in your application's own database, not the Phonekit database.
CREATE TABLE phonekit_message_jobs (
event_id text PRIMARY KEY,
message_id text NOT NULL,
number_id text NOT NULL,
received_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);import postgres from "postgres"
import type { MessageReceivedEvent } from "./receiver"
const databaseUrl = process.env["DATABASE_URL"]
if (!databaseUrl) throw new Error("Set your application's DATABASE_URL")
export const jobsDb = postgres(databaseUrl, { max: 5 })
/** The insert is both deduplication and durable queueing, in one atomic statement. */
export async function enqueueOnce(
event: MessageReceivedEvent
): Promise<"queued" | "duplicate"> {
const inserted = await jobsDb<{ event_id: string }[]>`
INSERT INTO phonekit_message_jobs (event_id, message_id, number_id, received_at)
VALUES (${event.id}, ${event.data.messageId}, ${event.data.numberId}, ${event.data.receivedAt})
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id
`
return inserted.length ? "queued" : "duplicate"
}The receiver returns 204 only after a successful insert or a confirmed duplicate. If persistence fails, it returns 503 so delivery can be retried. It does not launch an unawaited promise after returning the HTTP response.
Delivery contract
A valid notification has a persisted metadata job before Phonekit receives a successful acknowledgement. A repeated event ID maps to the same job. This provides durable enqueueing; your application's side effects still need idempotency when a worker retries.
Retrieve content from a separate worker
Install the SDK in the worker project. The selected key must include the notified message's number. This example processes one pending metadata job:
import { PhonekitClient } from "@phonekit/sdk"
import type { PhonekitMessage } from "@phonekit/sdk"
import { jobsDb } from "./jobs"
/**
* Invoke from a separately scheduled, single worker. A restart can repeat an
* unfinished job: consume must be idempotent by eventId. No SMS is saved here.
*/
export async function processNextJob(
consume: (input: {
eventId: string
message: PhonekitMessage
}) => Promise<void>
): Promise<boolean> {
const apiKey = process.env["PHONEKIT_API_KEY"]
if (!apiKey) throw new Error("Set PHONEKIT_API_KEY in the worker")
const [job] = await jobsDb<{ event_id: string; message_id: string }[]>`
SELECT event_id, message_id FROM phonekit_message_jobs
WHERE completed_at IS NULL
ORDER BY created_at, event_id
LIMIT 1
`
if (!job) return false
const client = new PhonekitClient(apiKey)
const message = await client.message(job.message_id)
await consume({ eventId: job.event_id, message })
await jobsDb`
UPDATE phonekit_message_jobs SET completed_at = now()
WHERE event_id = ${job.event_id}
`
return true
}Invoke processNextJob() from a separately scheduled worker, passing your application's awaited consume({ eventId, message }) function. For this small adapter, run one worker and serialize calls. Use your existing queue or add atomic claims/leases before scaling to concurrent workers.
A worker crash can happen after your application acts but before completed_at is saved. Make those actions idempotent by eventId. Apply bounded retry delays and attempt limits in your scheduler, expose failed jobs for inspection, and stop retrying permanent authorization or retention failures. Message contents stay in memory; the queue contains metadata only.
Recover delivery gaps
Save an inbox cursor for each subscribed line. A notification carries a stable message/event ID; duplicate deliveries keep that identity. If a notification is missed, read messages/after immediately from the saved position and process the returned receipts in order. Persist progress after idempotent processing. Retention and read grants still apply.
An authorized diagnostics:read connection can inspect delivery status, HTTP status and sanitized attempt categories. webhooks:manage can retry a failed delivery. Destination response bodies and message contents are excluded from diagnostics.
Verify your receiver
Test the receiver in your own application before enabling a live subscription:
- Sign an exact raw JSON body with your test secret and the current timestamp. It should enqueue one metadata job and acknowledge it.
- Change one byte without changing the signature, then try an expired and a future timestamp. Each must be rejected without enqueueing.
- Send malformed events and oversized bodies. Confirm rejection before any job is saved.
- Send the valid event twice. Both deliveries can be acknowledged; only one durable job should exist.
- Force the queue transaction to fail. The receiver must return a failure, allowing Phonekit to retry; it must not acknowledge unsaved work.
- Restart the worker after its action but before completion is saved. Reprocessing the event must not duplicate your application’s action.
Then exercise your deployed receiver with a real retained message. Check that a metadata row appears, the worker can read that exact message with its selected key, and your application's result completes once under duplicate delivery. Fixture success does not establish live HTTPS connectivity or carrier delivery.
Questions about notifications
Can the signing secret retrieve a code from Phonekit?
The signing secret verifies the notification only. Fetch content with a separate read-only API key selected for that message's number. This keeps message contents out of the event and lets you manage notification authentication separately from read access.
Why did signature verification fail after I parsed the body?
The signature covers raw bytes, including whitespace. Parsing and serializing JSON can change those bytes even when the objects look equal. Read the body once, verify its raw bytes with the header timestamp, and only then parse and validate it. Also check your receiver clock and that it has the generated secret for this webhook.
How should I handle duplicate deliveries?
Persist a unique event ID with the job in the same atomic operation. An already committed event can receive another 2xx acknowledgement without creating a second job. Worker retries are a separate concern: use that same ID to make application side effects idempotent. An in-memory set loses this protection when your service restarts or scales.
Why can my worker receive 404 for a valid signed event?
A valid signature does not grant message-read permission. Check whether the worker's key selects the notified number and whether the message remains in retention. An event can also wait too long in your own queue. Inspect permanent failures rather than substituting a different latest message or retrying forever.
Do Playwright and coding agents need a webhook server?
They can use cursor-based SDK or CLI waits directly from their trusted runner. A webhook is useful when an existing background service wants work enqueued as messages arrive. Choose based on where the task executes and whether you already operate a receiver and worker; both routes use selected-number read access.