Zendesk vs Sume webhook signatures: base64 no dot vs hex with a dot
Zendesk signs base64(HMAC-SHA256(timestamp + body)) with no separator; Sume signs timestamp.body as sume-v1 hex. One verifier cannot check both.

If your service receives both Zendesk and Sume webhooks, you need two verifiers. Zendesk signs base64(HMACSHA256(TIMESTAMP + BODY)) with nothing between the timestamp and the body. Sume signs <timestamp>.<raw_body>, with a dot, and sends the digest as hex behind a sume-v1= prefix. Copying one check to the other fails every request.
Side by side
Both vendors use HMAC-SHA256 over a timestamp and the raw body, which makes them look alike. The differences are in the details.
| Detail | Zendesk | Sume |
|---|---|---|
| Timestamp header | X-Zendesk-Webhook-Signature-Timestamp | x-sume-webhook-timestamp |
| Signature header | X-Zendesk-Webhook-Signature | x-sume-webhook-signature |
| Signed string | TIMESTAMP + BODY, no separator | <timestamp>.<raw_body>, with a dot |
| Encoding | Base64 | Hex, prefixed sume-v1= |
| During rotation | One signature | Comma-separated entries, newest first (24 hour overlap) |
Two functions
Keep them apart and name them for the vendor. The Sume SDK has verifyWebhook({ body, headers, secret }) for its side; the Zendesk side is a few lines of standard library code.
import base64, hashlib, hmac
def zendesk_ok(raw: bytes, ts: str, sig: str, secret: str) -> bool:
if not secret:
raise RuntimeError("empty secret")
mac = hmac.new(secret.encode(), ts.encode() + raw, hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(mac).decode(), sig)
def sume_ok(raw: bytes, ts: str, header: str, secret: str) -> bool:
if not secret:
raise RuntimeError("empty secret")
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256)
want = "sume-v1=" + mac.hexdigest()
return any(hmac.compare_digest(want, p.strip()) for p in header.split(","))The rotation detail
During a Sume secret rotation the header carries more than one signature, newest first, for 24 hours. The any(...) loop above accepts the request if any listed entry matches your secret, so a delivery signed during the overlap still verifies. Read the current value from /dashboard/webhooks or GET /v1/webhooks/signing-secret. Zendesk's page describes a single signature, so no such loop is needed there.
Limits
Parse the body only after the check passes, and compare the raw bytes you received, not a re-serialized object. A proxy that rewrites whitespace will break both schemes. Sume's timestamp is in epoch seconds and its helper checks a replay window of 300 seconds by default (toleranceSeconds); the example above leaves that check out for brevity, so add it before production.
Before you ship
- Keep one verifier per vendor; do not share code between them.
- Read the raw body bytes before any JSON parsing.
- Reject an empty secret at startup.
- Test with a real signed sample from each side.
- Compare with hmac.compare_digest or the platform equivalent, never with ==
- Handle a missing header as a rejection, not as an exception that returns 500.
Sources
Related posts
More in Integrations
- Botpress Execute Code card: call Sume's API without waiting on the run
Botpress's Execute Code card offers Axios for HTTP and no external libraries. Start a Sume run in one card and collect the result by webhook, not a wait loop.
- Braze Connected Content has 2 seconds: pre-render the Sume clip
Braze drops Connected Content that answers in over 2 s. A Sume avatar job takes longer, so render first, store the URL, and let the campaign read a fast lookup.
- Braze 404 renders empty: a fallback while the Sume clip renders
Braze turns a 404 into an empty string. Sume returns 409 job_not_completed until a result exists. Map one to a fallback URL so no message goes out blank.
- Braze retries a 500: keep the Sume job submit idempotent
Braze sends 500 and 502 responses to retry logic, and a :retry option exists. Give every Sume submit a stable Idempotency-Key so a retry is not a second bill.
Written by Sume