Learn how to verify signed callbacks/webhooks (delivery receipts), restrict access via IP whitelisting, and how our circuit breaker protects your endpoint from cascading failures.
Every callback we send to your endpoint is signed using HMAC-SHA256. This lets you verify that the request genuinely came from us and has not been tampered with.
The signature is computed over the timestamp and the raw request body:HMAC-SHA256(api_client_secret,"{X-Timestamp}.{body}")where body is the exact JSON string of the request body (no reformatting or whitespace changes).
Follow these steps in your callback handler to authenticate each inbound request:
1
Read the headers
Extract the X-Timestamp and X-Signature headers from the incoming request.
2
Check the timestamp
Reject the request if X-Timestamp is too old or too far in the future (we recommend a 5-minute tolerance window). This protects against replay attacks where an attacker resends a previously captured request.
3
Recompute the signature
Using your API client secret (UTF-8 encoded), compute:
HMAC-SHA256("{X-Timestamp}.{raw request body}")
Make sure you use the raw, unmodified request body bytes — do not parse and re-serialize the JSON.
4
Compare the signatures
Base64-encode your computed HMAC, then compare it against the value in X-Signature (strip the sha256= prefix first). If they match, the request is authentic. If they don’t match, reject it with 401 Unauthorized.
When you regenerate your API client secret, signed callbacks may continue to
be generated with your previous secret for up to 5 minutes. During this
propagation window, verify signatures against both the old and new secrets.
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "math" "strconv" "strings" "time")func verifyCallback( apiClientSecret, timestampHeader, signatureHeader, rawBody string,) bool { // Reject requests whose timestamp is more than 5 minutes old or in the future isFresh := func(ts string) bool { timestamp, err := strconv.ParseInt(ts, 10, 64) return err == nil && math.Abs(float64(time.Now().Unix()-timestamp)) <= 300 } // Sign an arbitrary payload string with the API client secret computeHmac := func(payload string) string { mac := hmac.New(sha256.New, []byte(apiClientSecret)) mac.Write([]byte(payload)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } // hmac.Equal provides a constant-time comparison to prevent timing attacks return isFresh(timestampHeader) && hmac.Equal( []byte(computeHmac(timestampHeader+"."+rawBody)), // sign "{timestamp}.{body}" []byte(strings.TrimPrefix(signatureHeader, "sha256=")), // strip the "sha256=" prefix )}
defmodule CallbackVerifier do @max_age_seconds 300 def verify_callback(api_client_secret, timestamp_header, signature_header, raw_body) do with {timestamp, _} <- Integer.parse(timestamp_header), true <- fresh?(timestamp) do # Build the signed payload, compute its HMAC, then compare using a # constant-time function to prevent timing attacks "#{timestamp_header}.#{raw_body}" |> compute_hmac(api_client_secret) |> Plug.Crypto.secure_compare(String.replace_prefix(signature_header, "sha256=", "")) else _ -> false end end # Returns true when the timestamp is within the allowed 5-minute tolerance window defp fresh?(timestamp), do: abs(System.os_time(:second) - timestamp) <= @max_age_seconds # Signs a payload with the given secret defp compute_hmac(payload, secret), do: :crypto.mac(:hmac, :sha256, secret, payload) |> Base.encode64()end
module CallbackVerifier (verifyCallback) whereimport Control.Monad (guard)import Control.Monad.IO.Class (liftIO)import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)import Crypto.Hash.Algorithms (SHA256)import Crypto.MAC.HMAC (HMAC, hmac)import Data.ByteArray.Encoding (Base (Base64), convertToBase)import Data.ByteString (ByteString)import qualified Data.ByteString.Char8 as BCimport Data.Maybe (isJust)import Data.Time.Clock.POSIX (getPOSIXTime)verifyCallback :: ByteString -- ^ API client secret -> ByteString -- ^ X-Timestamp header value -> ByteString -- ^ X-Signature header value -> ByteString -- ^ Raw request body -> IO BoolverifyCallback apiClientSecret timestampHeader signatureHeader rawBody = -- runMaybeT turns any failed `guard` into Nothing; isJust maps that to Bool isJust <$> runMaybeT (do now <- liftIO $ round <$> getPOSIXTime let timestamp = read (BC.unpack timestampHeader) :: Int -- Reject requests more than 5 minutes old or in the future guard $ abs (now - timestamp) <= 300 let expected = convertToBase Base64 (hmac apiClientSecret (timestampHeader <> "." <> rawBody) :: HMAC SHA256) received = BC.drop 7 signatureHeader -- strip the "sha256=" prefix -- guard short-circuits to Nothing (→ False) when signatures do not match guard $ expected == received)
Always use a timing-safe comparison (e.g. crypto.timingSafeEqual,
hmac.compare_digest, hash_equals) when comparing signatures. Regular
string equality is vulnerable to timing attacks.
As an additional layer of defense, you can restrict your callback endpoint to only accept inbound requests from our IP address ranges. Any request arriving from an address outside this list can be rejected at the network or application level before your handler code even runs.
To protect both your infrastructure and ours from cascading failures, we apply a per-URL circuit breaker to all outbound callbacks. If your endpoint becomes temporarily unavailable, the circuit breaker automatically backs off and retries without flooding your server.
With the default settings, successive circuit opens produce the following reset timeouts (before jitter is applied):
Open #
Reset timeout
1st
1 minute
2nd
2 minutes
3rd
4 minutes
4th
8 minutes
5th +
10 minutes(capped)
The ±20 % jitter means the actual wait time varies slightly around each value
above — for example, a 4-minute timeout becomes somewhere between 3 min 12
s and 4 min 48 s. This prevents multiple circuit breakers from all
attempting recovery at exactly the same moment.
Return HTTP 200 quickly. Acknowledge the callback as soon as you receive it and process it asynchronously. This keeps your response time well under the 5-second call timeout.
Keep your endpoint healthy. A single URL accumulates failures independently — a slow endpoint will trip its own breaker without affecting other URLs you have registered.
Monitor for silent skips. While the circuit is open, callbacks are not delivered and are not retried after the circuit closes. Make sure your integration does not rely solely on callbacks for critical state updates.