Skip to content

docs(webhooks): sync wire format + verification docs with shipped/about-to-ship behaviour#60

Merged
Yan Xue (yanxue06) merged 4 commits into
mainfrom
docs/webhook-wire-format-sync
May 28, 2026
Merged

docs(webhooks): sync wire format + verification docs with shipped/about-to-ship behaviour#60
Yan Xue (yanxue06) merged 4 commits into
mainfrom
docs/webhook-wire-format-sync

Conversation

@yanxue06
Copy link
Copy Markdown
Member

@yanxue06 Yan Xue (yanxue06) commented May 28, 2026

Summary

Brings the public webhook docs in line with what's shipped (or about to ship) in spectrum-webhook after PRs #46, #32, #31:

  • docs: update spectrum-ts documentation for v1.11.3 #46 (merged): typed wire shapes — every content arm has a fixed projection, no more content: unknown. Function thunks (read(), stream(), async OG accessors) and server-internal fields (path on attachment/voice) stripped before delivery; contact keeps a raw pass-through; richlink ships { type, url } only; reaction/reply/edit targets ship as slim SerializedMessageRef instead of recursing the full target.
  • Add Spectrum intro page and reorder nav tabs #32 (open): X-Spectrum-Event-Id header on messages deliveries (set to message.id, identical across retries, omitted on dynamic events).
  • docs(webhooks): agent-readable polish #31 (open): verifyPhotonWebhook enforces a ±5 min replay window and returns a discriminated union { ok: true } | { ok: false; reason: ... } so customers can tell clock skew apart from tampering. Stripe-parallel.

PR #48 (the closed inline-bytes/bytesStatus direction) is not documented anywhere in the diff — the philosophy across these docs stays webhook = notification + metadata for self-service retrieval. Bytes are retrieved out of band via the SDK or by contacting support; the docs note that a first-class HTTP download endpoint is on the roadmap and that message.id is the canonical handle for any future retrieval API.

Commits

  1. docs(webhooks): update wire format for per-arm projectionsevents.mdx.vel
  2. docs(webhooks): document X-Spectrum-Event-Id headerevents.mdx.vel, delivery.mdx, overview.mdx, troubleshooting.mdx
  3. docs(webhooks): document ±5min replay-window verificationverifying-signatures.mdx

Files touched

  • docs-src/webhooks/events.mdx.vel (vellum source for webhooks/events.mdx)
  • webhooks/delivery.mdx
  • webhooks/overview.mdx
  • webhooks/troubleshooting.mdx
  • webhooks/verifying-signatures.mdx

The rendered webhooks/events.mdx is gitignored and built by CI from the .vel source via pnpm docs:generate.

Test plan

  • pnpm docs:generate — vellum renders all 32 templates including webhooks/events.mdx, llms-*.txt files generated cleanly
  • pnpm typecheck:docs — all 129 code blocks across advanced-imessage / legacy / opensource configs type-check
  • pnpm lint — clean
  • Pre-commit hook ran on every commit and passed
  • Manual scan: zero references to PR docs: update advanced-imessage-ts documentation for v0.9.1 #48 / PR docs: update spectrum-ts documentation for v1.11.1 #44 concepts (bytesStatus, inline-bytes, meta: pending, async OG resolution, hybrid inline-bytes) anywhere in docs/webhooks/ or docs/docs-src/webhooks/
  • No promised GET /v1/messages/{id}/attachment endpoint; the byte-retrieval guidance keeps the existing "use the SDK in a long-lived process or contact support" escape hatch and notes the HTTP endpoint is on the roadmap

Out of scope (surfaced for follow-up)

  • The verifyEither rotation snippet in webhooks/managing-webhooks.mdx uses a generic verify() helper rather than the canonical verifyPhotonWebhook. It still works correctly with either return shape; aligning it with the discriminated-union shape would be a small polish PR.

Made with Cursor


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

Summary by CodeRabbit

  • Documentation
    • Enhanced webhook event documentation with clearer payload structure and content type guidance.
    • Added clarification that binary content delivers metadata only; full content requires separate retrieval using the webhook message ID.
    • Expanded reference documentation and code examples for handling reactions, replies, and edits.

Review Change Stack

Yan Xue (yanxue06) and others added 3 commits May 27, 2026 21:26
Reflects spectrum-webhook PR #46 — every content arm now has a typed,
hand-shaped wire projection (no more `content: unknown`). Documents:

- Per-arm wire shapes in an accordion-grouped reference covering all 16
  arms (text/attachment/voice/contact/richlink/reaction/reply/edit/
  group/poll/poll_option/effect/typing/custom/rename/avatar) plus the
  generic-projection fall-through for any future arm
- `attachment` and `voice` strip `read()`/`stream()` thunks and the
  server-internal `path` (filesystem leak guard); only metadata rides
- `contact` ships the standard projection plus an opt-in `raw` field
  carrying the provider-native blob (vCard text, WhatsApp `nativeCard`,
  …) JSON-normalized through the cycle-safe walker
- `richlink` ships `{ type, url }` only; the SDK's async OG accessors
  (`title()`, `summary()`, `cover()`) would put delivery latency on the
  slowest target site, so customers fetch OG metadata themselves
- `reaction`/`reply`/`edit` `target` fields ship as a slim
  `SerializedMessageRef` (`{ id, platform, timestamp, sender?,
  contentPreview? }`) instead of recursing the full referenced message,
  with an 80-char `contentPreview` for "in reply to «hello»" UIs
- `avatar` set arm carries `{ action: { kind: "set", mimeType } }`,
  clear arm carries `{ action: { kind: "clear" } }`
