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
- How to add an MCP server to ChatGPT with developer mode
Turn on ChatGPT developer mode, create an app for the server's URL, and sign in with OAuth. The steps, with Sume's hosted MCP server as the example.
- How to add subtitles to a video in Python
Add subtitles to a video in Python with Requests: POST the video URL to Sume's /v1/video-captions, poll the job, then read the captioned video_url.
- Add Sume to Claude as a custom connector (remote MCP)
Add Sume's hosted MCP server to Claude under Customize > Connectors, see what Sume's OAuth consent grants, and decide whether to allow paid tools.
- Airflow HTTP sensor: wait for an AI video job to finish
Submit an AI video job with Airflow's HttpOperator, then wait with an HttpSensor in reschedule mode that passes once the job's status is completed.
Written by Sume