docs(webhooks): sync wire format + verification docs with shipped/about-to-ship behaviour#60
Conversation
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>
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughDocumentation for webhook message content payloads is substantially expanded. The ChangesWebhook Content Type Schema and Reference Documentation
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (8)
docs-src/webhooks/events.mdx.vel (2)
198-199: ⚡ Quick winSimplify 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 winSplit the description into multiple sentences.
The
X-Spectrum-Event-Iddescription 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 winSplit 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 winSplit 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 winSplit 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 winBreak 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 winSplit 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 winBreak 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
📒 Files selected for processing (5)
docs-src/webhooks/events.mdx.velwebhooks/delivery.mdxwebhooks/overview.mdxwebhooks/troubleshooting.mdxwebhooks/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.mdxwebhooks/overview.mdxwebhooks/verifying-signatures.mdxwebhooks/troubleshooting.mdx
docs-src/**/*.mdx.vel
📄 CodeRabbit inference engine (CLAUDE.md)
docs-src/**/*.mdx.vel: Pull type information through vellum from.d.tsfiles instead of writing it by hand - usesym.signaturefor full declarations,sym.members[]for interface properties,sym.variants[]for enums/discriminated unions, andsym.doc.summaryfor descriptions
ImportTypeTooltipfrom/snippets/type-tooltip.mdxin every.mdx.velfile 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("<", "<") | replace(">", ">")
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>
Realigning with spectrum-webhook
|
| 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 referenceaccordion indocs-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 theSerializedUnknownContentfall-through tailattachment/voicestripread()/stream()thunks and the server-internalpathcontact.rawpass-through (provider-native vCard /nativeCard, JSON-normalized via the cycle-safe walker)richlinkships{ type, url }only (no OG resolution at delivery)reaction/reply/edittargetfields ship as slimSerializedMessageRef({ id, platform, timestamp, sender?, contentPreview? })avatarset 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 = 300export,now/toleranceSecondsparams, "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#32lands →git cherry-pick 0bd63f4(X-Spectrum-Event-Id docs)photon-hq/spectrum-webhook#31lands →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).
|
Added commit dropping all If poll arms appear in real webhook payloads, they fall through the |
|
Added a follow-up commit dropping all If avatar arms appear in real webhook payloads they fall through the |
|
Split the poll + avatar strip commits ( 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:
|
Summary
Brings the public webhook docs in line with what's shipped (or about to ship) in
spectrum-webhookafter PRs #46, #32, #31:content: unknown. Function thunks (read(),stream(), async OG accessors) and server-internal fields (pathon attachment/voice) stripped before delivery;contactkeeps arawpass-through;richlinkships{ type, url }only;reaction/reply/edittargets ship as slimSerializedMessageRefinstead of recursing the full target.X-Spectrum-Event-Idheader onmessagesdeliveries (set tomessage.id, identical across retries, omitted on dynamic events).verifyPhotonWebhookenforces 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/
bytesStatusdirection) 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 thatmessage.idis the canonical handle for any future retrieval API.Commits
docs(webhooks): update wire format for per-arm projections—events.mdx.veldocs(webhooks): document X-Spectrum-Event-Id header—events.mdx.vel,delivery.mdx,overview.mdx,troubleshooting.mdxdocs(webhooks): document ±5min replay-window verification—verifying-signatures.mdxFiles touched
docs-src/webhooks/events.mdx.vel(vellum source forwebhooks/events.mdx)webhooks/delivery.mdxwebhooks/overview.mdxwebhooks/troubleshooting.mdxwebhooks/verifying-signatures.mdxThe rendered
webhooks/events.mdxis gitignored and built by CI from the.velsource viapnpm docs:generate.Test plan
pnpm docs:generate— vellum renders all 32 templates includingwebhooks/events.mdx, llms-*.txt files generated cleanlypnpm typecheck:docs— all 129 code blocks across advanced-imessage / legacy / opensource configs type-checkpnpm lint— cleanbytesStatus,inline-bytes,meta: pending, async OG resolution, hybrid inline-bytes) anywhere indocs/webhooks/ordocs/docs-src/webhooks/GET /v1/messages/{id}/attachmentendpoint; 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 roadmapOut of scope (surfaced for follow-up)
verifyEitherrotation snippet inwebhooks/managing-webhooks.mdxuses a genericverify()helper rather than the canonicalverifyPhotonWebhook. It still works correctly with either return shape; aligning it with the discriminated-union shape would be a small polish PR.Made with Cursor
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.Summary by CodeRabbit