- The byte-bearing-arms warning broadens to cover attachment, voice,
  avatar (set), and contact.photo, and clarifies that `message.id` is
  the canonical handle for any future retrieval API

Co-authored-by: Cursor <cursoragent@cursor.com>
Reflects spectrum-webhook PR #32 — `messages` deliveries now stamp
`X-Spectrum-Event-Id` with `message.id`, the per-event identifier that
stays stable across retries. Documents:

- New header row in the events page table, including the explicit
  contract that the header is OMITTED (not present-but-empty) on
  dynamic-event deliveries because dynamic-event payloads don't share a
  canonical id field across providers
- Stripe parity note: `X-Spectrum-Webhook-Id` (config row) and
  `X-Spectrum-Event-Id` (per-event) mirror Stripe's
  `Webhook-Endpoint-Id` / per-event `id` split
- The header carries the same value as `payload.message.id`, so
  middleware running before JSON parsing can dedupe without buffering
  the full body. Idempotency snippets in the events page, the delivery
  page's "Be idempotent" section, and the troubleshooting "I receive
  duplicates" section all key off the header now, with a fallback to
  `payload.message.id` post-parse
- `X-Spectrum-Event-Id` added to the anatomy-of-a-delivery example, the
  overview page's HTTP teaser, and the troubleshooting test-curl
  snippet for parity with what real deliveries carry

Co-authored-by: Cursor <cursoragent@cursor.com>
Reflects spectrum-webhook PR #31 — `verifyPhotonWebhook` now enforces
a replay window before the HMAC compare and returns a discriminated
union so customers can tell clock skew apart from tampering.
Documents:

- Replay-window check runs first (cheaper than constant-time hash
  compare; surfaces clock skew as a distinct failure mode), with the
  default tolerance pinned at `DEFAULT_REPLAY_TOLERANCE_SECONDS = 300`
  to match Stripe's 5-minute tolerance
- Discriminated-union return type (`{ ok: true } | { ok: false; reason:
  "invalid_signature" | "malformed_timestamp" | "stale_timestamp" }`)
  with structured-logging guidance — `stale_timestamp` rate going up
  is a clock-drift incident; `invalid_signature` rate going up is
  key-rotation or active probing
- Updated `Reusing our verifier` snippet to the new shape, with
  override guidance for the `toleranceSeconds` parameter (tighter on a
  hot path, looser for batch/queue ingestors with legitimate lag) and
  a `now` parameter for deterministic test fixtures
- Reconciled the recipe step "If any check fails, return 401" with
  what the in-language snippets actually do — stale timestamps return
  `400`, HMAC failures return `401`, matching the discriminated-union
  philosophy at the HTTP layer

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings May 28, 2026 04:34
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Documentation for webhook message content payloads is substantially expanded. The content field is redefined as a discriminated-union wire projection, clarifying what fields are stripped before delivery. Per-arm reference documentation is added with forward-compat handling and a warning that byte-bearing content ships metadata only. Code examples are updated to handle reaction and reply cases.

Changes

Webhook Content Type Schema and Reference Documentation

Layer / File(s) Summary
Discriminated-Union Content Model Definition
docs-src/webhooks/events.mdx.vel
The content field description is rewritten from a limited "two shapes" model to a proper discriminated-union wire projection. Documentation clarifies that function thunks and server-internal fields are stripped before JSON delivery, and the type narrative expands to cover a larger published arm set, two forward-compat arms (rename, avatar), and a generic pass-through for unrecognized future arms.
Per-Arm Wire Reference and Target Projections
docs-src/webhooks/events.mdx.vel
Comprehensive per-arm reference documentation is added covering many content types and forward-compat behavior. A warning clarifies that byte-bearing content arms ship only metadata (MIME type, size, filenames) without raw bytes or download URLs, requiring a separate retrieval step keyed off message.id. Target reference documentation for reaction, reply, and edit explains the slim referenced-message projection, including contentPreview behavior for text targets.
Updated Content-Type Switch Example
docs-src/webhooks/events.mdx.vel
Code example for switch (content.type) is updated to include explicit cases for reaction and reply, and the default case is changed to log the concrete content.type value instead of a generic unknown message.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • photon-hq/docs#31: Both PRs update the docs-src/webhooks/events.mdx.vel webhook message payload spec—one specifically marks attachment.size as optional while the other rewrites/expands the overall discriminated-union "content.type" wire projections (including how content arms are shaped).
  • photon-hq/docs#30: Both PRs modify docs-src/webhooks/events.mdx.vel to change the documented webhook message payload shapes, with overlapping changes to the content arm/target projections and message delivery examples.
  • photon-hq/docs#26: Both PRs modify the same webhook events spec in docs-src/webhooks/events.mdx.vel, particularly the content payload documentation and discriminated-union structure.

Poem

🐰 A webhook's content now shines with care,
Discriminated arms declared fair,
Metadata hints (but no bytes to spare!),
Reactions, replies—examples compare,
Docs hopping forward with future-proof flair! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and accurately describes the main change: synchronizing webhook documentation with shipped/about-to-ship behavior in spectrum-webhook, which aligns with the extensive documentation updates to wire format and verification docs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/webhook-wire-format-sync

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (8)
docs-src/webhooks/events.mdx.vel (2)

198-199: ⚡ Quick win

Simplify the complex sentence for clarity.

The sentence starting with "Hold a [spectrum-ts]..." contains multiple ideas (SDK usage, byte accessors, support contact, message.id as handle, future API). Consider splitting it for better readability.

