Tally webhook to a Sume music job: verify, dedupe, return 200 fast
Tally signs with base64 HMAC-SHA256, waits 10 s and retries up to a day. Verify the signature, use eventId as the Idempotency-Key, then start the Sume job.

A Tally form can feed a music brief straight into Sume. Verify the Tally-Signature header, which is a base64 HMAC-SHA256 of the payload, return 200 within Tally's 10-second timeout, and use the payload's eventId as the Idempotency-Key for the Sume request. A Tally retry then reuses the first job instead of creating and paying for a second one.
Tally's delivery rules
From Tally's webhook help page:
| Topic | Tally documents |
|---|---|
| Signature header | Tally-Signature |
| Algorithm | HMAC-SHA256, base64 encoded |
| Timeout | 10 seconds |
| Retries | After 5 minutes, 30 minutes, 1 hour, 6 hours and 1 day |
| Payload fields | eventId, eventType, createdAt, and the form data |
The handler
Compute the digest over the raw request bytes, compare in constant time, then call Sume with the event id as the key. The Music Router accepts a prompt of 1 to 5,000 characters and has no duration field, so map the form's length answer into the prompt text. The request starts a job in the default async mode and returns an id in the envelope; you do not wait for the track.
import base64, hashlib, hmac, json, os, urllib.request
def handle(raw: bytes, signature: str) -> int:
secret = os.environ["TALLY_SIGNING_SECRET"]
if not secret:
raise RuntimeError("empty secret")
mac = hmac.new(secret.encode(), raw, hashlib.sha256).digest()
if not hmac.compare_digest(base64.b64encode(mac).decode(), signature):
return 401
event = json.loads(raw)
prompt = "Instrumental bed for: " + str(event["data"]["fields"][0]["value"])
req = urllib.request.Request(
"https://api.sume.com/v1/music-router/generate",
data=json.dumps({"prompt": prompt[:5000]}).encode(),
headers={"Authorization": "Bearer " + os.environ["SUME_API_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": "tally-" + event["eventId"]})
urllib.request.urlopen(req, timeout=8).read()
return 200Why the event id is the key
Tally retries on a ladder that reaches a full day, so one submission can arrive several times, including after your service was down. The eventId is stable across those tries. The same key sent to Sume means one generation, not a $0.125 charge for each retry.
Limits
The field positions in the example are a placeholder: read the form's field labels instead of an index. Keep the timeout of the Sume call below Tally's 10 seconds, or accept the event, store it, and start the job from a worker. If Tally disables or abandons a webhook after the retry ladder, nothing replays it, so keep a record of failed events yourself.
Before you ship
- Reject requests whose signature does not match before parsing the body.
- Return 200 only after the event is stored or the Sume call is accepted.
- Use eventId, not the submission time, as the dedupe key.
- Keep the Sume key and the Tally secret in environment variables; never print them.
Sources
Related posts
More in Integrations
- Telegram sendAudio for a Sume track: URL 20 MB, upload 50 MB
Telegram can fetch a sendAudio file from a URL up to 20 MB, or accept an upload up to 50 MB. Pass the Sume artifact link first; upload only as a fallback.
- Threads link limit: 5 unique URLs per post, checked before publish
Threads allows 5 unique URLs per post and returns THREADS_API__LINK_LIMIT_EXCEEDED past that. Count unique links in the caption before posting a Sume clip.
- Threads topic tag: 1 to 50 characters, no periods or ampersands
Meta's Threads API accepts a topic tag of 1 to 50 characters and rejects periods and ampersands. Validate the tag in code before you publish a Sume clip.
- TikTok import get_transcript: TikTok only, plan the Instagram fallback
get_transcript on media-imports is TikTok only; Instagram imports return no transcript. Fallback: video_inspect transcribe at $0.01 a minute.
Written by Sume