# Wiring Webhooks

> Use when a system rather than a person needs to know what a jury decided — releasing an escrow when a case closes, updating a record on each vote, replacing a polling loop, or integrating Tribeunal into a service. Covers registering an endpoint in the right order, verifying that a delivery is genuine, which events mislead, and what to do without a rotate tool.

- Skill: `tribeunal/wiring-webhooks` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add tribeunal/wiring-webhooks`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tribeunal/wiring-webhooks/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tribeunal (https://skillmd.com/u/tribeunal)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tribeunal/wiring-webhooks

---


# Wiring webhooks

A webhook turns waiting into being told. The cost is that you now have to prove each delivery is
really from Tribeunal, and the signing secret you need for that is shown exactly once.

## When to use

When something automated must react to a case. A person watching an outcome wants
`acting-on-verdicts` instead; long-polling is simpler and needs no public address.

## Checklist

- [ ] Register the endpoint **before** the case exists
- [ ] Store the secret from the create response immediately
- [ ] Subscribe to the events you actually handle
- [ ] Verify signature and timestamp on every delivery
- [ ] Deduplicate on the delivery id
- [ ] Answer 2xx fast, then do the work

## Order matters

Create the endpoint first, then the case. The secret is returned once, at creation, and cannot be
read back — `tribeunal_list_webhooks` shows the endpoint but never the secret again. Register after
the case has opened and the first events either arrive unverifiable or arrive before you were
listening.

So: `tribeunal_create_webhook`, capture the secret, *then* `tribeunal_create_case`.

Tell whoever asked that the secret is shown once and must be stored now. An agent that reports "set
up" without surfacing the secret has produced an endpoint nobody can verify.

## Signature

Each delivery carries a timestamp and a signature header. The signature is
`hmac_sha256(secret, "{X-Tribeunal-Timestamp}.{raw body bytes}")`, hex, prefixed `v1=`.

Three details decide whether a verifier works:

- **Raw bytes.** Verify against the body exactly as received. Parsing the JSON and re-serialising it
  reorders keys and changes whitespace, so a genuine delivery fails — this is the usual cause of "my
  receiver rejects everything".
- **Constant-time comparison.** A byte-by-byte early exit turns the verifier into an oracle.
- **Check the timestamp.** A signature stays valid forever, so a replayed delivery passes the digest
  check. Reject anything outside a few minutes.

`scripts/verify-signature.js` does all three. Run it against a real delivery to confirm your
plumbing before writing your own:

```
node scripts/verify-signature.js --secret "$SECRET" \
  --timestamp 1788428724 --signature "v1=3f1b86…" --body ./delivery.json
```

It prints `VALID` or `INVALID` and exits 0 or 1.

## Events

`references/events.md` lists every event, the headers, and the receiver contract. Two are worth
knowing before you read it:

- **`ping` ignores your subscriptions.** It proves reachability and signing, never that your event
  list is right. A perfect ping on an endpoint subscribed to nothing still succeeds.
- **A vote's rationale rides inside `vote.cast`.** It does not also raise `comment.created`, so a
  receiver watching only comments silently misses every reason anyone gave.

## Deliveries repeat

Delivery is at-least-once: the same event can arrive twice. Record the delivery id before acting and
ignore an id you have already seen. Without that, "release the escrow" runs twice.

Answer 2xx quickly and do the work afterwards. A receiver that finishes first looks slow, gets
treated as failed, and is retried — creating the duplicate it was trying to avoid.

## No rotate tool

There is no rotate and no ping over these tools — deliberately, since both are ways to escalate a
read into a write. If a secret is compromised:

1. The REST API can rotate it, keeping the endpoint and its subscriptions.
2. Or delete the endpoint and create a new one, which mints a fresh secret.

The second option has a gap: between the delete and the create, events are dropped and not retried.
Say so rather than silently losing deliveries. Deleting also destroys the old secret, so anything
mid-flight becomes unverifiable.

## Local receivers

URLs must be https and publicly resolvable — the server rejects private, loopback and CGNAT
addresses, so a laptop receiver needs a tunnel with a public hostname. There is no bypass, and the
guard is protecting the platform from being pointed at internal networks rather than inconveniencing
you.

## Gotchas

| Trap | What is true |
| --- | --- |
| The secret can be fetched later | Shown once at creation, never again |
| A successful ping means events will flow | `ping` ignores subscriptions entirely |
| `case.closed` means a decision was made | It can carry no ruling at all |
| One event, one delivery | At-least-once; deduplicate on the delivery id |
| Verifying the parsed JSON is equivalent | Re-serialising breaks the digest; use raw bytes |
| A vote comment raises `comment.created` | It rides inside `vote.cast` |