📝 Proposed refinement
-**Byte-bearing arms ship metadata, not bytes.** `attachment`, `voice`, `avatar` (set arm), and `contact.photo` all carry `mimeType` / `size` / filename — never the raw bytes themselves and never a download URL. To process the actual content you need an additional retrieval step. Hold a [`spectrum-ts`](/spectrum-ts/getting-started) instance in a long-lived process and call its byte accessors, or contact support if you need attachment retrieval — the `message.id` from the webhook is the canonical handle any future retrieval API will accept. A first-class HTTP download endpoint is on the roadmap.
+**Byte-bearing arms ship metadata, not bytes.** `attachment`, `voice`, `avatar` (set arm), and `contact.photo` all carry `mimeType` / `size` / filename — never the raw bytes themselves and never a download URL. To process the actual content you need an additional retrieval step. Hold a [`spectrum-ts`](/spectrum-ts/getting-started) instance in a long-lived process and call its byte accessors, or contact support if you need attachment retrieval. The `message.id` from the webhook is the canonical handle any future retrieval API will accept. A first-class HTTP download endpoint is on the roadmap.

As per coding guidelines, keep sentences concise with one idea per sentence in documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-src/webhooks/events.mdx.vel` around lines 198 - 199, The paragraph
mixing several ideas should be split into shorter sentences: break the long
sentence that begins "Hold a [`spectrum-ts`]..." into separate statements
clarifying (1) use of a long-lived spectrum-ts instance to call its byte
accessors, (2) that you can contact support for attachment retrieval, and (3)
that the webhook's message.id is the canonical handle any future retrieval API
will accept; also ensure earlier mention that `attachment`, `voice`, `avatar`,
and `contact.photo` carry `mimeType`/`size`/filename but not raw bytes or a
download URL remains unchanged.

70-70: ⚡ Quick win

Split the description into multiple sentences.

The X-Spectrum-Event-Id description contains too many ideas in one sentence, violating the guideline to keep one idea per sentence. Consider breaking it into separate sentences covering: (1) what it is, (2) stability semantics, (3) dedupe use case, (4) omission on dynamic events, and (5) the reference link.

📝 Proposed refinement
-| `X-Spectrum-Event-Id` | `message.id` (only on `messages` events) | Per-event identifier. Same value across every retry of the same delivery, distinct across events — the right key for an idempotent dedupe table without parsing the body. **Omitted** on dynamic-event deliveries (typing, custom provider events, etc.) because there is no canonical id field across providers; absence ≠ empty. See [Idempotency](`#idempotency-the-message-id-rule`). |
+| `X-Spectrum-Event-Id` | `message.id` (only on `messages` events) | Per-event identifier. Same value across every retry of the same delivery, distinct across events. Use this as the dedupe key for idempotent processing without parsing the body. **Omitted** on dynamic-event deliveries (typing, custom provider events, etc.) because there is no canonical id field across providers. See [Idempotency](`#idempotency-the-message-id-rule`). |

As per coding guidelines, keep sentences concise with one idea per sentence in documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-src/webhooks/events.mdx.vel` at line 70, Split the long table cell for
`X-Spectrum-Event-Id` into multiple concise sentences: first state that
`X-Spectrum-Event-Id` corresponds to `message.id` on `messages` events; then
state that it is a per-event identifier that remains the same across retries but
differs across events (stability semantics); add a separate sentence noting it
is the appropriate key for idempotent dedupe tables without parsing the body
(dedupe use case); add another sentence explaining it is omitted on
dynamic-event deliveries (typing, custom provider events) and that absence does
not equal empty; finish with a short sentence linking to the Idempotency section
(see Idempotency).
webhooks/troubleshooting.mdx (1)

120-120: ⚡ Quick win

Split the complex sentence for clarity.

Line 120 contains multiple ideas (what the key is, what it carries, stability semantics, when it's readable). This violates the guideline to keep one idea per sentence.

📝 Proposed refinement
-For the `messages` event, the cheapest dedupe key is the `X-Spectrum-Event-Id` header — it carries `message.id`, is identical across every retry, and is readable before JSON parsing:
+For the `messages` event, the cheapest dedupe key is the `X-Spectrum-Event-Id` header. It carries `message.id` and remains identical across every retry. It's readable before JSON parsing:

As per coding guidelines, keep sentences concise with one idea per sentence in documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webhooks/troubleshooting.mdx` at line 120, Split the complex sentence into
multiple concise sentences: state that for the "messages" event the cheapest
dedupe key is the X-Spectrum-Event-Id header; in a separate sentence explain
that this header carries message.id and remains identical across retries; and in
a third sentence note that it is readable before JSON parsing. Update the
webhooks/troubleshooting.mdx description around the "messages" event to replace
the single multi-idea sentence with these three clear, one-idea-per-sentence
statements.
webhooks/delivery.mdx (1)

146-146: ⚡ Quick win

Split the complex sentence for clarity.

The second sentence on line 146 contains multiple ideas (what the key is, what it's set to, stability across retries, distinctness). Consider splitting this into separate sentences.

📝 Proposed refinement
-At-least-once delivery means the same event can arrive more than once if your server hung after processing but before responding. The cheapest dedupe key for the `messages` event is the `X-Spectrum-Event-Id` header — set to `message.id`, identical across every retry of the same delivery, distinct across events:
+At-least-once delivery means the same event can arrive more than once if your server hung after processing but before responding. The cheapest dedupe key for the `messages` event is the `X-Spectrum-Event-Id` header. It's set to `message.id` and remains identical across every retry of the same delivery, while being distinct across different events:

As per coding guidelines, keep sentences concise with one idea per sentence in documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webhooks/delivery.mdx` at line 146, Split the long sentence into two concise
sentences: first state that for the messages event the cheapest dedupe key is
the X-Spectrum-Event-Id header, and second state that this header should be set
to message.id and that it remains identical across every retry of the same
delivery but is distinct across different events. Mention the `messages` event,
`X-Spectrum-Event-Id` header and `message.id` explicitly to keep the
documentation clear.
webhooks/verifying-signatures.mdx (4)

347-347: ⚡ Quick win

Split multi-idea sentences for clarity.

This paragraph's second sentence combines four ideas: the directive to use the reason field, the purpose (logging), and two distinct diagnostic scenarios with their interpretations.

As per coding guidelines: "Keep sentences concise with one idea per sentence in documentation."

📝 Suggested rewrite
-Pair it with the constant-time compare your language exposes (`timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal`, …) and you have a complete verifier matching what Spectrum runs in production. Use the `reason` field to log structured failure metrics — `stale_timestamp` rate going up is a clock-drift incident on either side; `invalid_signature` rate going up is either a key-rotation issue or active probing.
+Pair it with the constant-time compare your language exposes (`timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal`, …) and you have a complete verifier matching what Spectrum runs in production. Use the `reason` field to log structured failure metrics. A `stale_timestamp` rate going up signals a clock-drift incident on either side. An `invalid_signature` rate going up signals either a key-rotation issue or active probing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webhooks/verifying-signatures.mdx` at line 347, Rewrite the long sentence
into separate concise sentences: keep the instruction to pair the verifier with
the language's constant-time compare (timingSafeEqual / hmac.compare_digest /
hmac.Equal) as one sentence; make a second sentence that directs readers to use
the reason field to log structured failure metrics; then add two short sentences
that explain the two diagnostic scenarios separately — one stating that a rising
stale_timestamp rate indicates clock drift, and another stating that a rising
invalid_signature rate indicates key-rotation issues or active probing.

340-347: ⚡ Quick win

Break bullet-point sentences into single ideas.

While the bullet format helps organization, individual sentences within bullets 1-4 still pack multiple ideas together. Line 343 combines five ideas: the default value, Stripe comparison, override scenarios, example values, and general advice. Line 344 is the longest, cramming the approach, its benefit, three distinct diagnostic scenarios with remediation paths, and the consequence of not using it.

As per coding guidelines: "Keep sentences concise with one idea per sentence in documentation."

📝 Suggested rewrite
-- **Replay window first.** Stale timestamps short-circuit before the constant-time HMAC compare. The check is cheaper (one subtraction vs a hash) and it surfaces clock skew as a distinct failure mode from tampering — see [Reject stale timestamps](`#3-reject-stale-timestamps`).
+- **Replay window first.** Stale timestamps short-circuit before the constant-time HMAC compare. The check is cheaper: one subtraction vs a hash. It surfaces clock skew as a distinct failure mode from tampering. See [Reject stale timestamps](`#3-reject-stale-timestamps`).

