Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.soundpiece.co/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Supply a callback_url on the original PUT and we’ll POST the final response to that URL when the operation reaches ready or failed. This eliminates polling latency for production integrations. Webhooks follow the Standard Webhooks specification, so any standards-compliant client library can verify them out of the box.
Polling is canonical. Webhooks are a best-effort latency optimisation; if you didn’t receive one within a reasonable window, fall back to polling the GET endpoint. Don’t rely on webhook receipt as proof of operation state.

Registering a callback

Add callback_url to any PUT request body:
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
  "lyrics": "...",
  "prompt": "...",
  "callback_url": "https://your-app.example.com/webhooks/soundpiece"
}
Your endpoint must:
  • Be publicly accessible over HTTPS with a valid TLS certificate (self-signed certs are rejected).
  • Return a 2xx status code within 10 seconds.
  • Handle duplicate deliveries idempotently — use the webhook-id header as your deduplication key.
We re-verify the URL against the same rules at delivery time, so DNS rebinding to a private IP between submission and delivery is rejected too.

Payload shape

The body is the same DTO you’d get from polling, wrapped:
{
  "type": "create.new_song.ready",
  "data": {
    "id": "op_01h7k...",
    "status": "ready",
    "created_at": "2026-05-20T12:34:56Z",
    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
    "lyrics": "...",
    "prompt": "...",
    "reference": null,
    "outputs": [
      {
        "url": "https://cdn.soundpiece.co/...signed...",
        "expires_at": "2026-05-20T12:50:21Z",
        "duration": 87.4
      }
    ],
    "error": null
  }
}
type is <route>.<status> — either .ready or .failed — for fan-out routing on your side. The data field is exactly the DTO from the matching GET endpoint. Anything you can do with a polled response, you can do with a webhook payload. Correlate webhook deliveries with your own records using the echoed idempotency_key — you chose it on the PUT, so you already have it on your side.

Signature verification

Every delivery includes three headers (per the Standard Webhooks spec):
HeaderValue
webhook-idA stable id for this event. Retries reuse the same id, so consumers dedupe on it.
webhook-timestampUnix seconds at first delivery attempt.
webhook-signaturev1,<base64(HMAC-SHA256(secret, "<id>.<timestamp>.<body>"))>. Multiple signatures are space-separated during secret rotation.
To verify a webhook:
  1. Compute HMAC-SHA256(your_signing_secret, "<webhook-id>.<webhook-timestamp>.<raw-body>").
  2. Base64-encode it.
  3. Compare against the v1,… signature in webhook-signature using constant-time comparison.
  4. Reject if the signature doesn’t match, or if webhook-timestamp is more than 5 minutes from the current time.
Your webhook signing secret is shown once when you create your API key — it’s the whsec_… value. See authentication. There are official libraries that hide the HMAC math. From github.com/standard-webhooks/standard-webhooks:
# pip install standardwebhooks
from standardwebhooks import Webhook
from flask import Flask, request, abort

app = Flask(__name__)
wh = Webhook(os.environ["SOUNDPIECE_WEBHOOK_SECRET"])

@app.post("/webhooks/soundpiece")
def handle():
    try:
        payload = wh.verify(request.get_data(), request.headers)
    except Exception:
        abort(403)

    event_type = payload["type"]                # e.g. "create.new_song.ready"
    op = payload["data"]                        # the same DTO you'd poll

    if op["status"] == "ready":
        for output in op["outputs"]:
            ...  # download from output["url"]
    elif op["status"] == "failed":
        log.error("soundpiece op failed: %s", op["error"])

    return "", 200
Libraries for Go, Ruby, Kotlin, Rust, and PHP are available from the same repository.

Manual verification

If you’d rather not pull in a library, here’s the equivalent in plain Python:
Python
import hmac
import hashlib
import time
import base64
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SOUNDPIECE_WEBHOOK_SECRET"]  # whsec_<...>
# Strip the whsec_ prefix and base64-decode the key material
key = base64.b64decode(SECRET.removeprefix("whsec_") + "==")

@app.post("/webhooks/soundpiece")
def handle():
    webhook_id = request.headers["webhook-id"]
    webhook_ts = request.headers["webhook-timestamp"]
    webhook_sig = request.headers["webhook-signature"]
    body = request.get_data()

    # 5-minute timestamp window
    if abs(time.time() - int(webhook_ts)) > 300:
        abort(403)

    signed = f"{webhook_id}.{webhook_ts}.{body.decode()}".encode()
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # Compare against every signature in the (space-separated) header
    sigs = [s.split(",", 1)[1] for s in webhook_sig.split() if s.startswith("v1,")]
    if not any(hmac.compare_digest(expected, sig) for sig in sigs):
        abort(403)

    payload = request.get_json()
    ...
Always use a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, equivalent in your language). Direct == comparison leaks information about the secret via timing.

Retry behaviour

If your endpoint doesn’t return a 2xx, we retry with exponential backoff up to 3 attempts:
AttemptDelay before retry
1st retry1 second
2nd retry5 seconds
3rd retry30 seconds
ResponseAction
2xxDelivered. We won’t call again for this operation.
4xx (except 429)Fail immediately — this indicates a configuration issue we can’t fix by retrying.
5xx, 429, network error or timeoutRetry.
After the final retry attempt fails we give up and log the failure on our side. The operation result remains available via the GET endpoint indefinitely — fall back to polling if you suspect a delivery was missed.

Secret rotation

To rotate your webhook signing secret without dropping deliveries:
  1. Create a new key in the dashboard (which generates a new signing secret too).
  2. Roll out the new secret to your webhook handler.
  3. During the cutover, our webhook-signature header includes both signatures (space-separated, v1,<new> v1,<old>) for deliveries from operations submitted under the old key. The Standard Webhooks libraries handle multi-signature verification automatically.
  4. Once you’re confident every old-key operation has drained, revoke the old key.

Local testing

Use a tunnelling tool like ngrok to expose your local server during development:
ngrok http 3000
# → Forwarding https://abc123.ngrok.io → http://localhost:3000
Then pass the ngrok URL as callback_url when submitting test operations.