{"templateId":"markdown","sharedDataIds":{"sidebar":"sidebar-sidebars.yaml"},"props":{"metadata":{"markdoc":{"tagList":["admonition"]},"type":"markdown"},"seo":{"title":"Webhooks"},"dynamicMarkdocComponents":[],"compilationErrors":[],"ast":{"$$mdtype":"Tag","name":"article","attributes":{},"children":[{"$$mdtype":"Tag","name":"Heading","attributes":{"level":1,"id":"webhooks","__idx":0},"children":["Webhooks"]},{"$$mdtype":"Tag","name":"Admonition","attributes":{"type":"info","name":"Beta"},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Webhooks are in Beta and available to all customers. Additive changes are normal,"," ","and breaking changes may be made with notice while we incorporate customer feedback."," ","See ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/docs/v1/versioning"},"children":["Versioning and compatibility"]},"."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Webhooks notify your application when something happens in Rho, such as a new transaction being created. Rho sends an HTTP ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["POST"]}," request to your endpoint with the event data, so you don't have to keep checking the API for changes."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"setting-up-a-webhook","__idx":1},"children":["Setting up a webhook"]},{"$$mdtype":"Tag","name":"ol","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["In Rho, go to ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"https://app.rho.co/settings/webhooks"},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Business Settings > API > Webhooks"]}]},"."]}]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Click ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Add Endpoint"]},"."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"img","attributes":{"src":"/assets/endpoints.d06a09522f2112644a8d7add0e4c426c80221bc7189d079a2e470eac22f1a954.5e6b7205.png","alt":"Webhooks settings with the endpoint list and Add Endpoint button. The existing endpoint URL is redacted."},"children":[]}]}]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Enter an ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Endpoint URL"]}," - the HTTPS address in your application that will receive the requests."]}]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Select the events you want to receive, such as ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["transaction.created"]},"."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"img","attributes":{"src":"/assets/add-endpoint.b4ac9dbe1c9a952080e565146abfdad98b250d8c525226666e28c0f25270d227.5e6b7205.png","alt":"Webhooks settings with the New Endpoint form, Endpoint URL, event subscriptions, and Create button."},"children":[]}]}]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Click ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Create"]},". On the endpoint details page, you'll find its ",{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Signing Secret"]},". Copy it into your application's secret manager. You'll need it to verify incoming requests."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"img","attributes":{"src":"/assets/signing-secret.c722932b11d5ff8a6c468603ea0b85fbb8326fb30d2c3665234a6cff65dd8d70.5e6b7205.png","alt":"Webhooks settings with endpoint details, subscribed events, and the masked Signing Secret."},"children":[]}]}]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"checking-that-a-request-came-from-rho","__idx":2},"children":["Checking that a request came from Rho"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Before processing an event, verify its signature with the endpoint's Signing Secret. This checks that the request came from Rho and that its contents haven't changed along the way."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The Signing Secret is not an API Access Token. Keep it on your server, out of source control and logs. You don't send it back to Rho or include it in a response."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Each request includes three headers used for verification:"]},{"$$mdtype":"Tag","name":"div","attributes":{"className":"md-table-wrapper"},"children":[{"$$mdtype":"Tag","name":"table","attributes":{"className":"md"},"children":[{"$$mdtype":"Tag","name":"thead","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"Header"},"children":["Header"]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"What it contains"},"children":["What it contains"]}]}]},{"$$mdtype":"Tag","name":"tbody","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-id"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["The message ID. Retries of the same message keep this ID."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-timestamp"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["The time this delivery attempt was signed, in Unix seconds."]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-signature"]}]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":["One or more signatures to check against the request body."]}]}]}]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":3,"id":"verify-incoming-requests","__idx":3},"children":["Verify incoming requests"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Use ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"https://docs.svix.com/receiving/verifying-payloads/how"},"children":["Svix's maintained verification examples"]}," for your language and framework. When using them with Rho:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Use your endpoint's Signing Secret"]},", including the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["whsec_"]}," prefix. Load it from your secret manager or server environment."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Pass the raw request body"]},", before parsing JSON. Parsing and re-encoding it can change the bytes and break signature verification."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Use the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-*"]}," headers listed above."]}," Some examples use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["svix-id"]},", ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["svix-timestamp"]},", and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["svix-signature"]}," instead. The Svix libraries accept Rho's ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-*"]}," names directly, so you don't need to rename incoming headers."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Keep timestamp verification enabled."]}," Reject timestamps more than five minutes away from your server's clock, and keep that clock synchronized. This helps reject old requests being replayed, but doesn't replace duplicate handling."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Reject requests that fail verification."]}," Only process or store the event after verification succeeds, and return ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["2xx"]}," only after processing or durable storage succeeds."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["If verification fails"]},", check that you're using the secret for the right endpoint, all three headers reach your handler, and no middleware parses or changes the body before verification. Don't work around an error by skipping the check."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":4,"id":"using-the-standard-library","__idx":4},"children":["Using the standard library"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["If you prefer not to use a verification library, this example uses Go's standard library alone. It checks the timestamp and compares the HMAC-SHA256 signature against each ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["v1"]}," entry in ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-signature"]},"."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Set ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["RHO_SIGNING_SECRET"]}," to the full Signing Secret, including ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["whsec_"]},". Save this as ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["main.go"]}," in a Go module and run it with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["go run ."]}," using Go 1.22 or later."]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"go","header":{"controls":{"copy":{}}},"source":"package main\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc main() {\n\tsecret := os.Getenv(\"RHO_SIGNING_SECRET\")\n\tkey, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, \"whsec_\"))\n\tif err != nil || len(key) == 0 {\n\t\tlog.Fatal(\"Set RHO_SIGNING_SECRET to a valid endpoint signing secret\")\n\t}\n\n\thttp.HandleFunc(\"POST /webhooks/rho\", func(w http.ResponseWriter, r *http.Request) {\n\t\tpayload, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))\n\t\tif err != nil {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tif !verifySignature(key, payload, r.Header) {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\t// Process or durably save the event here before acknowledging it.\n\n\t\tw.WriteHeader(http.StatusNoContent)\n\t})\n\n\tlog.Fatal(http.ListenAndServe(\":8080\", nil))\n}\n\nfunc verifySignature(key, payload []byte, headers http.Header) bool {\n\tmessageID := headers.Get(\"webhook-id\")\n\ttimestamp := headers.Get(\"webhook-timestamp\")\n\tsignedAt, err := strconv.ParseInt(timestamp, 10, 64)\n\tif err != nil || messageID == \"\" {\n\t\treturn false\n\t}\n\n\t// Reject old requests and timestamps too far in the future.\n\tnow := time.Now().Unix()\n\tif signedAt < now-300 || signedAt > now+300 {\n\t\treturn false\n\t}\n\n\tmac := hmac.New(sha256.New, key)\n\tmac.Write([]byte(messageID + \".\" + timestamp + \".\"))\n\tmac.Write(payload)\n\texpected := mac.Sum(nil)\n\n\tfor _, entry := range strings.Fields(headers.Get(\"webhook-signature\")) {\n\t\tversion, signature, ok := strings.Cut(entry, \",\")\n\t\tif !ok || version != \"v1\" {\n\t\t\tcontinue\n\t\t}\n\t\tactual, err := base64.StdEncoding.DecodeString(signature)\n\t\tif err == nil && hmac.Equal(expected, actual) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n","lang":"go"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["hmac.Equal"]}," compares signatures without leaking their contents through comparison timing. The event-processing step is left for you to fill in: return ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["2xx"]}," only after processing or durable storage succeeds, and return an error if it fails. Don't use the empty success path in production. The 1 MiB body limit is an example, not a Rho payload-size guarantee. For production, configure server timeouts and expose the endpoint over HTTPS."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"source-ip-addresses","__idx":5},"children":["Source IP addresses"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["If your endpoint is behind a firewall or IP allowlist, allow inbound webhook requests from these US source addresses:"]},{"$$mdtype":"Tag","name":"div","attributes":{"className":"md-table-wrapper"},"children":[{"$$mdtype":"Tag","name":"table","attributes":{"className":"md"},"children":[{"$$mdtype":"Tag","name":"thead","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"IP version"},"children":["IP version"]},{"$$mdtype":"Tag","name":"th","attributes":{"data-label":"Source address or range"},"children":["Source address or range"]}]}]},{"$$mdtype":"Tag","name":"tbody","attributes":{},"children":[{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["IPv4"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["44.228.126.217"]}]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["IPv4"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["50.112.21.217"]}]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["IPv4"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["52.24.126.164"]}]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["IPv4"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["54.148.139.208"]}]}]},{"$$mdtype":"Tag","name":"tr","attributes":{},"children":[{"$$mdtype":"Tag","name":"td","attributes":{},"children":["IPv6"]},{"$$mdtype":"Tag","name":"td","attributes":{},"children":[{"$$mdtype":"Tag","name":"code","attributes":{},"children":["2600:1f24:64:8000::/56"]}]}]}]}]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["The IPv6 entry is a CIDR range, not a single address."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["An IP allowlist does not replace signature verification. These addresses are shared with other webhook senders, so always verify each request with your endpoint's Signing Secret."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"handling-deliveries-safely","__idx":6},"children":["Handling deliveries safely"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Acknowledge only after you've accepted the event."]}," Return a ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["2xx"]}," response once you've processed it or saved it for later processing. If saving fails, return an error so delivery can be retried."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Expect duplicates."]}," Use ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhook-id"]}," to recognize a message you've already accepted. Return ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["2xx"]}," for that duplicate without doing the work again. Store this record durably, not just in memory, and account for requests arriving at the same time."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":[{"$$mdtype":"Tag","name":"strong","attributes":{},"children":["Keep the response quick."]}," Save the event to a durable queue before responding, then do slower work such as sending emails or calling other services in a worker. Starting a background task without saving it first can lose the event if your server restarts."]}]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["If your endpoint is unreachable, times out, or returns a non-",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["2xx"]}," response, delivery is retried. Retries are limited, so don't rely on them as permanent storage."]}]},"headings":[{"value":"Webhooks","id":"webhooks","depth":1},{"value":"Setting up a webhook","id":"setting-up-a-webhook","depth":2},{"value":"Checking that a request came from Rho","id":"checking-that-a-request-came-from-rho","depth":2},{"value":"Verify incoming requests","id":"verify-incoming-requests","depth":3},{"value":"Using the standard library","id":"using-the-standard-library","depth":4},{"value":"Source IP addresses","id":"source-ip-addresses","depth":2},{"value":"Handling deliveries safely","id":"handling-deliveries-safely","depth":2}],"frontmatter":{"seo":{"title":"Webhooks"}},"lastModified":"2026-10-23T10:00:23.883Z","pagePropGetterError":{"message":"","name":""}},"slug":"/docs/v1/webhooks","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}