-- **Default tolerance: 300 seconds.** `DEFAULT_REPLAY_TOLERANCE_SECONDS` matches [Stripe's 5-minute window](https://stripe.com/docs/webhooks/signatures#replay-attacks). Override `toleranceSeconds` when you have a queue or batch ingestor that legitimately consumes deliveries with more than five minutes of lag — pin tighter (e.g. 60s) on a hot path, looser (e.g. 900s) for a deferred reconciliation pipeline. The tighter the better, all else equal.
+- **Default tolerance: 300 seconds.** `DEFAULT_REPLAY_TOLERANCE_SECONDS` matches [Stripe's 5-minute window](https://stripe.com/docs/webhooks/signatures#replay-attacks). Override `toleranceSeconds` when you have a queue or batch ingestor that legitimately consumes deliveries with more than five minutes of lag. Pin tighter (e.g. 60s) on a hot path. Pin looser (e.g. 900s) for a deferred reconciliation pipeline. The tighter the better, all else equal.

-- **Discriminated union, not boolean.** `{ ok: true }` vs `{ ok: false; reason: ... }` keeps the diagnostic distinction between three remediation paths: a `malformed_timestamp` is your code or our serializer (file a bug), a `stale_timestamp` is clock skew or a deferred queue (fix NTP or widen tolerance), and `invalid_signature` is tampering or wrong secret (rotate, audit). Boolean returns lose that — every customer report ends up in the same triage bucket.
+- **Discriminated union, not boolean.** `{ ok: true }` vs `{ ok: false; reason: ... }` keeps the diagnostic distinction between three remediation paths. A `malformed_timestamp` means your code or our serializer has a bug — file a bug report. A `stale_timestamp` means clock skew or a deferred queue — fix NTP or widen tolerance. An `invalid_signature` means tampering or wrong secret — rotate keys and audit. Boolean returns lose that distinction. Every customer report ends up in the same triage bucket.

-- **Test-friendly `now` parameter.** Pass an explicit `now` to make replay-window assertions deterministic against fixed fixtures; production callers can omit it and let `Date.now()` win.
+- **Test-friendly `now` parameter.** Pass an explicit `now` to make replay-window assertions deterministic against fixed fixtures. Production callers can omit it and let `Date.now()` win.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webhooks/verifying-signatures.mdx` around lines 340 - 347, Split the dense
bullets into single-idea sentences: for the first bullet, separate the "replay
window first" rule, the reason it short-circuits before HMAC, and the pointer to
"Reject stale timestamps" into 2–3 sentences; for the second bullet, state the
default value DEFAULT_REPLAY_TOLERANCE_SECONDS/300s as one sentence, cite
Stripe's 5-minute parity as a separate sentence, and move override guidance
(queue/batch, examples 60s/900s) into one or two short sentences; for the
discriminated-union bullet, make one sentence describing the shape ({ ok: true }
vs { ok: false; reason }) and then one sentence for each remediation path
(malformed_timestamp, stale_timestamp, invalid_signature) so each idea is
atomic; for the now parameter and timing-safe compare guidance, make each a
separate short sentence; keep references to DEFAULT_REPLAY_TOLERANCE_SECONDS,
the { ok: true }/{ ok: false; reason } discriminated union, the now parameter,
and timingSafeEqual/hmac.compare_digest so reviewers can find the exact places
to edit.

21-21: ⚡ Quick win

Split each sentence to contain one idea.

This paragraph packs multiple ideas into each sentence, violating the guideline to keep one idea per sentence. The first sentence combines the status code, semantic meaning, and three potential causes. The third sentence mixes a directive, justification, and implementation reference.

As per coding guidelines: "Keep sentences concise with one idea per sentence in documentation."

📝 Suggested rewrite
-A stale timestamp returns `400 Bad Request` (the request was well-formed but no longer fresh — your clock or our clock is off, or this is a captured replay). A failed HMAC compare returns `401 Unauthorized` (the secret doesn't match or the body was tampered with). Keep the two distinct so customer-side log analysis can tell clock-skew incidents apart from active probing — Spectrum's own [`verifyPhotonWebhook`](`#reusing-our-verifier`) returns a discriminated union for the same reason.
+A stale timestamp returns `400 Bad Request`. The request was well-formed but is no longer fresh — your clock or our clock drifted, or this is a captured replay. A failed HMAC compare returns `401 Unauthorized`. Either the secret doesn't match or the body was tampered with. Keep the two failure modes distinct so customer-side log analysis can tell clock-skew incidents apart from active probing. Spectrum's own [`verifyPhotonWebhook`](`#reusing-our-verifier`) returns a discriminated union for the same reason.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webhooks/verifying-signatures.mdx` at line 21, Split the paragraph into short
sentences each expressing one idea: state that a stale timestamp returns 400 Bad
Request; explain separately that causes for staleness include clock skew or
replay; state that a failed HMAC compare returns 401 Unauthorized; separately
explain that this indicates a mismatched secret or tampered body; and finally,
in its own sentence, advise keeping the two distinct for customer-side log
analysis and mention Spectrum's verifyPhotonWebhook returns a discriminated
union as an implementation reference.

256-260: ⚡ Quick win

Break complex sentences into single ideas for clarity.

Lines 256, 258, and 260 each pack multiple ideas into single sentences. Line 260 is particularly dense, combining a directive, two justifications, a consequence, and an implementation reference in one paragraph-length sentence.

As per coding guidelines: "Keep sentences concise with one idea per sentence in documentation."

📝 Suggested rewrite
-5 minutes (300 seconds) is the recommended tolerance — generous enough to absorb clock drift between our worker and your server, tight enough to make captured-and-replayed attacks impractical.
+5 minutes (300 seconds) is the recommended tolerance. It's generous enough to absorb clock drift between our worker and your server. It's tight enough to make captured-and-replayed attacks impractical. Tighten or loosen it based on your threat model.

-The bound mirrors [Stripe's `Stripe-Signature` 5-minute tolerance](https://stripe.com/docs/webhooks/signatures#replay-attacks) — same threat model, same chosen value. Anyone porting a Stripe verifier sees identical timing behaviour here.
+The bound mirrors [Stripe's `Stripe-Signature` 5-minute tolerance](https://stripe.com/docs/webhooks/signatures#replay-attacks). Both use the same threat model and chosen value. Anyone porting a Stripe verifier sees identical timing behaviour here.

-Run the staleness check **before** the HMAC compare. It's cheaper (one subtraction vs a constant-time hash compare), and it lets you surface "clock skew" as a different failure mode than "tampered or wrong secret" — the two need opposite remediation paths and collapsing them into a single "bad signature" branch sends customers chasing the wrong root cause. Spectrum's own internal `verifyPhotonWebhook` does this; see [Reusing our verifier](`#reusing-our-verifier`) below for the discriminated-union shape.
+Run the staleness check **before** the HMAC compare. The staleness check is cheaper: one subtraction vs a constant-time hash compare. More importantly, it lets you surface "clock skew" as a different failure mode than "tampered or wrong secret". The two need opposite remediation paths. Collapsing them into a single "bad signature" branch sends customers chasing the wrong root cause. Spectrum's own internal `verifyPhotonWebhook` does this; see [Reusing our verifier](`#reusing-our-verifier`) below for the discriminated-union shape.

Note: Remove the standalone "Tighten or loosen it based on your threat model" from line 256 in the original since it's already present at the end of that paragraph.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webhooks/verifying-signatures.mdx` around lines 256 - 260, The paragraph at
lines 256–260 is too dense—split into concise sentences so each conveys a single
idea: state the 5-minute (300s) recommended tolerance, note it mirrors Stripe's
Stripe-Signature 5-minute tolerance, advise readers to adjust this value based
on their threat model, and separately instruct to run the staleness check before
the HMAC compare (mentioning the benefit of differentiating "clock skew" vs
"tampered or wrong secret" and referencing the internal verifyPhotonWebhook as
an example). Remove the duplicate "Tighten or loosen it based on your threat
model" and ensure each of the four points is a separate short sentence for
clarity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs-src/webhooks/events.mdx.vel`:
- Around line 198-199: The paragraph mixing several ideas should be split into
shorter sentences: break the long sentence that begins "Hold a
[`spectrum-ts`]..." into separate statements clarifying (1) use of a long-lived
spectrum-ts instance to call its byte accessors, (2) that you can contact
support for attachment retrieval, and (3) that the webhook's message.id is the
canonical handle any future retrieval API will accept; also ensure earlier
mention that `attachment`, `voice`, `avatar`, and `contact.photo` carry
`mimeType`/`size`/filename but not raw bytes or a download URL remains
unchanged.
- Line 70: Split the long table cell for `X-Spectrum-Event-Id` into multiple
concise sentences: first state that `X-Spectrum-Event-Id` corresponds to
`message.id` on `messages` events; then state that it is a per-event identifier
that remains the same across retries but differs across events (stability
semantics); add a separate sentence noting it is the appropriate key for
idempotent dedupe tables without parsing the body (dedupe use case); add another
sentence explaining it is omitted on dynamic-event deliveries (typing, custom
provider events) and that absence does not equal empty; finish with a short
sentence linking to the Idempotency section (see Idempotency).

In `@webhooks/delivery.mdx`:
- Line 146: Split the long sentence into two concise sentences: first state that
for the messages event the cheapest dedupe key is the X-Spectrum-Event-Id
header, and second state that this header should be set to message.id and that
it remains identical across every retry of the same delivery but is distinct
across different events. Mention the `messages` event, `X-Spectrum-Event-Id`
header and `message.id` explicitly to keep the documentation clear.

In `@webhooks/troubleshooting.mdx`:
- Line 120: Split the complex sentence into multiple concise sentences: state
that for the "messages" event the cheapest dedupe key is the X-Spectrum-Event-Id
header; in a separate sentence explain that this header carries message.id and
remains identical across retries; and in a third sentence note that it is
readable before JSON parsing. Update the webhooks/troubleshooting.mdx
description around the "messages" event to replace the single multi-idea
sentence with these three clear, one-idea-per-sentence statements.

In `@webhooks/verifying-signatures.mdx`:
- Line 347: Rewrite the long sentence into separate concise sentences: keep the
instruction to pair the verifier with the language's constant-time compare
(timingSafeEqual / hmac.compare_digest / hmac.Equal) as one sentence; make a
second sentence that directs readers to use the reason field to log structured
failure metrics; then add two short sentences that explain the two diagnostic
scenarios separately — one stating that a rising stale_timestamp rate indicates
clock drift, and another stating that a rising invalid_signature rate indicates
key-rotation issues or active probing.
- Around line 340-347: Split the dense bullets into single-idea sentences: for
the first bullet, separate the "replay window first" rule, the reason it
short-circuits before HMAC, and the pointer to "Reject stale timestamps" into
2–3 sentences; for the second bullet, state the default value
DEFAULT_REPLAY_TOLERANCE_SECONDS/300s as one sentence, cite Stripe's 5-minute
parity as a separate sentence, and move override guidance (queue/batch, examples
60s/900s) into one or two short sentences; for the discriminated-union bullet,
make one sentence describing the shape ({ ok: true } vs { ok: false; reason })
and then one sentence for each remediation path (malformed_timestamp,
stale_timestamp, invalid_signature) so each idea is atomic; for the now
parameter and timing-safe compare guidance, make each a separate short sentence;
keep references to DEFAULT_REPLAY_TOLERANCE_SECONDS, the { ok: true }/{ ok:
false; reason } discriminated union, the now parameter, and
timingSafeEqual/hmac.compare_digest so reviewers can find the exact places to
edit.
- Line 21: Split the paragraph into short sentences each expressing one idea:
state that a stale timestamp returns 400 Bad Request; explain separately that
causes for staleness include clock skew or replay; state that a failed HMAC
compare returns 401 Unauthorized; separately explain that this indicates a
mismatched secret or tampered body; and finally, in its own sentence, advise
keeping the two distinct for customer-side log analysis and mention Spectrum's
verifyPhotonWebhook returns a discriminated union as an implementation
reference.
- Around line 256-260: The paragraph at lines 256–260 is too dense—split into
concise sentences so each conveys a single idea: state the 5-minute (300s)
recommended tolerance, note it mirrors Stripe's Stripe-Signature 5-minute
tolerance, advise readers to adjust this value based on their threat model, and
separately instruct to run the staleness check before the HMAC compare
(mentioning the benefit of differentiating "clock skew" vs "tampered or wrong
secret" and referencing the internal verifyPhotonWebhook as an example). Remove
the duplicate "Tighten or loosen it based on your threat model" and ensure each
of the four points is a separate short sentence for clarity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3670a073-d69c-4a48-8064-e3d8b56af9c1

📥 Commits

Reviewing files that changed from the base of the PR and between 2ce5edf and ed1c408.

📒 Files selected for processing (5)
  • docs-src/webhooks/events.mdx.vel
  • webhooks/delivery.mdx
  • webhooks/overview.mdx
  • webhooks/troubleshooting.mdx
  • webhooks/verifying-signatures.mdx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: copilot-pull-request-reviewer
🧰 Additional context used
📓 Path-based instructions (2)
**/*.mdx

📄 CodeRabbit inference engine (AGENTS.md)

**/*.mdx: Pages should be written as MDX files with YAML frontmatter
Use active voice and second person ("you") in documentation
Keep sentences concise with one idea per sentence in documentation
Use sentence case for headings in documentation
Bold UI elements in documentation (e.g., Click Settings)
Use code formatting for file names, commands, paths, and code references in documentation

Files:

  • webhooks/delivery.mdx
  • webhooks/overview.mdx
  • webhooks/verifying-signatures.mdx
  • webhooks/troubleshooting.mdx
docs-src/**/*.mdx.vel

📄 CodeRabbit inference engine (CLAUDE.md)

docs-src/**/*.mdx.vel: Pull type information through vellum from .d.ts files instead of writing it by hand - use sym.signature for full declarations, sym.members[] for interface properties, sym.variants[] for enums/discriminated unions, and sym.doc.summary for descriptions
Import TypeTooltip from /snippets/type-tooltip.mdx in every .mdx.vel file that mentions a type symbol in prose
Use <TypeTooltip> whenever a named type appears inline (method return type, parameter, passing reference), with one {% set %} per symbol at first reference and pairing the tooltip with a short prose description
Use <Accordion> for reference material like long options tables, enumerated values, and event-type mappings, with title being just the type name (no suffixes), description from {{ sym.doc.summary }}, and related accordions stacked in <AccordionGroup>
In Accordion tables containing TypeScript type text, apply the standard escape chain: replace("\n", " ") | replace(" ", "") | replace("|", "\\|") | replace("<", "&lt;") | replace(">", "&gt;")
Use <Tabs> component when showing the same task in multiple ways (e.g. plain text / with options / builder API), not successive ### headings
Do not include type annotations in code block comments; move type information to prose with <TypeTooltip> instead
Avoid scare-quote comments in code blocks (e.g. // Narrowed, // Returns); explain in surrounding prose or delete
Keep code snippets minimal with real API calls and no mock data shaped to look real
Drive table rows from vellum when they map 1:1 to source members/variants using {% for %} loops; only hand-write tables when there's no corresponding symbol, when adding categorization/editorial structure, or for dynamic counts using {{ sym.variants | length }}
When vellum extracts well-formed patterns, use the extracted data; for fall-through cases (unions of named references, non-literal discriminators, mixed unions, pure literal unions), hand-write tables...

Files:

  • docs-src/webhooks/events.mdx.vel
🧠 Learnings (1)
📚 Learning: 2026-05-27T07:01:01.271Z
Learnt from: yanxue06
Repo: photon-hq/docs PR: 56
File: docs-src/webhooks/events.mdx.vel:0-0
Timestamp: 2026-05-27T07:01:01.271Z
Learning: In Vellum-templated webhook docs where the data source is a Zod schema whose fields (e.g., iMessage `Space` `type`/`phone` from `spaceSchema`) are not exposed as enumerable members/variants to the TypeScript extractor, do not use Vellum `{% for %}` loops to generate rows. Instead, write the relevant table rows explicitly (e.g., hand-written rows inside the appropriate `<Accordion>` blocks) so the docs render the expected `type`/`phone` entries.

Applied to files:

  • docs-src/webhooks/events.mdx.vel
🔇 Additional comments (11)
docs-src/webhooks/events.mdx.vel (4)

25-25: LGTM!


141-168: LGTM!


201-366: LGTM!


369-392: LGTM!

webhooks/overview.mdx (1)

24-24: LGTM!

webhooks/delivery.mdx (1)

149-169: LGTM!

webhooks/troubleshooting.mdx (2)

123-138: LGTM!


232-232: LGTM!

webhooks/verifying-signatures.mdx (3)

17-17: LGTM!


303-337: LGTM!


349-357: LGTM!

PR #60 was initially scoped against three in-flight spectrum-webhook PRs:

- #46 (typed `SerializedContent` per-arm wire shapes, contact `raw`
  pass-through, slim `SerializedMessageRef`, `richlink` as `{type,url}`,
  `SerializedUnknownContent` fall-through)
- #32 (`X-Spectrum-Event-Id` header on `messages` deliveries)
- #31 (`verifyPhotonWebhook` discriminated-union return shape with
  built-in ±5min replay window and `DEFAULT_REPLAY_TOLERANCE_SECONDS`
  export)

#46 merged to spectrum-webhook `main`; #32 and #31 are still OPEN. To
honor the "docs must match `main`" invariant, this commit reverts the
two later commits on this branch (commits 0bd63f4 and ed1c408) that
describe the still-unshipped #32 / #31 behavior:

- Reverts `X-Spectrum-Event-Id` documentation in
  `docs-src/webhooks/events.mdx.vel`, `webhooks/delivery.mdx`,
  `webhooks/overview.mdx`, `webhooks/troubleshooting.mdx`. The header
  isn't set by the worker on `main` yet — documenting it would promise
  a header customer endpoints cannot actually rely on.
- Reverts `verifyPhotonWebhook` discriminated-union /
  `DEFAULT_REPLAY_TOLERANCE_SECONDS` / `toleranceSeconds`+`now` params /
  ±5min replay-window-before-HMAC documentation in
  `webhooks/verifying-signatures.mdx`. The verifier on `main` still
  returns `boolean` and the replay window is layered by the caller in
  the snippet, not baked into the verifier.

The #46 commit (179cd47, per-arm wire shapes / contact raw / slim refs
/ richlink / avatar / `SerializedUnknownContent` tail) stays — that
behavior IS on spectrum-webhook `main` as of f6fd777.

Re-land the reverted doc sections once the corresponding
spectrum-webhook PRs merge:

- photon-hq/spectrum-webhook#32 → re-land `X-Spectrum-Event-Id` docs
- photon-hq/spectrum-webhook#31 → re-land `verifyPhotonWebhook`
  discriminated-union + replay-window docs

The cherry-pick handles are 0bd63f4 (for #32) and ed1c408 (for #31).

Co-authored-by: Cursor <cursoragent@cursor.com>
@yanxue06
Copy link
Copy Markdown
Member Author

Realigning with spectrum-webhook main

PR #60 was initially scoped against three in-flight spectrum-webhook PRs:

PR Title spectrum-webhook status
#46 typed SerializedContent per-arm wire shapes, contact raw pass-through, slim SerializedMessageRef, richlink {type, url}, SerializedUnknownContent tail MERGED (f6fd777)
#32 X-Spectrum-Event-Id header on messages deliveries OPEN
#31 verifyPhotonWebhook discriminated-union return, built-in ±5min replay window, DEFAULT_REPLAY_TOLERANCE_SECONDS export OPEN

#46 is on main; #32 and #31 are not. To honor the docs follow code invariant, I just pushed 3cb5379 which reverts the two later commits on this branch (0bd63f4 and ed1c408) that describe still-unshipped behavior.

What stays (PR #46, merged — ground-truth verified against spectrum-webhook/main)

  • Per-arm wire reference accordion in docs-src/webhooks/events.mdx.vel: all 16 arms documented (text / attachment / voice / contact / richlink / reaction / reply / edit / group / poll / poll_option / effect / typing / custom / rename / avatar) with the SerializedUnknownContent fall-through tail
  • attachment / voice strip read() / stream() thunks and the server-internal path
  • contact.raw pass-through (provider-native vCard / nativeCard, JSON-normalized via the cycle-safe walker)
  • richlink ships { type, url } only (no OG resolution at delivery)
  • reaction / reply / edit target fields ship as slim SerializedMessageRef ({ id, platform, timestamp, sender?, contentPreview? })
  • avatar set arm { action: { kind: "set", mimeType } }, clear arm { action: { kind: "clear" } }
  • Broadened byte-bearing-arms warning + message.id-as-canonical-handle language

All verified against src/delivery/serialize.ts on spectrum-webhook/main.

What was reverted

X-Spectrum-Event-Id (spectrum-webhook #32, not yet on main) — removed from:

  • docs-src/webhooks/events.mdx.vel (headers table row, anatomy-of-a-delivery example, Stripe-parity paragraph, idempotency snippets, dynamic-events omission note)
  • webhooks/delivery.mdx (be-idempotent section)
  • webhooks/overview.mdx (HTTP teaser)
  • webhooks/troubleshooting.mdx (dedupe snippet, test-curl example)

The header isn't set by the worker on main yet (dispatch.ts writes only Content-Type, User-Agent, X-Spectrum-Event, X-Spectrum-Signature, X-Spectrum-Timestamp, X-Spectrum-Webhook-Id) — documenting it would promise customer endpoints a header they can't actually rely on.

verifyPhotonWebhook discriminated-union / replay window (spectrum-webhook #31, not yet on main) — removed from:

  • webhooks/verifying-signatures.mdx: the discriminated-union return type ({ ok: true } | { ok: false; reason: "invalid_signature" | "malformed_timestamp" | "stale_timestamp" }), DEFAULT_REPLAY_TOLERANCE_SECONDS = 300 export, now / toleranceSeconds params, "replay window runs first" guidance, structured-logging-by-reason snippet, and the reconciled 400-vs-401 split

The verifier on main still returns boolean and the replay window is layered by the caller in the snippet (not baked into the verifier). The restored wording matches what shipped: a boolean verifier + a separate staleness check in step 3.

Re-land plan

Once the corresponding spectrum-webhook PRs merge, cherry-pick the original commits back:

  • photon-hq/spectrum-webhook#32 lands → git cherry-pick 0bd63f4 (X-Spectrum-Event-Id docs)
  • photon-hq/spectrum-webhook#31 lands → git cherry-pick ed1c408 (verifyPhotonWebhook discriminated-union docs)

Build / typecheck

pnpm docs:generate ✅ · pnpm typecheck:docs ✅ · pnpm lint ✅ (pre-commit hook ran all three on 3cb5379 before push).

@yanxue06 Yan Xue (yanxue06) merged commit fde8af9 into main May 28, 2026
3 of 4 checks passed
@yanxue06
Copy link
Copy Markdown
Member Author

Added commit dropping all poll / poll_option references — the product position is that polls aren't a supported content type yet, so the public docs shouldn't list them.

If poll arms appear in real webhook payloads, they fall through the SerializedUnknownContent tail (already documented), which matches the "unsupported" framing — customers see them as unknown content types rather than as a Photon-blessed shape.

@yanxue06
Copy link
Copy Markdown
Member Author

Added a follow-up commit dropping all avatar references — same framing as the poll strip above; the runtime serializer still handles avatar content, but the product position is "not officially supported yet" so the public webhook docs shouldn't list it.

If avatar arms appear in real webhook payloads they fall through the SerializedUnknownContent tail (already documented), which matches the "unsupported" framing — customers see them as unknown content types rather than as a Photon-blessed shape.

@yanxue06
Copy link
Copy Markdown
Member Author

Split the poll + avatar strip commits (cba3632, 2ad92de) out into #61 — they're a separate product decision ("not officially supported") and shouldn't ride with the wire-format-sync work in this PR.

Pushed two revert commits to bring this branch back to the wire-format-sync + unmerged-PR-revert state. This PR is now scoped purely to:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants