Guides

Don't build the webhook delivery table. Install it.

A customer asks whether their webhook went out, and two weeks later you are speccing a deliveries page. Repost publishes that page as a shadcn registry.

Quentin Mousset4 min read
A balance scale. The raised left pan carries a stack of five cards labelled virtualized list, live tail, attempt chain, JSON viewer and replay button, annotated in coral as weeks of work. The lowered right pan carries a single line reading npx shadcn add @repost/delivery-history-table, with a check mark and the note one command. Under the scale, a dashed line and the sentence: the only part worth writing is the token endpoint.

A customer asks whether their webhook went out. You look it up, you answer, it takes four minutes. Then it happens every week, so you start speccing a deliveries page in your own dashboard: history, status, attempts, the request body, a replay button. That page is real work, and it is not your product.

Three questions fill that inbox, and they never change. Did you send it. What was in it. Can you send it again. Answering them for the customer instead of for yourself is a product surface, and you are about to build it.

Sketch it and it looks like a table. Build it and it stops being one. The list is thousands of rows a customer wants to scroll, so it is virtualized or it is janky. New deliveries land while they read, so it needs a live tail that doesn't yank the viewport out from under them. A row opens into a delivery, which is a chain of attempts, each with a status, a latency, request headers, a request body, response headers, a response body. The bodies are JSON, so now you are picking a viewer. Exhausted deliveries need somewhere to go, and somebody has to press replay.

None of it is hard. All of it is weeks. And the screen you get at the end is one your customers judge you on, sitting next to the screens you actually sell.

Install the table#

Everything the Repost portal shows is published as a shadcn registry: delivery history, the histogram, endpoint management, the dead-letter queue, the event catalog. Register the @repost namespace once in your components.json.

Registrycomponents.json
{
  "registries": {
    "@repost": "https://repost.sh/r/{name}.json"
  }
}
json

Then add a component the way you add any other.

Terminalshadcn CLI
$npx shadcn@latest add @repost/delivery-history-table
Time
Event
Endpoint
Status
Latency
Attempt
Live tailingCtrl+Space

The CLI copies the source into your project and resolves what it stands on: the virtualized table underneath, the badges, the npm packages those need, and the color tokens for delivery status, injected into your CSS with a light and a dark value each (Installation).

What lands is a file in your repo. DeliveryHistoryTable renders one row per attempt with time, event type, endpoint, status and latency. Its className is where you size the frame, because the table scrolls inside itself rather than making the page scroll on its behalf. It arrives inverted, the way the portal shows it: newest delivery at the visual bottom, scrollback loading upward. Wire onToggleStream and the bottom strip becomes a live tail, Ctrl+Space included (every prop).

Where the rows come from#

Nowhere, until you say. Nothing fetches inside the component: it renders what you pass it and calls back when someone clicks. The data is a separate decision, and that is the line to keep straight.

LevelYou getYou write
BlocksA working dashboard view per install, wired to live data.A getToken callback.
ComponentsPresentational pieces you feed with props.The data wiring, via the hooks.
LibrariesTyped hooks and a framework-agnostic client.All of the UI.

@repost/portal-react is the other half: hooks over one customer's webhook data, with a cache of its own, so an existing TanStack Query setup in your app is left alone.

Your dashboardapp/webhooks/deliveries.tsx
import { RepostPortalProvider, useLogsFeed } from "@repost/portal-react"
import { DeliveryHistoryTable } from "@/components/delivery-history-table"

export function Deliveries() {
  return (
    <RepostPortalProvider getToken={getToken}>
      <DeliveryList />
    </RepostPortalProvider>
  )
}

function DeliveryList() {
  const feed = useLogsFeed()

  return <DeliveryHistoryTable className="h-[420px]" rows={feed.rows} />
}
tsx

useLogsFeed runs a websocket on top of a REST backfill: deduped, newest first, with a buffer cap and a counter for the rows the stream dropped. There is no polling anywhere in the library.

The one part you write yourself#

The browser never sees your API key. Your backend mints a portal access token for the customer who is signed in, and hands that over instead.

Token endpointapp/api/repost-portal-token/route.ts
const url = "https://api.repost.sh/v1/customers/" + customerId + "/portal-access"

const response = await fetch(url, {
  method: "POST",
  headers: { Authorization: "Bearer " + process.env.REPOST_TOKEN },
})

const { token } = await response.json()
ts

getToken is a callback returning that token, and every wired piece takes one and handles refresh itself. Tokens are short-lived and scoped to one customer in one environment, so mint one per request and store none.

Mint it with readOnly: true and the whole surface still reads, live feed included, while every mutation comes back 403 forbidden. That is the support view your own team asks for the week after the customer view ships. usePortalContext exposes the flag, so the buttons can be gone before anyone gets to click one.

The drawer is where the weeks go#

The table is the easy half. The hard half is what opens when a customer clicks a row, and delivery-detail is the drawer the Repost app itself opens, ported whole.

RowDetailSheet is the sheet around it: right-hand, and deliberately not modal, so clicking another row swaps the detail in place instead of closing on you. Inside, the delivery's current state sits on top, the attempt trail underneath as a selectable table, and the request and response of the selected attempt below that, each with a headers and body toggle.

Those inspectors are Monaco in read-only mode, following your light and dark themes. ISO timestamps pick up a readable annotation inline. Hovering a line offers copy, plus include and exclude callbacks a query bar of yours can consume. A string body renders in whatever language the Content-Type resolves to.

Pass onReplay and the Replay action appears. Leave it out and it doesn't, which is how a read-only view stays read-only. If you want the attempt trail without the drawer around it, delivery-attempts-panel is that half alone, and its entire prop surface is attempts and isLoading.

The rest of the support queue#

  • dlq-panel: dead-lettered deliveries, one replay button per row.
  • endpoints-list and endpoint-form: endpoints with status and dead-letter counts, plus edit, pause, resume, delete, and reveal the signing secret. Leave a callback out and its button is never rendered.
  • event-catalog-browser: your published event types, grouped, with sample payloads generated from their schemas, the same ones your SDKs come from.
  • webhook-metrics-cards and timeline-histogram: volume, success rate and failures, each with a sparkline, above a timeline stacked by status that you drag across to select a range.

The catalog has a live preview and a props table per component. And if you would rather not assemble a view at all, install a whole one: @repost/webhook-dashboard composes the metrics, the timeline, the history table and the drawer, already wired.

Webhooks

Paused
Deliveries
Success rate
Failures
Time
Event
Endpoint
Status
Latency
Attempt
Live tail offCtrl+Space

@repost/endpoint-management, @repost/webhook-dlq and @repost/event-catalog do the same for the other three surfaces. Each one takes getToken and works.

It is your code the moment it lands#

Styling is standard shadcn tokens plus four status tokens (--status-success, --status-error, --status-warning, --status-info) the CLI injects with light and dark values. Override those and the whole set rebrands at once. Anything structural, you edit, because it is your file now: reorder the block's sections, drop the histogram, wrap the table in your own card.

Your customers will judge that page, and not one of them will ask who wrote it. The only part worth writing yourself is the token endpoint.

Share