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.

4 min readSume
All posts

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:

Tally webhook facts (Tally Help Center, read 2026-10-05)
TopicTally documents
Signature headerTally-Signature
AlgorithmHMAC-SHA256, base64 encoded
Timeout10 seconds
RetriesAfter 5 minutes, 30 minutes, 1 hour, 6 hours and 1 day
Payload fieldseventId, 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 200

Why 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

All Integrations posts

Written by Sume