From fcf5d1b8bc45e2de84a549c6c3491bb2780b9e39 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:14:48 +0800 Subject: [PATCH] docs(webhooks): critical-path callout and Rust signature example - Add a top-of-page warning: never gate a payment's outcome on receiving a webhook; webhooks are a notification layer, not the system of record (Riaz's feedback, backed by Stripe/Shopify/ Coinbase precedents) - Restructure webhooks vs. polling into the three-layer pattern: polling as source of truth, webhooks for speed, reconciliation as backstop - Add a Rust signature verification example (hmac/sha2/base64 crates; Rust stdlib has no crypto), validated against the test vector - Note the Rust crate exception in the agent prompt Co-Authored-By: Claude Fable 5 --- copy-agent-prompt.js | 2 ++ payments/webhooks.mdx | 64 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/copy-agent-prompt.js b/copy-agent-prompt.js index 9b2a5d0..441133d 100644 --- a/copy-agent-prompt.js +++ b/copy-agent-prompt.js @@ -46,6 +46,8 @@ " payment.settled.", "", "6. Use only the standard library for the crypto. Do not add a dependency.", + " (Exception: Rust's standard library has no crypto; use the hmac, sha2,", + " and base64 crates.)", "", "Verify the implementation against this test vector (skip the timestamp", "tolerance check for the vector only, since the timestamp is in the past):", diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index a8c6cc6..887a4a6 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -6,13 +6,19 @@ description: "Get notified in real time as payments move through their lifecycle Webhooks push payment lifecycle events to an HTTPS endpoint you control, as they happen. Enhance your integration by registering an endpoint once: we `POST` an event to it whenever a payment is created, starts processing, succeeds, fails, expires, is cancelled, or settles. + +**Never gate a payment's outcome on receiving a webhook.** Webhooks are a fast notification layer, not your system of record. Delivery is best-effort: an event can arrive late, out of order, more than once, or, after an outage longer than the retry window, not at all. Critical actions such as releasing goods, crediting a balance, or marking an order paid must be driven by payment state, never by whether a webhook showed up. + + ## Webhooks vs. polling -Polling [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) is the primary way to validate payment status during the purchase cycle: create the payment, then poll until the status is final. Webhooks come on top as an enhancement. They usually tell you first, and with far fewer requests, so you can update the order status in your UI, notify a customer, or kick off downstream processing the moment something happens. +A robust integration uses three layers: -When a webhook event arrives, its payload already contains everything you need. Every event is a full signed snapshot of the payment and carries a monotonic `payment_state_version`, so verifying the signature, deduplicating by `id`, and applying the version guard is enough; there is no need to call the API from inside your handler. +1. **Source of truth: polling.** [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) is the primary way to validate payment status during the purchase cycle: create the payment, then poll until the status is final. +2. **Speed: webhooks.** They usually tell you first, and with far fewer requests, so you can update the order status in your UI, notify a customer, or kick off downstream processing the moment something happens. When an event arrives, its payload already contains everything you need: every event is a full signed snapshot of the payment with a monotonic `payment_state_version`, so verifying the signature, deduplicating by `id`, and applying the version guard is enough. There is no need to call the API from inside your handler. +3. **Backstop: reconciliation.** A periodic job that re-checks any payment your records still show in a non-final state after its `expires_at` has passed (see [Retries](#retries)) catches anything the other two layers missed. -Webhook delivery is **at-least-once** and **not guaranteed to be in order** (see [Delivery guarantees](#delivery-guarantees)). A well-built integration works correctly even if a webhook arrives twice, late, out of order, or not at all. +Webhook delivery is **at-least-once** and **not guaranteed to be in order**; the [Delivery guarantees](#delivery-guarantees) section shows how to handle both. ## Events @@ -239,7 +245,7 @@ Verification is four steps: 3. **Compare.** The delivery is authentic if your digest equals any `v1` entry in `webhook-signature`, using a constant-time comparison. 4. **Check the timestamp.** Reject deliveries whose `webhook-timestamp` is more than 5 minutes from the current time, to prevent replay attacks. -Dependency-free implementations, standard library only: +Implementations with no webhook SDK, using each language's standard library (Rust's standard library has no crypto, so it uses the `hmac`, `sha2`, and `base64` crates): @@ -356,6 +362,56 @@ func VerifyWebhook(secret string, headers http.Header, rawBody []byte) error { } ``` +```rust Rust +// crates: hmac = "0.12", sha2 = "0.10", base64 = "0.22" +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TOLERANCE_S: i64 = 5 * 60; + +pub fn verify_webhook( + secret: &str, + id: &str, + timestamp: &str, + signatures: &str, + raw_body: &[u8], +) -> Result<(), &'static str> { + let ts: i64 = timestamp.parse().map_err(|_| "invalid timestamp")?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| "clock error")? + .as_secs() as i64; + if (now - ts).abs() > TOLERANCE_S { + return Err("timestamp outside tolerance"); + } + + let key = BASE64 + .decode(secret.strip_prefix("whsec_").ok_or("missing whsec_ prefix")?) + .map_err(|_| "invalid secret")?; + + let mut mac = Hmac::::new_from_slice(&key).map_err(|_| "invalid key")?; + mac.update(id.as_bytes()); + mac.update(b"."); + mac.update(timestamp.as_bytes()); + mac.update(b"."); + mac.update(raw_body); + + for entry in signatures.split(' ') { + if let Some(sig) = entry.strip_prefix("v1,") { + if let Ok(sig_bytes) = BASE64.decode(sig) { + // verify_slice is constant-time + if mac.clone().verify_slice(&sig_bytes).is_ok() { + return Ok(()); + } + } + } + } + Err("no matching signature") +} +``` + ```java Java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec;