Arkestro Webhooks API
# Verifying webhook signatures Arkestro signs **every** outbound webhook with **HMAC-SHA256**. The instructions below apply to all webhook events. Before you trust a delivery, verify the signature so you can be sure the request came from Arkestro and was not tampered with in transit. ## Headers Every delivery includes these headers: | Header | Description | | --- | --- | | `X-Arkestro-Signature` | One or more HMAC-SHA256 signatures of the request, each formatted `sha256=` and joined by commas with no whitespace (e.g. `sha256=5d61...,sha256=9af2...`). Multiple values appear during secret rotation — see below. | | `X-Arkestro-Timestamp` | The Unix timestamp (epoch **seconds**, as a string) at which the request was signed, e.g. `1748290800`. This value is part of the signed payload. | | `X-Arkestro-Idempotency-Key` | A UUID identifying the delivery **event**. It is generated once when the event fires and stays **constant across all retry attempts** of that delivery, so you can use it to deduplicate retries on your end. Unlike `request_id` in the body, which changes on every attempt, this value does not. It is **not** part of the signed payload. | ## How the signature is computed Arkestro builds a **signed payload** by concatenating the timestamp, a literal `.`, and the raw request body: ```text signed_payload = "{X-Arkestro-Timestamp}.{raw_request_body}" ``` It then computes `HMAC-SHA256(secret, signed_payload)` as a lowercase **hex** digest and prefixes it with `sha256=`. Folding the timestamp into the digest means a replayed request with a tampered timestamp will fail verification. ## How to verify a delivery 1. Read the `X-Arkestro-Timestamp` and `X-Arkestro-Signature` headers. 2. *(Recommended)* Reject the delivery if the timestamp is outside a tolerance window (e.g. ±5 minutes from now) to limit replay attacks. 3. Rebuild the signed payload as `"{timestamp}.{raw_body}"` using the **raw request bytes** — not a re-serialized or pretty-printed copy of the JSON. Re-encoding the body will change the bytes and break verification. 4. Compute `sha256=` + the hex HMAC-SHA256 of the signed payload using your signing secret. 5. Split `X-Arkestro-Signature` on `,` and check, using a **constant-time** comparison, whether your computed value matches any of the provided values. Accept the delivery if at least one matches. ## Secret rotation and multiple signatures `X-Arkestro-Signature` can contain **more than one** `sha256=` value. Arkestro signs each delivery with every secret that is currently active, including a previous secret that is still within its rotation grace period. This lets you rotate secrets with zero missed deliveries: - While both the old and new secret are active, Arkestro sends a signature for each. A verifier holding either secret will find a matching value. - Once you have switched to the new secret and the old one expires, only the new signature is sent. When verifying, always **split the header on commas and accept if any value matches** a digest computed with any secret you currently hold. Never assume a single signature. ## Code examples Each example reads the raw request body, rebuilds the signed payload, splits the signature header on commas, and compares each candidate against a locally computed digest using a constant-time comparison. `secrets` is the list of signing secrets you currently hold (one normally, two during a rotation). ### Node.js ```js const crypto = require('crypto'); // rawBody must be the exact bytes received (e.g. a string or Buffer), // not JSON.parse'd and re-stringified. function verifyWebhook(rawBody, headers, secrets, toleranceSec = 300) { const timestamp = headers['x-arkestro-timestamp']; const signatureHeader = headers['x-arkestro-signature']; if (!timestamp || !signatureHeader) return false; // Optional replay protection. const age = Math.floor(Date.now() / 1000) - Number(timestamp); if (!Number.isFinite(age) || Math.abs(age) > toleranceSec) return false; const signedPayload = `${timestamp}.${rawBody}`; const provided = signatureHeader.split(','); return secrets.some((secret) => { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex'); const expectedBuf = Buffer.from(expected); return provided.some((candidate) => { const candidateBuf = Buffer.from(candidate); return ( candidateBuf.length === expectedBuf.length && crypto.timingSafeEqual(candidateBuf, expectedBuf) ); }); }); } ``` ### Python ```python import hashlib import hmac import time def verify_webhook(raw_body: bytes, headers, secrets, tolerance_sec: int = 300) -> bool: timestamp = headers.get("X-Arkestro-Timestamp") signature_header = headers.get("X-Arkestro-Signature") if not timestamp or not signature_header: return False # Optional replay protection. Fail closed on a malformed timestamp header. try: age = abs(int(time.time()) - int(timestamp)) except (TypeError, ValueError): return False if age > tolerance_sec: return False # raw_body must be the exact bytes received. signed_payload = f"{timestamp}.".encode() + raw_body provided = signature_header.split(",") for secret in secrets: digest = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() expected = f"sha256={digest}" if any(hmac.compare_digest(expected, candidate) for candidate in provided): return True return False ``` ### Ruby ```ruby require "openssl" require "active_support/security_utils" # raw_body must be the exact request body string received (e.g. request.raw_post). def verify_webhook(raw_body, headers, secrets, tolerance_sec: 300) timestamp = headers["X-Arkestro-Timestamp"] signature_header = headers["X-Arkestro-Signature"] return false if timestamp.nil? || signature_header.nil? # Optional replay protection. return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_sec signed_payload = "#{timestamp}.#{raw_body}" provided = signature_header.split(",") secrets.any? do |secret| digest = OpenSSL::HMAC.hexdigest("sha256", secret, signed_payload) expected = "sha256=#{digest}" provided.any? { |candidate| ActiveSupport::SecurityUtils.secure_compare(expected, candidate) } end end ``` # Responding to a delivery Your endpoint's HTTP response tells Arkestro whether the delivery succeeded. - **Acknowledge with a `2xx` status code.** Any `2xx` (e.g. `200`, `201`, `202`, `204`) is treated identically as success. The response body is ignored. - **Respond promptly.** Arkestro waits up to **10 seconds** for a response. Do any slow processing asynchronously (enqueue it and return `2xx` right away) so you don't exceed the timeout. - **Anything else is a failed delivery.** A non-`2xx` status code, a timeout, or a connection error is treated as a failure. Whether Arkestro retries depends on the status code — see the retry schedule below. In particular, `4xx` errors (except `429`) are treated as permanent failures and will **not** be retried. ## Retry schedule Arkestro retries failed deliveries automatically. The retry behavior depends on the failure type. **What gets retried:** `429`, `5xx`, transport errors (timeouts, connection failures). **What is not retried (delivery is discarded):** `4xx` (except `429`). Arkestro makes up to **10 retry attempts** using exponential backoff. If all attempts are exhausted without a `2xx` response, the delivery is permanently abandoned. The final retry occurs approximately **4–5 hours** after the initial attempt.